diff --git a/src/devhelm/_errors.py b/src/devhelm/_errors.py index 83588c0..c3bde0a 100644 --- a/src/devhelm/_errors.py +++ b/src/devhelm/_errors.py @@ -82,6 +82,11 @@ class DevhelmApiError(DevhelmError): The optional `request_id` field is the per-request id emitted by the API as the `X-Request-Id` response header and embedded in the JSON error body. Always include it in support tickets. + + The optional `retry_after` field is the parsed value of the + `Retry-After` response header in whole seconds. It's populated on + rate-limit (429) responses that include the header so callers can back + off for exactly as long as the server asked; ``None`` otherwise. """ status: int @@ -93,6 +98,7 @@ class DevhelmApiError(DevhelmError): # narrowing. (Subclasses still inherit the same `str` type.) code: str request_id: str | None + retry_after: int | None def __init__( self, @@ -103,6 +109,7 @@ def __init__( body: dict[str, Any] | str | None = None, code: str | None = None, request_id: str | None = None, + retry_after: int | None = None, ) -> None: super().__init__(message) self.status = status @@ -113,6 +120,9 @@ def __init__( # `err.code` is never ``None`` for callers switching on category. self.code = code or "API_ERROR" self.request_id = request_id + # Parsed from the `Retry-After` response header (seconds). Populated + # on 429 / 503 responses that include it; ``None`` otherwise. + self.retry_after = retry_after class DevhelmAuthError(DevhelmApiError): @@ -152,8 +162,28 @@ def __init__(self, message: str, *, cause: Exception | None = None) -> None: self.__cause__ = cause +def _parse_retry_after(value: str | None) -> int | None: + """Parse a ``Retry-After`` header value into whole seconds. + + The API emits ``Retry-After`` as an integer number of seconds. We parse + defensively: any non-integer value (an HTTP-date form, or garbage from a + misbehaving proxy) yields ``None`` rather than raising, so a malformed + header can never break error construction. + """ + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def error_from_response( - status: int, body: str, *, request_id: str | None = None + status: int, + body: str, + *, + request_id: str | None = None, + retry_after: str | None = None, ) -> DevhelmApiError: """Map an HTTP error response to a typed DevhelmApiError subclass. @@ -161,6 +191,11 @@ def error_from_response( pulled out at the call site (rather than re-parsed from the body) so the SDK still surfaces the id even when the server returns a non-JSON body (e.g. an HTML error page from a misconfigured proxy). + + `retry_after` is the raw value of the `Retry-After` response header, + pulled out at the call site for the same reason. It's parsed into whole + seconds and surfaced as ``err.retry_after`` (e.g. on 429 responses) so + callers can back off for exactly as long as the server asked. """ message = f"HTTP {status}" detail: str | None = None @@ -195,6 +230,7 @@ def error_from_response( "body": parsed_body, "code": code, "request_id": resolved_request_id, + "retry_after": _parse_retry_after(retry_after), } if status in (401, 403): diff --git a/src/devhelm/_http.py b/src/devhelm/_http.py index 9d08a99..90a45b0 100644 --- a/src/devhelm/_http.py +++ b/src/devhelm/_http.py @@ -180,6 +180,7 @@ def checked_fetch(response: httpx.Response) -> _JsonResponse: response.status_code, response.text, request_id=response.headers.get("x-request-id"), + retry_after=response.headers.get("retry-after"), ) diff --git a/src/devhelm/resources/api_keys.py b/src/devhelm/resources/api_keys.py index ec40991..c8b9dca 100644 --- a/src/devhelm/resources/api_keys.py +++ b/src/devhelm/resources/api_keys.py @@ -3,7 +3,7 @@ import httpx from devhelm._generated import ApiKeyCreateResponse, ApiKeyDto, CreateApiKeyRequest -from devhelm._http import api_delete, api_post, path_param +from devhelm._http import api_delete, api_get, api_post, path_param from devhelm._pagination import Page, fetch_all_pages, fetch_page from devhelm._validation import RequestBody, parse_single, validate_request @@ -22,6 +22,14 @@ def list_page(self, page: int, size: int) -> Page[ApiKeyDto]: """List API keys with manual page control.""" return fetch_page(self._client, "/api/v1/api-keys", ApiKeyDto, page, size) + def get(self, id: int | str) -> ApiKeyDto: + """Get a single API key by ID.""" + return parse_single( + ApiKeyDto, + api_get(self._client, f"/api/v1/api-keys/{path_param(id)}"), + f"GET /api/v1/api-keys/{id}", + ) + def create(self, body: RequestBody[CreateApiKeyRequest]) -> ApiKeyCreateResponse: """Create an API key. Returns the key value (shown only once).""" body = validate_request(CreateApiKeyRequest, body, "apiKeys.create") diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py new file mode 100644 index 0000000..f1033f5 --- /dev/null +++ b/tests/test_api_keys.py @@ -0,0 +1,76 @@ +"""Tests for the ``ApiKeys`` resource module. + +Mirrors ``test_maintenance_windows`` / ``test_services``: spin up an +``httpx.MockTransport``, point a real ``ApiKeys`` instance at it, and +assert the resulting ``httpx.Request`` carries the wire-level URL and +method the API documents — plus that responses are unwrapped into typed +models. +""" + +from __future__ import annotations + +import httpx + +from devhelm.resources.api_keys import ApiKeys + +# --------------------------------------------------------------------------- +# Fixtures: canned API payload (camelCase wire shape) +# --------------------------------------------------------------------------- + + +_API_KEY = { + "id": 42, + "name": "CI pipeline", + "key": "dh_live_abc123", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-06-01T00:00:00Z", + "lastUsedAt": None, + "revokedAt": None, + "expiresAt": None, +} + + +def _stub_transport(captured: list[httpx.Request]) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + method = request.method + path = request.url.path + if method == "GET" and path.startswith("/api/v1/api-keys/"): + return httpx.Response(200, json={"data": _API_KEY}) + raise AssertionError(f"unexpected {method} {path}") + + return httpx.MockTransport(handler) + + +def _resource(transport: httpx.MockTransport) -> ApiKeys: + http_client = httpx.Client(transport=transport, base_url="http://localhost:8080") + return ApiKeys(http_client) + + +class TestGet: + def test_get_is_callable(self) -> None: + api_keys = _resource(_stub_transport([])) + assert callable(api_keys.get) + + def test_get_hits_resource_url_and_unwraps(self) -> None: + captured: list[httpx.Request] = [] + api_keys = _resource(_stub_transport(captured)) + + result = api_keys.get(42) + + assert len(captured) == 1 + assert captured[0].method == "GET" + assert captured[0].url.path == "/api/v1/api-keys/42" + assert result.id == 42 + assert result.name == "CI pipeline" + assert result.key == "dh_live_abc123" + + def test_get_encodes_path_param(self) -> None: + captured: list[httpx.Request] = [] + api_keys = _resource(_stub_transport(captured)) + + api_keys.get("a b") + + # ``url.path`` is percent-decoded by httpx; assert on the raw bytes + # to confirm ``path_param`` encoded the space before it hit the wire. + assert b"/api/v1/api-keys/a%20b" == captured[0].url.raw_path diff --git a/tests/test_errors.py b/tests/test_errors.py index 6d9512a..879be86 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -151,6 +151,33 @@ def test_no_code_or_request_id_for_non_json_body(self) -> None: assert err.request_id is None +class TestRetryAfter: + def test_429_parses_retry_after_header_to_int(self) -> None: + err = error_from_response( + 429, json.dumps({"message": "Slow down"}), retry_after="30" + ) + assert isinstance(err, DevhelmRateLimitError) + assert err.retry_after == 30 + assert isinstance(err.retry_after, int) + + def test_retry_after_absent_is_none(self) -> None: + err = error_from_response(429, json.dumps({"message": "Slow down"})) + assert err.retry_after is None + + def test_retry_after_non_integer_is_none(self) -> None: + # HTTP-date form (or any garbage) must not break error construction. + err = error_from_response( + 429, + json.dumps({"message": "Slow down"}), + retry_after="Wed, 21 Oct 2026 07:28:00 GMT", + ) + assert err.retry_after is None + + def test_retry_after_default_none_on_constructor(self) -> None: + err = DevhelmApiError("boom", status=500) + assert err.retry_after is None + + class TestDevhelmErrorInheritance: def test_api_error_is_devhelm_error(self) -> None: err = DevhelmApiError("test", status=500) diff --git a/tests/test_http.py b/tests/test_http.py index 8e95560..208e51f 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -2,11 +2,12 @@ from __future__ import annotations +import httpx import pytest from pydantic import BaseModel, Field -from devhelm._errors import DevhelmError -from devhelm._http import DevhelmConfig, build_client, path_param +from devhelm._errors import DevhelmError, DevhelmRateLimitError +from devhelm._http import DevhelmConfig, api_get, build_client, path_param from devhelm._validation import parse_list, parse_model, parse_single # ---------- path_param ---------- @@ -120,6 +121,49 @@ def test_env_opt_out_drops_all_surface_headers( client.close() +# ---------- Rate-limit Retry-After surfacing ---------- + + +class TestRetryAfterFromResponse: + """A 429 with a ``Retry-After`` header must surface ``retry_after`` as an + integer on the raised :class:`DevhelmRateLimitError` so callers can back + off for exactly as long as the server asked. + """ + + def test_429_retry_after_header_surfaces_as_int(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + headers={"Retry-After": "30"}, + json={"message": "Slow down", "code": "RATE_LIMITED"}, + ) + + client = httpx.Client( + transport=httpx.MockTransport(handler), base_url="http://localhost:8080" + ) + with pytest.raises(DevhelmRateLimitError) as exc_info: + api_get(client, "/api/v1/monitors") + client.close() + + err = exc_info.value + assert err.status == 429 + assert err.retry_after == 30 + assert isinstance(err.retry_after, int) + + def test_429_without_header_has_none_retry_after(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(429, json={"message": "Slow down"}) + + client = httpx.Client( + transport=httpx.MockTransport(handler), base_url="http://localhost:8080" + ) + with pytest.raises(DevhelmRateLimitError) as exc_info: + api_get(client, "/api/v1/monitors") + client.close() + + assert exc_info.value.retry_after is None + + # ---------- Pydantic validation helpers ---------- diff --git a/uv.lock b/uv.lock index 00830d6..66cafdd 100644 --- a/uv.lock +++ b/uv.lock @@ -331,7 +331,7 @@ wheels = [ [[package]] name = "devhelm" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "httpx" },