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
38 changes: 37 additions & 1 deletion src/devhelm/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -152,15 +162,40 @@ 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.

`request_id` is the value of the `X-Request-Id` response header. It is
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
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions src/devhelm/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)


Expand Down
10 changes: 9 additions & 1 deletion src/devhelm/resources/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand Down
76 changes: 76 additions & 0 deletions tests/test_api_keys.py
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 46 additions & 2 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------
Expand Down Expand Up @@ -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 ----------


Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading