Skip to content

Commit a414f37

Browse files
authored
Merge pull request #41 from devhelmhq/fix/api-keys-get-retry-after
fix: add api_keys.get + expose retry_after on rate limit errors
2 parents 8cfb9f6 + ac06b56 commit a414f37

7 files changed

Lines changed: 197 additions & 5 deletions

File tree

src/devhelm/_errors.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ class DevhelmApiError(DevhelmError):
8282
The optional `request_id` field is the per-request id emitted by the
8383
API as the `X-Request-Id` response header and embedded in the JSON
8484
error body. Always include it in support tickets.
85+
86+
The optional `retry_after` field is the parsed value of the
87+
`Retry-After` response header in whole seconds. It's populated on
88+
rate-limit (429) responses that include the header so callers can back
89+
off for exactly as long as the server asked; ``None`` otherwise.
8590
"""
8691

8792
status: int
@@ -93,6 +98,7 @@ class DevhelmApiError(DevhelmError):
9398
# narrowing. (Subclasses still inherit the same `str` type.)
9499
code: str
95100
request_id: str | None
101+
retry_after: int | None
96102

97103
def __init__(
98104
self,
@@ -103,6 +109,7 @@ def __init__(
103109
body: dict[str, Any] | str | None = None,
104110
code: str | None = None,
105111
request_id: str | None = None,
112+
retry_after: int | None = None,
106113
) -> None:
107114
super().__init__(message)
108115
self.status = status
@@ -113,6 +120,9 @@ def __init__(
113120
# `err.code` is never ``None`` for callers switching on category.
114121
self.code = code or "API_ERROR"
115122
self.request_id = request_id
123+
# Parsed from the `Retry-After` response header (seconds). Populated
124+
# on 429 / 503 responses that include it; ``None`` otherwise.
125+
self.retry_after = retry_after
116126

117127

118128
class DevhelmAuthError(DevhelmApiError):
@@ -152,15 +162,40 @@ def __init__(self, message: str, *, cause: Exception | None = None) -> None:
152162
self.__cause__ = cause
153163

154164

165+
def _parse_retry_after(value: str | None) -> int | None:
166+
"""Parse a ``Retry-After`` header value into whole seconds.
167+
168+
The API emits ``Retry-After`` as an integer number of seconds. We parse
169+
defensively: any non-integer value (an HTTP-date form, or garbage from a
170+
misbehaving proxy) yields ``None`` rather than raising, so a malformed
171+
header can never break error construction.
172+
"""
173+
if value is None:
174+
return None
175+
try:
176+
return int(value)
177+
except (TypeError, ValueError):
178+
return None
179+
180+
155181
def error_from_response(
156-
status: int, body: str, *, request_id: str | None = None
182+
status: int,
183+
body: str,
184+
*,
185+
request_id: str | None = None,
186+
retry_after: str | None = None,
157187
) -> DevhelmApiError:
158188
"""Map an HTTP error response to a typed DevhelmApiError subclass.
159189
160190
`request_id` is the value of the `X-Request-Id` response header. It is
161191
pulled out at the call site (rather than re-parsed from the body) so the
162192
SDK still surfaces the id even when the server returns a non-JSON body
163193
(e.g. an HTML error page from a misconfigured proxy).
194+
195+
`retry_after` is the raw value of the `Retry-After` response header,
196+
pulled out at the call site for the same reason. It's parsed into whole
197+
seconds and surfaced as ``err.retry_after`` (e.g. on 429 responses) so
198+
callers can back off for exactly as long as the server asked.
164199
"""
165200
message = f"HTTP {status}"
166201
detail: str | None = None
@@ -195,6 +230,7 @@ def error_from_response(
195230
"body": parsed_body,
196231
"code": code,
197232
"request_id": resolved_request_id,
233+
"retry_after": _parse_retry_after(retry_after),
198234
}
199235

200236
if status in (401, 403):

src/devhelm/_http.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ def checked_fetch(response: httpx.Response) -> _JsonResponse:
180180
response.status_code,
181181
response.text,
182182
request_id=response.headers.get("x-request-id"),
183+
retry_after=response.headers.get("retry-after"),
183184
)
184185

185186

src/devhelm/resources/api_keys.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import httpx
44

55
from devhelm._generated import ApiKeyCreateResponse, ApiKeyDto, CreateApiKeyRequest
6-
from devhelm._http import api_delete, api_post, path_param
6+
from devhelm._http import api_delete, api_get, api_post, path_param
77
from devhelm._pagination import Page, fetch_all_pages, fetch_page
88
from devhelm._validation import RequestBody, parse_single, validate_request
99

@@ -22,6 +22,14 @@ def list_page(self, page: int, size: int) -> Page[ApiKeyDto]:
2222
"""List API keys with manual page control."""
2323
return fetch_page(self._client, "/api/v1/api-keys", ApiKeyDto, page, size)
2424

25+
def get(self, id: int | str) -> ApiKeyDto:
26+
"""Get a single API key by ID."""
27+
return parse_single(
28+
ApiKeyDto,
29+
api_get(self._client, f"/api/v1/api-keys/{path_param(id)}"),
30+
f"GET /api/v1/api-keys/{id}",
31+
)
32+
2533
def create(self, body: RequestBody[CreateApiKeyRequest]) -> ApiKeyCreateResponse:
2634
"""Create an API key. Returns the key value (shown only once)."""
2735
body = validate_request(CreateApiKeyRequest, body, "apiKeys.create")

tests/test_api_keys.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Tests for the ``ApiKeys`` resource module.
2+
3+
Mirrors ``test_maintenance_windows`` / ``test_services``: spin up an
4+
``httpx.MockTransport``, point a real ``ApiKeys`` instance at it, and
5+
assert the resulting ``httpx.Request`` carries the wire-level URL and
6+
method the API documents — plus that responses are unwrapped into typed
7+
models.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import httpx
13+
14+
from devhelm.resources.api_keys import ApiKeys
15+
16+
# ---------------------------------------------------------------------------
17+
# Fixtures: canned API payload (camelCase wire shape)
18+
# ---------------------------------------------------------------------------
19+
20+
21+
_API_KEY = {
22+
"id": 42,
23+
"name": "CI pipeline",
24+
"key": "dh_live_abc123",
25+
"createdAt": "2026-01-01T00:00:00Z",
26+
"updatedAt": "2026-06-01T00:00:00Z",
27+
"lastUsedAt": None,
28+
"revokedAt": None,
29+
"expiresAt": None,
30+
}
31+
32+
33+
def _stub_transport(captured: list[httpx.Request]) -> httpx.MockTransport:
34+
def handler(request: httpx.Request) -> httpx.Response:
35+
captured.append(request)
36+
method = request.method
37+
path = request.url.path
38+
if method == "GET" and path.startswith("/api/v1/api-keys/"):
39+
return httpx.Response(200, json={"data": _API_KEY})
40+
raise AssertionError(f"unexpected {method} {path}")
41+
42+
return httpx.MockTransport(handler)
43+
44+
45+
def _resource(transport: httpx.MockTransport) -> ApiKeys:
46+
http_client = httpx.Client(transport=transport, base_url="http://localhost:8080")
47+
return ApiKeys(http_client)
48+
49+
50+
class TestGet:
51+
def test_get_is_callable(self) -> None:
52+
api_keys = _resource(_stub_transport([]))
53+
assert callable(api_keys.get)
54+
55+
def test_get_hits_resource_url_and_unwraps(self) -> None:
56+
captured: list[httpx.Request] = []
57+
api_keys = _resource(_stub_transport(captured))
58+
59+
result = api_keys.get(42)
60+
61+
assert len(captured) == 1
62+
assert captured[0].method == "GET"
63+
assert captured[0].url.path == "/api/v1/api-keys/42"
64+
assert result.id == 42
65+
assert result.name == "CI pipeline"
66+
assert result.key == "dh_live_abc123"
67+
68+
def test_get_encodes_path_param(self) -> None:
69+
captured: list[httpx.Request] = []
70+
api_keys = _resource(_stub_transport(captured))
71+
72+
api_keys.get("a b")
73+
74+
# ``url.path`` is percent-decoded by httpx; assert on the raw bytes
75+
# to confirm ``path_param`` encoded the space before it hit the wire.
76+
assert b"/api/v1/api-keys/a%20b" == captured[0].url.raw_path

tests/test_errors.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,33 @@ def test_no_code_or_request_id_for_non_json_body(self) -> None:
151151
assert err.request_id is None
152152

153153

154+
class TestRetryAfter:
155+
def test_429_parses_retry_after_header_to_int(self) -> None:
156+
err = error_from_response(
157+
429, json.dumps({"message": "Slow down"}), retry_after="30"
158+
)
159+
assert isinstance(err, DevhelmRateLimitError)
160+
assert err.retry_after == 30
161+
assert isinstance(err.retry_after, int)
162+
163+
def test_retry_after_absent_is_none(self) -> None:
164+
err = error_from_response(429, json.dumps({"message": "Slow down"}))
165+
assert err.retry_after is None
166+
167+
def test_retry_after_non_integer_is_none(self) -> None:
168+
# HTTP-date form (or any garbage) must not break error construction.
169+
err = error_from_response(
170+
429,
171+
json.dumps({"message": "Slow down"}),
172+
retry_after="Wed, 21 Oct 2026 07:28:00 GMT",
173+
)
174+
assert err.retry_after is None
175+
176+
def test_retry_after_default_none_on_constructor(self) -> None:
177+
err = DevhelmApiError("boom", status=500)
178+
assert err.retry_after is None
179+
180+
154181
class TestDevhelmErrorInheritance:
155182
def test_api_error_is_devhelm_error(self) -> None:
156183
err = DevhelmApiError("test", status=500)

tests/test_http.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
from __future__ import annotations
44

5+
import httpx
56
import pytest
67
from pydantic import BaseModel, Field
78

8-
from devhelm._errors import DevhelmError
9-
from devhelm._http import DevhelmConfig, build_client, path_param
9+
from devhelm._errors import DevhelmError, DevhelmRateLimitError
10+
from devhelm._http import DevhelmConfig, api_get, build_client, path_param
1011
from devhelm._validation import parse_list, parse_model, parse_single
1112

1213
# ---------- path_param ----------
@@ -120,6 +121,49 @@ def test_env_opt_out_drops_all_surface_headers(
120121
client.close()
121122

122123

124+
# ---------- Rate-limit Retry-After surfacing ----------
125+
126+
127+
class TestRetryAfterFromResponse:
128+
"""A 429 with a ``Retry-After`` header must surface ``retry_after`` as an
129+
integer on the raised :class:`DevhelmRateLimitError` so callers can back
130+
off for exactly as long as the server asked.
131+
"""
132+
133+
def test_429_retry_after_header_surfaces_as_int(self) -> None:
134+
def handler(request: httpx.Request) -> httpx.Response:
135+
return httpx.Response(
136+
429,
137+
headers={"Retry-After": "30"},
138+
json={"message": "Slow down", "code": "RATE_LIMITED"},
139+
)
140+
141+
client = httpx.Client(
142+
transport=httpx.MockTransport(handler), base_url="http://localhost:8080"
143+
)
144+
with pytest.raises(DevhelmRateLimitError) as exc_info:
145+
api_get(client, "/api/v1/monitors")
146+
client.close()
147+
148+
err = exc_info.value
149+
assert err.status == 429
150+
assert err.retry_after == 30
151+
assert isinstance(err.retry_after, int)
152+
153+
def test_429_without_header_has_none_retry_after(self) -> None:
154+
def handler(request: httpx.Request) -> httpx.Response:
155+
return httpx.Response(429, json={"message": "Slow down"})
156+
157+
client = httpx.Client(
158+
transport=httpx.MockTransport(handler), base_url="http://localhost:8080"
159+
)
160+
with pytest.raises(DevhelmRateLimitError) as exc_info:
161+
api_get(client, "/api/v1/monitors")
162+
client.close()
163+
164+
assert exc_info.value.retry_after is None
165+
166+
123167
# ---------- Pydantic validation helpers ----------
124168

125169

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)