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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ finally:
few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
an `idempotency_key` for retry safety: a retry with the same key after a
successful provision returns the original host instead of creating a duplicate.
A provider at its host limit raises `SandboxCapacityError`, a
`SandboxUnavailableError` whose `retry_after` gives the seconds to wait.

`SandboxAPI.from_env(prefix="SANDBOX_")` reads
`SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
Expand Down
2 changes: 2 additions & 0 deletions src/drukbox_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .exceptions import (
SandboxAPIError,
SandboxAuthError,
SandboxCapacityError,
SandboxConflictError,
SandboxNotFoundError,
SandboxProvisioningError,
Expand All @@ -36,6 +37,7 @@
"SandboxAPI",
"SandboxAPIError",
"SandboxAuthError",
"SandboxCapacityError",
"SandboxConflictError",
"SandboxHost",
"SandboxNotFoundError",
Expand Down
6 changes: 6 additions & 0 deletions src/drukbox_sdk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

from .exceptions import (
SandboxAuthError,
SandboxCapacityError,
SandboxConflictError,
SandboxNotFoundError,
SandboxProvisioningError,
Expand Down Expand Up @@ -615,6 +616,11 @@ async def _request(
raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))

if response.status_code == 503:
if json_response.get("error_code") == "CAPACITY":
raise SandboxCapacityError(
json_response.get("detail", "provider at host limit"),
retry_after=int(response.headers["Retry-After"]),
)
raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))

if response.status_code == 422:
Expand Down
12 changes: 12 additions & 0 deletions src/drukbox_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ class SandboxUnavailableError(RuntimeError):
"""


class SandboxCapacityError(SandboxUnavailableError):
"""503 — the provider is at its host limit.

``retry_after`` is the service's ``Retry-After`` value in seconds:
wait at least that long before the next create.
"""

def __init__(self, message: str, retry_after: int) -> None:
super().__init__(message)
self.retry_after = retry_after


class SandboxAPIError(RuntimeError):
"""Base for any error the sandbox service returned via HTTP."""

Expand Down
22 changes: 22 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
Issuer,
SandboxAPI,
SandboxAuthError,
SandboxCapacityError,
SandboxConflictError,
SandboxNotFoundError,
SandboxProvisioningError,
Expand Down Expand Up @@ -796,6 +797,27 @@ async def test_503_raises_sandbox_unavailable_error(api: SandboxAPI):
await api.list_hosts()


@respx.mock
async def test_capacity_503_raises_sandbox_capacity_error(api: SandboxAPI):
"""A 503 with error_code CAPACITY is a host-limit refusal, not an
outage. It stays a SandboxUnavailableError so existing retries keep
working, and carries the service's Retry-After in seconds."""

respx.post(f"{BASE_URL}/hosts").mock(
return_value=httpx.Response(
503,
json={"detail": "provider 'exe' is at its host limit", "error_code": "CAPACITY"},
headers={"Retry-After": "30"},
),
)

with pytest.raises(SandboxCapacityError, match="host limit") as exc_info:
await api.create_host()

assert isinstance(exc_info.value, SandboxUnavailableError)
assert exc_info.value.retry_after == 30


@respx.mock
async def test_non_json_body_raises_sandbox_response_error(api: SandboxAPI):
respx.get(f"{BASE_URL}/hosts").mock(
Expand Down
Loading