devhelm 1.3.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devhelm-1.3.0 → devhelm-1.4.0}/PKG-INFO +1 -1
- {devhelm-1.3.0 → devhelm-1.4.0}/docs/openapi/monitoring-api.json +43 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/pyproject.toml +1 -1
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/_errors.py +37 -1
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/_http.py +1 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/api_keys.py +9 -1
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/run_sdk.py +32 -0
- devhelm-1.4.0/tests/test_api_keys.py +76 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_errors.py +27 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_http.py +46 -2
- {devhelm-1.3.0 → devhelm-1.4.0}/uv.lock +1 -1
- {devhelm-1.3.0 → devhelm-1.4.0}/.github/workflows/ci.yml +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/.github/workflows/release.yml +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/.github/workflows/spec-check.yml +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/.gitignore +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/LICENSE +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/Makefile +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/README.md +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/scripts/emit_response_enums.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/scripts/inject_strict_config.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/scripts/regen-from.sh +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/scripts/release.sh +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/scripts/typegen.sh +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/__init__.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/_enums.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/_generated.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/_pagination.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/_validation.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/client.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/py.typed +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/__init__.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/alert_channels.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/dependencies.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/deploy_lock.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/environments.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/forensics.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/incidents.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/maintenance_windows.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/monitors.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/notification_policies.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/resource_groups.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/secrets.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/services.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/status.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/status_pages.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/tags.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/resources/webhooks.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/src/devhelm/types.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/__init__.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_client.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_dependencies.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_maintenance_windows.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_negative_validation.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_schemas.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_services.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_spec_parity.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_typing.py +0 -0
- {devhelm-1.3.0 → devhelm-1.4.0}/tests/test_validation_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devhelm
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more
|
|
5
5
|
Project-URL: Homepage, https://github.com/devhelmhq/sdk-python
|
|
6
6
|
Project-URL: Repository, https://github.com/devhelmhq/sdk-python.git
|
|
@@ -2274,6 +2274,18 @@
|
|
|
2274
2274
|
],
|
|
2275
2275
|
"summary": "List categories with service counts",
|
|
2276
2276
|
"operationId": "listCategories",
|
|
2277
|
+
"parameters": [
|
|
2278
|
+
{
|
|
2279
|
+
"name": "publishedOnly",
|
|
2280
|
+
"in": "query",
|
|
2281
|
+
"description": "Count only published services (curated public pSEO set); default false",
|
|
2282
|
+
"required": false,
|
|
2283
|
+
"schema": {
|
|
2284
|
+
"type": "boolean",
|
|
2285
|
+
"default": false
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
],
|
|
2277
2289
|
"responses": {
|
|
2278
2290
|
"200": {
|
|
2279
2291
|
"description": "OK",
|
|
@@ -13529,6 +13541,15 @@
|
|
|
13529
13541
|
"type": "string"
|
|
13530
13542
|
}
|
|
13531
13543
|
},
|
|
13544
|
+
{
|
|
13545
|
+
"name": "sort",
|
|
13546
|
+
"in": "query",
|
|
13547
|
+
"description": "Result ordering: 'recent' (default, newest first) or 'curated' (curated/recognizable first)",
|
|
13548
|
+
"required": false,
|
|
13549
|
+
"schema": {
|
|
13550
|
+
"type": "string"
|
|
13551
|
+
}
|
|
13552
|
+
},
|
|
13532
13553
|
{
|
|
13533
13554
|
"name": "cursor",
|
|
13534
13555
|
"in": "query",
|
|
@@ -13670,6 +13691,16 @@
|
|
|
13670
13691
|
"type": "boolean",
|
|
13671
13692
|
"default": false
|
|
13672
13693
|
}
|
|
13694
|
+
},
|
|
13695
|
+
{
|
|
13696
|
+
"name": "publishedOnly",
|
|
13697
|
+
"in": "query",
|
|
13698
|
+
"description": "Resolve only published services (curated public pSEO set); 404 otherwise. Default false",
|
|
13699
|
+
"required": false,
|
|
13700
|
+
"schema": {
|
|
13701
|
+
"type": "boolean",
|
|
13702
|
+
"default": false
|
|
13703
|
+
}
|
|
13673
13704
|
}
|
|
13674
13705
|
],
|
|
13675
13706
|
"responses": {
|
|
@@ -15367,6 +15398,18 @@
|
|
|
15367
15398
|
"summary": "Global status summary across all services",
|
|
15368
15399
|
"description": "Returns aggregate counts of services by status and a list of services currently experiencing issues.",
|
|
15369
15400
|
"operationId": "getGlobalStatusSummary",
|
|
15401
|
+
"parameters": [
|
|
15402
|
+
{
|
|
15403
|
+
"name": "publishedOnly",
|
|
15404
|
+
"in": "query",
|
|
15405
|
+
"description": "Aggregate only published services (curated public pSEO set); default false",
|
|
15406
|
+
"required": false,
|
|
15407
|
+
"schema": {
|
|
15408
|
+
"type": "boolean",
|
|
15409
|
+
"default": false
|
|
15410
|
+
}
|
|
15411
|
+
}
|
|
15412
|
+
],
|
|
15370
15413
|
"responses": {
|
|
15371
15414
|
"200": {
|
|
15372
15415
|
"description": "OK",
|
|
@@ -82,6 +82,11 @@ class DevhelmApiError(DevhelmError):
|
|
|
82
82
|
The optional `request_id` field is the per-request id emitted by the
|
|
83
83
|
API as the `X-Request-Id` response header and embedded in the JSON
|
|
84
84
|
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.
|
|
85
90
|
"""
|
|
86
91
|
|
|
87
92
|
status: int
|
|
@@ -93,6 +98,7 @@ class DevhelmApiError(DevhelmError):
|
|
|
93
98
|
# narrowing. (Subclasses still inherit the same `str` type.)
|
|
94
99
|
code: str
|
|
95
100
|
request_id: str | None
|
|
101
|
+
retry_after: int | None
|
|
96
102
|
|
|
97
103
|
def __init__(
|
|
98
104
|
self,
|
|
@@ -103,6 +109,7 @@ class DevhelmApiError(DevhelmError):
|
|
|
103
109
|
body: dict[str, Any] | str | None = None,
|
|
104
110
|
code: str | None = None,
|
|
105
111
|
request_id: str | None = None,
|
|
112
|
+
retry_after: int | None = None,
|
|
106
113
|
) -> None:
|
|
107
114
|
super().__init__(message)
|
|
108
115
|
self.status = status
|
|
@@ -113,6 +120,9 @@ class DevhelmApiError(DevhelmError):
|
|
|
113
120
|
# `err.code` is never ``None`` for callers switching on category.
|
|
114
121
|
self.code = code or "API_ERROR"
|
|
115
122
|
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
|
|
116
126
|
|
|
117
127
|
|
|
118
128
|
class DevhelmAuthError(DevhelmApiError):
|
|
@@ -152,8 +162,28 @@ class DevhelmTransportError(DevhelmError):
|
|
|
152
162
|
self.__cause__ = cause
|
|
153
163
|
|
|
154
164
|
|
|
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
|
+
|
|
155
181
|
def error_from_response(
|
|
156
|
-
status: int,
|
|
182
|
+
status: int,
|
|
183
|
+
body: str,
|
|
184
|
+
*,
|
|
185
|
+
request_id: str | None = None,
|
|
186
|
+
retry_after: str | None = None,
|
|
157
187
|
) -> DevhelmApiError:
|
|
158
188
|
"""Map an HTTP error response to a typed DevhelmApiError subclass.
|
|
159
189
|
|
|
@@ -161,6 +191,11 @@ def error_from_response(
|
|
|
161
191
|
pulled out at the call site (rather than re-parsed from the body) so the
|
|
162
192
|
SDK still surfaces the id even when the server returns a non-JSON body
|
|
163
193
|
(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.
|
|
164
199
|
"""
|
|
165
200
|
message = f"HTTP {status}"
|
|
166
201
|
detail: str | None = None
|
|
@@ -195,6 +230,7 @@ def error_from_response(
|
|
|
195
230
|
"body": parsed_body,
|
|
196
231
|
"code": code,
|
|
197
232
|
"request_id": resolved_request_id,
|
|
233
|
+
"retry_after": _parse_retry_after(retry_after),
|
|
198
234
|
}
|
|
199
235
|
|
|
200
236
|
if status in (401, 403):
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import httpx
|
|
4
4
|
|
|
5
5
|
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
|
|
7
7
|
from devhelm._pagination import Page, fetch_all_pages, fetch_page
|
|
8
8
|
from devhelm._validation import RequestBody, parse_single, validate_request
|
|
9
9
|
|
|
@@ -22,6 +22,14 @@ class ApiKeys:
|
|
|
22
22
|
"""List API keys with manual page control."""
|
|
23
23
|
return fetch_page(self._client, "/api/v1/api-keys", ApiKeyDto, page, size)
|
|
24
24
|
|
|
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
|
+
|
|
25
33
|
def create(self, body: RequestBody[CreateApiKeyRequest]) -> ApiKeyCreateResponse:
|
|
26
34
|
"""Create an API key. Returns the key value (shown only once)."""
|
|
27
35
|
body = validate_request(CreateApiKeyRequest, body, "apiKeys.create")
|
|
@@ -259,6 +259,8 @@ def run(client: Devhelm, resource: str, action: str, rest: list[str]) -> Any: #
|
|
|
259
259
|
# -- API Keys --
|
|
260
260
|
if op == "api-keys.list":
|
|
261
261
|
return client.api_keys.list()
|
|
262
|
+
if op == "api-keys.get":
|
|
263
|
+
return client.api_keys.get(rest[0])
|
|
262
264
|
if op == "api-keys.create":
|
|
263
265
|
return client.api_keys.create(_parse(CreateApiKeyRequest, rest[0]))
|
|
264
266
|
if op == "api-keys.revoke":
|
|
@@ -275,10 +277,40 @@ def run(client: Devhelm, resource: str, action: str, rest: list[str]) -> Any: #
|
|
|
275
277
|
return client.dependencies.get(rest[0])
|
|
276
278
|
if op == "dependencies.track":
|
|
277
279
|
return client.dependencies.track(rest[0])
|
|
280
|
+
if op == "dependencies.update-alert-sensitivity":
|
|
281
|
+
return client.dependencies.update_alert_sensitivity(rest[0], rest[1])
|
|
278
282
|
if op == "dependencies.delete":
|
|
279
283
|
client.dependencies.delete(rest[0])
|
|
280
284
|
return None
|
|
281
285
|
|
|
286
|
+
# -- Services (Status Data catalog, read-only) --
|
|
287
|
+
if op == "services.list":
|
|
288
|
+
return client.services.list()
|
|
289
|
+
if op == "services.get":
|
|
290
|
+
return client.services.get(rest[0])
|
|
291
|
+
if op == "services.live-status":
|
|
292
|
+
return client.services.live_status(rest[0])
|
|
293
|
+
if op == "services.categories":
|
|
294
|
+
return client.services.categories()
|
|
295
|
+
if op == "services.summary":
|
|
296
|
+
return client.services.summary()
|
|
297
|
+
if op == "services.components":
|
|
298
|
+
return client.services.components(rest[0])
|
|
299
|
+
if op == "services.component-uptime":
|
|
300
|
+
return client.services.component_uptime(rest[0], rest[1])
|
|
301
|
+
if op == "services.batch-component-uptime":
|
|
302
|
+
return client.services.batch_component_uptime(rest[0])
|
|
303
|
+
if op == "services.day":
|
|
304
|
+
return client.services.day(rest[0], rest[1])
|
|
305
|
+
if op == "services.incidents":
|
|
306
|
+
return client.services.incidents(rest[0] if rest else None)
|
|
307
|
+
if op == "services.incident":
|
|
308
|
+
return client.services.incident(rest[0], rest[1])
|
|
309
|
+
if op == "services.uptime":
|
|
310
|
+
return client.services.uptime(rest[0])
|
|
311
|
+
if op == "services.maintenances":
|
|
312
|
+
return client.services.maintenances(rest[0])
|
|
313
|
+
|
|
282
314
|
# -- Deploy Lock --
|
|
283
315
|
if op == "deploy-lock.acquire":
|
|
284
316
|
return client.deploy_lock.acquire(_parse(AcquireDeployLockRequest, rest[0]))
|
|
@@ -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
|
|
@@ -151,6 +151,33 @@ class TestErrorFromResponse:
|
|
|
151
151
|
assert err.request_id is None
|
|
152
152
|
|
|
153
153
|
|
|
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
|
+
|
|
154
181
|
class TestDevhelmErrorInheritance:
|
|
155
182
|
def test_api_error_is_devhelm_error(self) -> None:
|
|
156
183
|
err = DevhelmApiError("test", status=500)
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import httpx
|
|
5
6
|
import pytest
|
|
6
7
|
from pydantic import BaseModel, Field
|
|
7
8
|
|
|
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
|
|
10
11
|
from devhelm._validation import parse_list, parse_model, parse_single
|
|
11
12
|
|
|
12
13
|
# ---------- path_param ----------
|
|
@@ -120,6 +121,49 @@ class TestSurfaceTelemetry:
|
|
|
120
121
|
client.close()
|
|
121
122
|
|
|
122
123
|
|
|
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
|
+
|
|
123
167
|
# ---------- Pydantic validation helpers ----------
|
|
124
168
|
|
|
125
169
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|