diff --git a/README.md b/README.md index 3d80198..3488a01 100644 --- a/README.md +++ b/README.md @@ -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", diff --git a/axme_sdk/__init__.py b/axme_sdk/__init__.py index f3504ef..f676591 100644 --- a/axme_sdk/__init__.py +++ b/axme_sdk/__init__.py @@ -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", ] diff --git a/axme_sdk/client.py b/axme_sdk/client.py index e900029..4e1ff7b 100644 --- a/axme_sdk/client.py +++ b/axme_sdk/client.py @@ -5,7 +5,13 @@ import httpx -from .exceptions import AxmeHttpError +from .exceptions import ( + AxmeAuthError, + AxmeHttpError, + AxmeRateLimitError, + AxmeServerError, + AxmeValidationError, +) @dataclass(frozen=True) @@ -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, @@ -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, @@ -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 diff --git a/axme_sdk/exceptions.py b/axme_sdk/exceptions.py index c372cb0..062db1e 100644 --- a/axme_sdk/exceptions.py +++ b/axme_sdk/exceptions.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + class AxmeError(Exception): """Base SDK exception.""" @@ -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.""" diff --git a/tests/test_client.py b/tests/test_client.py index 3884508..ef29b94 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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): @@ -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"