Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ with AxmeClient(config) as client:
print(result)
inbox = client.list_inbox(owner_agent="agent://example/receiver")
print(inbox)
changes = client.list_inbox_changes(owner_agent="agent://example/receiver", limit=50)
print(changes["next_cursor"], changes["has_more"])
replied = client.reply_inbox_thread(
"11111111-1111-4111-8111-111111111111",
message="Acknowledged",
Expand Down
13 changes: 12 additions & 1 deletion axme_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
from .client import AxmeClient, AxmeClientConfig
from .exceptions import AxmeError, AxmeHttpError
from .exceptions import (
AxmeAuthError,
AxmeError,
AxmeHttpError,
AxmeRateLimitError,
AxmeServerError,
AxmeValidationError,
)

__all__ = [
"AxmeClient",
"AxmeClientConfig",
"AxmeAuthError",
"AxmeError",
"AxmeHttpError",
"AxmeRateLimitError",
"AxmeServerError",
"AxmeValidationError",
]
93 changes: 79 additions & 14 deletions axme_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@

import httpx

from .exceptions import AxmeHttpError
from .exceptions import (
AxmeAuthError,
AxmeHttpError,
AxmeRateLimitError,
AxmeServerError,
AxmeValidationError,
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -40,9 +46,7 @@ def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:

def health(self) -> dict[str, Any]:
response = self._http.get("/health")
if response.status_code >= 400:
raise AxmeHttpError(response.status_code, response.text)
return response.json()
return self._parse_json_response(response)

def create_intent(
self,
Expand All @@ -62,27 +66,38 @@ def create_intent(
headers = {"Idempotency-Key": idempotency_key}

response = self._http.post("/v1/intents", json=request_payload, headers=headers)
if response.status_code >= 400:
raise AxmeHttpError(response.status_code, response.text)
return response.json()
return self._parse_json_response(response)

def list_inbox(self, *, owner_agent: str | None = None) -> dict[str, Any]:
params: dict[str, str] | None = None
if owner_agent is not None:
params = {"owner_agent": owner_agent}
response = self._http.get("/v1/inbox", params=params)
if response.status_code >= 400:
raise AxmeHttpError(response.status_code, response.text)
return response.json()
return self._parse_json_response(response)

def get_inbox_thread(self, thread_id: str, *, owner_agent: str | None = None) -> dict[str, Any]:
params: dict[str, str] | None = None
if owner_agent is not None:
params = {"owner_agent": owner_agent}
response = self._http.get(f"/v1/inbox/{thread_id}", params=params)
if response.status_code >= 400:
raise AxmeHttpError(response.status_code, response.text)
return response.json()
return self._parse_json_response(response)

def list_inbox_changes(
self,
*,
owner_agent: str | None = None,
cursor: str | None = None,
limit: int | None = None,
) -> dict[str, Any]:
params: dict[str, str] = {}
if owner_agent is not None:
params["owner_agent"] = owner_agent
if cursor is not None:
params["cursor"] = cursor
if limit is not None:
params["limit"] = str(limit)
response = self._http.get("/v1/inbox/changes", params=params or None)
return self._parse_json_response(response)

def reply_inbox_thread(
self,
Expand All @@ -104,6 +119,56 @@ def reply_inbox_thread(
json={"message": message},
headers=headers,
)
return self._parse_json_response(response)

def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
if response.status_code >= 400:
raise AxmeHttpError(response.status_code, response.text)
self._raise_http_error(response)
return response.json()

def _raise_http_error(self, response: httpx.Response) -> None:
body: Any | None
body = None
message = response.text
try:
body = response.json()
except ValueError:
body = None
else:
if isinstance(body, dict):
error_value = body.get("error")
if isinstance(error_value, str):
message = error_value
elif isinstance(error_value, dict) and isinstance(error_value.get("message"), str):
message = error_value["message"]
elif isinstance(body.get("message"), str):
message = body["message"]
elif isinstance(body, str):
message = body

retry_after = _parse_retry_after(response.headers.get("Retry-After"))
kwargs = {
"body": body,
"request_id": response.headers.get("x-request-id") or response.headers.get("request-id"),
"trace_id": response.headers.get("x-trace-id") or response.headers.get("trace-id"),
"retry_after": retry_after,
}
status_code = response.status_code
if status_code in (401, 403):
raise AxmeAuthError(status_code, message, **kwargs)
if status_code in (400, 409, 413, 422):
raise AxmeValidationError(status_code, message, **kwargs)
if status_code == 429:
raise AxmeRateLimitError(status_code, message, **kwargs)
if status_code >= 500:
raise AxmeServerError(status_code, message, **kwargs)
raise AxmeHttpError(status_code, message, **kwargs)


def _parse_retry_after(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value)
except ValueError:
return None
33 changes: 32 additions & 1 deletion axme_sdk/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from typing import Any


class AxmeError(Exception):
"""Base SDK exception."""
Expand All @@ -8,7 +10,36 @@ class AxmeError(Exception):
class AxmeHttpError(AxmeError):
"""Raised for non-success Axme API responses."""

def __init__(self, status_code: int, message: str) -> None:
def __init__(
self,
status_code: int,
message: str,
*,
body: Any | None = None,
request_id: str | None = None,
trace_id: str | None = None,
retry_after: int | None = None,
) -> None:
super().__init__(f"HTTP {status_code}: {message}")
self.status_code = status_code
self.message = message
self.body = body
self.request_id = request_id
self.trace_id = trace_id
self.retry_after = retry_after


class AxmeAuthError(AxmeHttpError):
"""Authentication or authorization failure."""


class AxmeValidationError(AxmeHttpError):
"""Request/contract validation failure."""


class AxmeRateLimitError(AxmeHttpError):
"""Rate limit exceeded."""


class AxmeServerError(AxmeHttpError):
"""Unexpected server-side failure."""
58 changes: 57 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest

from axme_sdk import AxmeClient, AxmeClientConfig
from axme_sdk.exceptions import AxmeHttpError
from axme_sdk.exceptions import AxmeAuthError, AxmeHttpError, AxmeRateLimitError, AxmeValidationError


def _transport(handler):
Expand Down Expand Up @@ -164,3 +164,59 @@ def handler(request: httpx.Request) -> httpx.Response:
owner_agent="agent://owner",
idempotency_key="reply-1",
) == {"ok": True, "thread": thread}


def test_list_inbox_changes_sends_pagination_params() -> None:
thread = _thread_payload()

def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert request.url.path == "/v1/inbox/changes"
assert request.url.params.get("owner_agent") == "agent://owner"
assert request.url.params.get("cursor") == "cur-1"
assert request.url.params.get("limit") == "50"
return httpx.Response(
200,
json={
"ok": True,
"changes": [{"cursor": "cur-2", "thread": thread}],
"next_cursor": "cur-2",
"has_more": True,
},
)

client = _client(handler)
assert client.list_inbox_changes(owner_agent="agent://owner", cursor="cur-1", limit=50) == {
"ok": True,
"changes": [{"cursor": "cur-2", "thread": thread}],
"next_cursor": "cur-2",
"has_more": True,
}


@pytest.mark.parametrize(
("status_code", "expected_exception"),
[
(401, AxmeAuthError),
(422, AxmeValidationError),
],
)
def test_client_maps_http_errors_to_typed_exceptions(status_code: int, expected_exception: type[Exception]) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, json={"message": "boom"})

client = _client(handler)
with pytest.raises(expected_exception):
client.health()


def test_client_maps_rate_limit_error_with_retry_after() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(429, json={"message": "too many"}, headers={"Retry-After": "30"})

client = _client(handler)
with pytest.raises(AxmeRateLimitError) as exc_info:
client.list_inbox()
assert exc_info.value.retry_after == 30
assert isinstance(exc_info.value.body, dict)
assert exc_info.value.body["message"] == "too many"