From 9ddfca661a73fcfe51c83a44b298b46c6a1dc604 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 12 Oct 2026 22:26:56 +0200 Subject: [PATCH] Raise SandboxCapacityError on a capacity 503 A 503 whose body has error_code CAPACITY means the provider is at its host limit, not that the service is down. Raise SandboxCapacityError, a SandboxUnavailableError subclass that carries the Retry-After value in seconds. Existing callers that catch SandboxUnavailableError keep their retry; any other 503 is unchanged. --- README.md | 2 ++ src/drukbox_sdk/__init__.py | 2 ++ src/drukbox_sdk/api.py | 6 ++++++ src/drukbox_sdk/exceptions.py | 12 ++++++++++++ tests/test_api.py | 22 ++++++++++++++++++++++ 5 files changed, 44 insertions(+) diff --git a/README.md b/README.md index a47e930..4f8429d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/drukbox_sdk/__init__.py b/src/drukbox_sdk/__init__.py index d15f3e9..e3d7272 100644 --- a/src/drukbox_sdk/__init__.py +++ b/src/drukbox_sdk/__init__.py @@ -19,6 +19,7 @@ from .exceptions import ( SandboxAPIError, SandboxAuthError, + SandboxCapacityError, SandboxConflictError, SandboxNotFoundError, SandboxProvisioningError, @@ -36,6 +37,7 @@ "SandboxAPI", "SandboxAPIError", "SandboxAuthError", + "SandboxCapacityError", "SandboxConflictError", "SandboxHost", "SandboxNotFoundError", diff --git a/src/drukbox_sdk/api.py b/src/drukbox_sdk/api.py index 6b77a49..6d7664e 100644 --- a/src/drukbox_sdk/api.py +++ b/src/drukbox_sdk/api.py @@ -47,6 +47,7 @@ from .exceptions import ( SandboxAuthError, + SandboxCapacityError, SandboxConflictError, SandboxNotFoundError, SandboxProvisioningError, @@ -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: diff --git a/src/drukbox_sdk/exceptions.py b/src/drukbox_sdk/exceptions.py index 0bcf1fc..7c524f6 100644 --- a/src/drukbox_sdk/exceptions.py +++ b/src/drukbox_sdk/exceptions.py @@ -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.""" diff --git a/tests/test_api.py b/tests/test_api.py index 7b39d89..4a7dacc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -28,6 +28,7 @@ Issuer, SandboxAPI, SandboxAuthError, + SandboxCapacityError, SandboxConflictError, SandboxNotFoundError, SandboxProvisioningError, @@ -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(