drukbox-python-sdk 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: drukbox-python-sdk
3
- Version: 0.3.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
@@ -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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -19,6 +19,7 @@ from .api import (
19
19
  from .exceptions import (
20
20
  SandboxAPIError,
21
21
  SandboxAuthError,
22
+ SandboxCapacityError,
22
23
  SandboxConflictError,
23
24
  SandboxNotFoundError,
24
25
  SandboxProvisioningError,
@@ -36,6 +37,7 @@ __all__ = [
36
37
  "SandboxAPI",
37
38
  "SandboxAPIError",
38
39
  "SandboxAuthError",
40
+ "SandboxCapacityError",
39
41
  "SandboxConflictError",
40
42
  "SandboxHost",
41
43
  "SandboxNotFoundError",
@@ -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,
@@ -435,14 +436,15 @@ class SandboxAPI:
435
436
  base_image: str | None = None,
436
437
  label: str | None = None,
437
438
  ) -> SandboxTemplate:
438
- """Start a template build and return immediately.
439
-
440
- The service responds 202 with ``status="building"``. Poll
441
- :meth:`get_template` until the status becomes ``"available"`` or
442
- ``"failed"``. A failed record carries the reason in
443
- ``last_error``. A repeated post with the same provider, base
444
- image, and setup script returns the existing record without a
445
- rebuild.
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.
446
448
  """
447
449
 
448
450
  payload: dict[str, Any] = {"setup_script": setup_script}
@@ -614,6 +616,11 @@ class SandboxAPI:
614
616
  raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))
615
617
 
616
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
+ )
617
624
  raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
618
625
 
619
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,
@@ -796,6 +797,27 @@ async def test_503_raises_sandbox_unavailable_error(api: SandboxAPI):
796
797
  await api.list_hosts()
797
798
 
798
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
+
799
821
  @respx.mock
800
822
  async def test_non_json_body_raises_sandbox_response_error(api: SandboxAPI):
801
823
  respx.get(f"{BASE_URL}/hosts").mock(