drukbox-python-sdk 0.2.0__tar.gz → 0.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.
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/AGENTS.md +3 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/PKG-INFO +25 -2
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/README.md +24 -1
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/pyproject.toml +1 -1
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/src/drukbox_sdk/__init__.py +4 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/src/drukbox_sdk/api.py +62 -8
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/src/drukbox_sdk/exceptions.py +12 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/tests/test_api.py +101 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/.gitignore +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/LICENSE +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.4.0}/tests/__init__.py +0 -0
|
@@ -57,3 +57,6 @@ run.
|
|
|
57
57
|
consumers unless the user asks for them.
|
|
58
58
|
- Preserve user changes in the worktree; do not clean or rewrite unrelated
|
|
59
59
|
files.
|
|
60
|
+
- A version bump rides alone: one commit (`chore: bump to X`) that touches
|
|
61
|
+
only `pyproject.toml`, with no other change in it. Never fold the bump into
|
|
62
|
+
a feature or fix commit. The release tag is cut from the bump commit.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Async Python client for Drukbox.
|
|
5
5
|
Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
|
|
@@ -59,6 +59,8 @@ finally:
|
|
|
59
59
|
few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
|
|
60
60
|
an `idempotency_key` for retry safety: a retry with the same key after a
|
|
61
61
|
successful provision returns the original host instead of creating a duplicate.
|
|
62
|
+
A provider at its host limit raises `SandboxCapacityError`, a
|
|
63
|
+
`SandboxUnavailableError` whose `retry_after` gives the seconds to wait.
|
|
62
64
|
|
|
63
65
|
`SandboxAPI.from_env(prefix="SANDBOX_")` reads
|
|
64
66
|
`SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
|
|
@@ -73,6 +75,7 @@ Public exports live in `drukbox_sdk`:
|
|
|
73
75
|
- `SandboxTemplate`
|
|
74
76
|
- `Secret`
|
|
75
77
|
- `Issuer`
|
|
78
|
+
- `ServiceAccount`
|
|
76
79
|
- `DoctorReport` and `DoctorCheck`
|
|
77
80
|
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
78
81
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
@@ -86,6 +89,7 @@ Supported host operations:
|
|
|
86
89
|
- `list_hosts`
|
|
87
90
|
- `renew_host`
|
|
88
91
|
- `delete_host`
|
|
92
|
+
- `refresh_secret`
|
|
89
93
|
- `doctor`
|
|
90
94
|
- `aclose`
|
|
91
95
|
|
|
@@ -127,6 +131,24 @@ The response carries no secret. A service name Drukbox does not know raises
|
|
|
127
131
|
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
128
132
|
permanent.
|
|
129
133
|
|
|
134
|
+
`SandboxHost.service_account` names the service account that created or
|
|
135
|
+
claimed the host, `admin` for an admin key, or `None` for an unclaimed warm
|
|
136
|
+
host.
|
|
137
|
+
|
|
138
|
+
`refresh_secret` makes the service forget the current value of one secret on
|
|
139
|
+
a host and fetch a new one from its issuer. A static secret raises
|
|
140
|
+
`SandboxConflictError`; an issuer or exchange that did not answer raises
|
|
141
|
+
`SandboxUnavailableError`.
|
|
142
|
+
|
|
143
|
+
Supported service account operations, admin key only:
|
|
144
|
+
|
|
145
|
+
- `create_service_account`
|
|
146
|
+
- `remove_service_account`
|
|
147
|
+
|
|
148
|
+
`create_service_account` returns a `ServiceAccount` with the token the
|
|
149
|
+
service hands out once. Store it. `remove_service_account` revokes it on the
|
|
150
|
+
next request.
|
|
151
|
+
|
|
130
152
|
Supported template operations:
|
|
131
153
|
|
|
132
154
|
- `create_template`
|
|
@@ -153,7 +175,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
|
|
|
153
175
|
|
|
154
176
|
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
155
177
|
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
156
|
-
provider, Tailscale when enabled) and always responds 200,
|
|
178
|
+
provider, secrets exchange, Tailscale when enabled) and always responds 200,
|
|
179
|
+
so callers branch
|
|
157
180
|
on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
|
|
158
181
|
carries a stable `hint` slug for remediation.
|
|
159
182
|
|
|
@@ -46,6 +46,8 @@ finally:
|
|
|
46
46
|
few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
|
|
47
47
|
an `idempotency_key` for retry safety: a retry with the same key after a
|
|
48
48
|
successful provision returns the original host instead of creating a duplicate.
|
|
49
|
+
A provider at its host limit raises `SandboxCapacityError`, a
|
|
50
|
+
`SandboxUnavailableError` whose `retry_after` gives the seconds to wait.
|
|
49
51
|
|
|
50
52
|
`SandboxAPI.from_env(prefix="SANDBOX_")` reads
|
|
51
53
|
`SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
|
|
@@ -60,6 +62,7 @@ Public exports live in `drukbox_sdk`:
|
|
|
60
62
|
- `SandboxTemplate`
|
|
61
63
|
- `Secret`
|
|
62
64
|
- `Issuer`
|
|
65
|
+
- `ServiceAccount`
|
|
63
66
|
- `DoctorReport` and `DoctorCheck`
|
|
64
67
|
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
65
68
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
@@ -73,6 +76,7 @@ Supported host operations:
|
|
|
73
76
|
- `list_hosts`
|
|
74
77
|
- `renew_host`
|
|
75
78
|
- `delete_host`
|
|
79
|
+
- `refresh_secret`
|
|
76
80
|
- `doctor`
|
|
77
81
|
- `aclose`
|
|
78
82
|
|
|
@@ -114,6 +118,24 @@ The response carries no secret. A service name Drukbox does not know raises
|
|
|
114
118
|
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
115
119
|
permanent.
|
|
116
120
|
|
|
121
|
+
`SandboxHost.service_account` names the service account that created or
|
|
122
|
+
claimed the host, `admin` for an admin key, or `None` for an unclaimed warm
|
|
123
|
+
host.
|
|
124
|
+
|
|
125
|
+
`refresh_secret` makes the service forget the current value of one secret on
|
|
126
|
+
a host and fetch a new one from its issuer. A static secret raises
|
|
127
|
+
`SandboxConflictError`; an issuer or exchange that did not answer raises
|
|
128
|
+
`SandboxUnavailableError`.
|
|
129
|
+
|
|
130
|
+
Supported service account operations, admin key only:
|
|
131
|
+
|
|
132
|
+
- `create_service_account`
|
|
133
|
+
- `remove_service_account`
|
|
134
|
+
|
|
135
|
+
`create_service_account` returns a `ServiceAccount` with the token the
|
|
136
|
+
service hands out once. Store it. `remove_service_account` revokes it on the
|
|
137
|
+
next request.
|
|
138
|
+
|
|
117
139
|
Supported template operations:
|
|
118
140
|
|
|
119
141
|
- `create_template`
|
|
@@ -140,7 +162,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
|
|
|
140
162
|
|
|
141
163
|
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
142
164
|
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
143
|
-
provider, Tailscale when enabled) and always responds 200,
|
|
165
|
+
provider, secrets exchange, Tailscale when enabled) and always responds 200,
|
|
166
|
+
so callers branch
|
|
144
167
|
on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
|
|
145
168
|
carries a stable `hint` slug for remediation.
|
|
146
169
|
|
|
@@ -14,10 +14,12 @@ from .api import (
|
|
|
14
14
|
SandboxHost,
|
|
15
15
|
SandboxTemplate,
|
|
16
16
|
Secret,
|
|
17
|
+
ServiceAccount,
|
|
17
18
|
)
|
|
18
19
|
from .exceptions import (
|
|
19
20
|
SandboxAPIError,
|
|
20
21
|
SandboxAuthError,
|
|
22
|
+
SandboxCapacityError,
|
|
21
23
|
SandboxConflictError,
|
|
22
24
|
SandboxNotFoundError,
|
|
23
25
|
SandboxProvisioningError,
|
|
@@ -35,6 +37,7 @@ __all__ = [
|
|
|
35
37
|
"SandboxAPI",
|
|
36
38
|
"SandboxAPIError",
|
|
37
39
|
"SandboxAuthError",
|
|
40
|
+
"SandboxCapacityError",
|
|
38
41
|
"SandboxConflictError",
|
|
39
42
|
"SandboxHost",
|
|
40
43
|
"SandboxNotFoundError",
|
|
@@ -44,4 +47,5 @@ __all__ = [
|
|
|
44
47
|
"SandboxUnavailableError",
|
|
45
48
|
"SandboxValidationError",
|
|
46
49
|
"Secret",
|
|
50
|
+
"ServiceAccount",
|
|
47
51
|
]
|
|
@@ -47,6 +47,7 @@ import httpx
|
|
|
47
47
|
|
|
48
48
|
from .exceptions import (
|
|
49
49
|
SandboxAuthError,
|
|
50
|
+
SandboxCapacityError,
|
|
50
51
|
SandboxConflictError,
|
|
51
52
|
SandboxNotFoundError,
|
|
52
53
|
SandboxProvisioningError,
|
|
@@ -85,10 +86,15 @@ class SandboxHost:
|
|
|
85
86
|
different SSH auth model (exe.dev's edge proxy, Tailscale ACLs
|
|
86
87
|
via tailscaled-SSH) return None here. A later ``get_host`` call
|
|
87
88
|
always returns None — the key is not persisted server-side.
|
|
89
|
+
|
|
90
|
+
``service_account`` names the service account that created or claimed
|
|
91
|
+
the host, ``"admin"`` for an admin key, or None for an unclaimed warm
|
|
92
|
+
host.
|
|
88
93
|
"""
|
|
89
94
|
|
|
90
95
|
id: str
|
|
91
96
|
name: str
|
|
97
|
+
service_account: str | None
|
|
92
98
|
status: str
|
|
93
99
|
provider: str
|
|
94
100
|
image: str
|
|
@@ -196,6 +202,15 @@ class DoctorReport:
|
|
|
196
202
|
checks: list[DoctorCheck]
|
|
197
203
|
|
|
198
204
|
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class ServiceAccount:
|
|
207
|
+
"""A name the service knows a caller by, and the token that caller
|
|
208
|
+
presents. The service returns the token once, at creation."""
|
|
209
|
+
|
|
210
|
+
name: str
|
|
211
|
+
token: str = field(repr=False)
|
|
212
|
+
|
|
213
|
+
|
|
199
214
|
@dataclass(frozen=True)
|
|
200
215
|
class HTTPProxy:
|
|
201
216
|
"""An account-bound HTTP proxy as returned by the service.
|
|
@@ -401,6 +416,18 @@ class SandboxAPI:
|
|
|
401
416
|
async def delete_host(self, host_id: uuid.UUID | str) -> None:
|
|
402
417
|
await self._request("DELETE", f"/hosts/{host_id}")
|
|
403
418
|
|
|
419
|
+
async def refresh_secret(self, host_id: uuid.UUID | str, service: str) -> None:
|
|
420
|
+
"""Make the service forget the current value of ``service`` on the
|
|
421
|
+
host and fetch a new one from its issuer.
|
|
422
|
+
|
|
423
|
+
Raises :class:`SandboxNotFoundError` for an unknown host or secret,
|
|
424
|
+
:class:`SandboxConflictError` for a secret with a static value, and
|
|
425
|
+
:class:`SandboxUnavailableError` when the exchange or the issuer did
|
|
426
|
+
not answer.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
await self._request("POST", f"/hosts/{host_id}/secrets/{service}/refresh")
|
|
430
|
+
|
|
404
431
|
async def create_template(
|
|
405
432
|
self,
|
|
406
433
|
*,
|
|
@@ -409,14 +436,15 @@ class SandboxAPI:
|
|
|
409
436
|
base_image: str | None = None,
|
|
410
437
|
label: str | None = None,
|
|
411
438
|
) -> SandboxTemplate:
|
|
412
|
-
"""Start a template build
|
|
413
|
-
|
|
414
|
-
The service responds 202 with
|
|
415
|
-
:meth:`get_template` until the status
|
|
416
|
-
``"failed"``. A failed record carries the
|
|
417
|
-
``last_error``. A repeated post with the same provider,
|
|
418
|
-
image, and setup script returns the existing record
|
|
419
|
-
|
|
439
|
+
"""Start a template build.
|
|
440
|
+
|
|
441
|
+
The service pulls the base image, then responds 202 with
|
|
442
|
+
``status="building"``. Poll :meth:`get_template` until the status
|
|
443
|
+
becomes ``"available"`` or ``"failed"``. A failed record carries the
|
|
444
|
+
reason in ``last_error``. A repeated post with the same provider,
|
|
445
|
+
base image digest, and setup script returns the existing record. A
|
|
446
|
+
``"failed"`` record builds again and keeps its ``last_error`` until
|
|
447
|
+
that build ends.
|
|
420
448
|
"""
|
|
421
449
|
|
|
422
450
|
payload: dict[str, Any] = {"setup_script": setup_script}
|
|
@@ -466,6 +494,27 @@ class SandboxAPI:
|
|
|
466
494
|
checks=[DoctorCheck(**check) for check in data["checks"]],
|
|
467
495
|
)
|
|
468
496
|
|
|
497
|
+
async def create_service_account(self, name: str) -> ServiceAccount:
|
|
498
|
+
"""Create a service account and return its token.
|
|
499
|
+
|
|
500
|
+
Only an admin key from the service's ``SERVICE_TOKENS`` may call
|
|
501
|
+
this. A name that already exists raises
|
|
502
|
+
:class:`SandboxConflictError`; an invalid name raises
|
|
503
|
+
:class:`SandboxValidationError`.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
data = await self._request("POST", "/service-accounts", json={"name": name})
|
|
507
|
+
return ServiceAccount(**data)
|
|
508
|
+
|
|
509
|
+
async def remove_service_account(self, name: str) -> None:
|
|
510
|
+
"""Revoke a service account's token and remove the account.
|
|
511
|
+
|
|
512
|
+
An unknown name raises :class:`SandboxNotFoundError`; the ``admin``
|
|
513
|
+
account raises :class:`SandboxConflictError`.
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
await self._request("DELETE", f"/service-accounts/{name}")
|
|
517
|
+
|
|
469
518
|
async def create_http_proxy(
|
|
470
519
|
self,
|
|
471
520
|
*,
|
|
@@ -567,6 +616,11 @@ class SandboxAPI:
|
|
|
567
616
|
raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))
|
|
568
617
|
|
|
569
618
|
if response.status_code == 503:
|
|
619
|
+
if json_response.get("error_code") == "CAPACITY":
|
|
620
|
+
raise SandboxCapacityError(
|
|
621
|
+
json_response.get("detail", "provider at host limit"),
|
|
622
|
+
retry_after=int(response.headers["Retry-After"]),
|
|
623
|
+
)
|
|
570
624
|
raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
|
|
571
625
|
|
|
572
626
|
if response.status_code == 422:
|
|
@@ -18,6 +18,18 @@ class SandboxUnavailableError(RuntimeError):
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class SandboxCapacityError(SandboxUnavailableError):
|
|
22
|
+
"""503 — the provider is at its host limit.
|
|
23
|
+
|
|
24
|
+
``retry_after`` is the service's ``Retry-After`` value in seconds:
|
|
25
|
+
wait at least that long before the next create.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, retry_after: int) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.retry_after = retry_after
|
|
31
|
+
|
|
32
|
+
|
|
21
33
|
class SandboxAPIError(RuntimeError):
|
|
22
34
|
"""Base for any error the sandbox service returned via HTTP."""
|
|
23
35
|
|
|
@@ -28,6 +28,7 @@ from drukbox_sdk import (
|
|
|
28
28
|
Issuer,
|
|
29
29
|
SandboxAPI,
|
|
30
30
|
SandboxAuthError,
|
|
31
|
+
SandboxCapacityError,
|
|
31
32
|
SandboxConflictError,
|
|
32
33
|
SandboxNotFoundError,
|
|
33
34
|
SandboxProvisioningError,
|
|
@@ -35,6 +36,7 @@ from drukbox_sdk import (
|
|
|
35
36
|
SandboxUnavailableError,
|
|
36
37
|
SandboxValidationError,
|
|
37
38
|
Secret,
|
|
39
|
+
ServiceAccount,
|
|
38
40
|
)
|
|
39
41
|
|
|
40
42
|
BASE_URL = "https://sandbox.test"
|
|
@@ -81,6 +83,7 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
|
|
|
81
83
|
payload: dict[str, Any] = {
|
|
82
84
|
"id": str(uuid4()),
|
|
83
85
|
"name": "host-abc",
|
|
86
|
+
"service_account": "admin",
|
|
84
87
|
"status": "provisioning",
|
|
85
88
|
"provider": "exe",
|
|
86
89
|
"image": "ghcr.io/drukbox/sandbox:test",
|
|
@@ -421,6 +424,83 @@ async def test_list_hosts_parses_each_record(api: SandboxAPI):
|
|
|
421
424
|
assert {h.id for h in hosts} == {p["id"] for p in payloads}
|
|
422
425
|
|
|
423
426
|
|
|
427
|
+
@respx.mock
|
|
428
|
+
async def test_host_carries_its_service_account(api: SandboxAPI):
|
|
429
|
+
respx.get(f"{BASE_URL}/hosts/h1").mock(
|
|
430
|
+
return_value=httpx.Response(200, json=_host_payload(service_account="druks-eu"))
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
host = await api.get_host("h1")
|
|
434
|
+
|
|
435
|
+
assert host.service_account == "druks-eu"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@respx.mock
|
|
439
|
+
async def test_refresh_secret_posts_and_accepts_204(api: SandboxAPI):
|
|
440
|
+
route = respx.post(f"{BASE_URL}/hosts/h1/secrets/github/refresh").mock(
|
|
441
|
+
return_value=httpx.Response(204)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
await api.refresh_secret("h1", "github")
|
|
445
|
+
|
|
446
|
+
assert route.called
|
|
447
|
+
assert not route.calls.last.request.content
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@respx.mock
|
|
451
|
+
@pytest.mark.parametrize(
|
|
452
|
+
"status, error",
|
|
453
|
+
[
|
|
454
|
+
(404, SandboxNotFoundError),
|
|
455
|
+
(409, SandboxConflictError),
|
|
456
|
+
(503, SandboxUnavailableError),
|
|
457
|
+
],
|
|
458
|
+
)
|
|
459
|
+
async def test_refresh_secret_raises_the_typed_error(
|
|
460
|
+
api: SandboxAPI, status: int, error: type[Exception]
|
|
461
|
+
):
|
|
462
|
+
respx.post(f"{BASE_URL}/hosts/h1/secrets/github/refresh").mock(
|
|
463
|
+
return_value=httpx.Response(status, json={"detail": "no"})
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
with pytest.raises(error):
|
|
467
|
+
await api.refresh_secret("h1", "github")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@respx.mock
|
|
471
|
+
async def test_create_service_account_returns_the_token_once(api: SandboxAPI):
|
|
472
|
+
route = respx.post(f"{BASE_URL}/service-accounts").mock(
|
|
473
|
+
return_value=httpx.Response(201, json={"name": "druks-eu", "token": "drkb_secret"})
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
account = await api.create_service_account("druks-eu")
|
|
477
|
+
|
|
478
|
+
assert json.loads(route.calls.last.request.content) == {"name": "druks-eu"}
|
|
479
|
+
assert account == ServiceAccount(name="druks-eu", token="drkb_secret")
|
|
480
|
+
assert "drkb_secret" not in repr(account)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@respx.mock
|
|
484
|
+
async def test_create_service_account_409_raises_conflict(api: SandboxAPI):
|
|
485
|
+
respx.post(f"{BASE_URL}/service-accounts").mock(
|
|
486
|
+
return_value=httpx.Response(409, json={"detail": "exists"})
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
with pytest.raises(SandboxConflictError):
|
|
490
|
+
await api.create_service_account("druks-eu")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@respx.mock
|
|
494
|
+
async def test_remove_service_account_swallows_204(api: SandboxAPI):
|
|
495
|
+
route = respx.delete(f"{BASE_URL}/service-accounts/druks-eu").mock(
|
|
496
|
+
return_value=httpx.Response(204)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
await api.remove_service_account("druks-eu")
|
|
500
|
+
|
|
501
|
+
assert route.called
|
|
502
|
+
|
|
503
|
+
|
|
424
504
|
@respx.mock
|
|
425
505
|
async def test_delete_host_swallows_204(api: SandboxAPI):
|
|
426
506
|
host_id = uuid4()
|
|
@@ -717,6 +797,27 @@ async def test_503_raises_sandbox_unavailable_error(api: SandboxAPI):
|
|
|
717
797
|
await api.list_hosts()
|
|
718
798
|
|
|
719
799
|
|
|
800
|
+
@respx.mock
|
|
801
|
+
async def test_capacity_503_raises_sandbox_capacity_error(api: SandboxAPI):
|
|
802
|
+
"""A 503 with error_code CAPACITY is a host-limit refusal, not an
|
|
803
|
+
outage. It stays a SandboxUnavailableError so existing retries keep
|
|
804
|
+
working, and carries the service's Retry-After in seconds."""
|
|
805
|
+
|
|
806
|
+
respx.post(f"{BASE_URL}/hosts").mock(
|
|
807
|
+
return_value=httpx.Response(
|
|
808
|
+
503,
|
|
809
|
+
json={"detail": "provider 'exe' is at its host limit", "error_code": "CAPACITY"},
|
|
810
|
+
headers={"Retry-After": "30"},
|
|
811
|
+
),
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
with pytest.raises(SandboxCapacityError, match="host limit") as exc_info:
|
|
815
|
+
await api.create_host()
|
|
816
|
+
|
|
817
|
+
assert isinstance(exc_info.value, SandboxUnavailableError)
|
|
818
|
+
assert exc_info.value.retry_after == 30
|
|
819
|
+
|
|
820
|
+
|
|
720
821
|
@respx.mock
|
|
721
822
|
async def test_non_json_body_raises_sandbox_response_error(api: SandboxAPI):
|
|
722
823
|
respx.get(f"{BASE_URL}/hosts").mock(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|