drukbox-python-sdk 0.3.0__tar.gz → 0.5.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.5.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.5.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,
@@ -197,7 +198,8 @@ class DoctorReport:
197
198
 
198
199
  ok: bool
199
200
  active_provider: str
200
- tailscale_enabled: bool
201
+ # Whether the active provider's hosts join the tailnet.
202
+ tailnet: bool
201
203
  checks: list[DoctorCheck]
202
204
 
203
205
 
@@ -435,14 +437,15 @@ class SandboxAPI:
435
437
  base_image: str | None = None,
436
438
  label: str | None = None,
437
439
  ) -> 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.
440
+ """Start a template build.
441
+
442
+ The service pulls the base image, then responds 202 with
443
+ ``status="building"``. Poll :meth:`get_template` until the status
444
+ becomes ``"available"`` or ``"failed"``. A failed record carries the
445
+ reason in ``last_error``. A repeated post with the same provider,
446
+ base image digest, and setup script returns the existing record. A
447
+ ``"failed"`` record builds again and keeps its ``last_error`` until
448
+ that build ends.
446
449
  """
447
450
 
448
451
  payload: dict[str, Any] = {"setup_script": setup_script}
@@ -488,7 +491,8 @@ class SandboxAPI:
488
491
  return DoctorReport(
489
492
  ok=data["ok"],
490
493
  active_provider=data["active_provider"],
491
- tailscale_enabled=data["tailscale_enabled"],
494
+ # drukbox before "Join the tailnet per provider" sent tailscale_enabled.
495
+ tailnet=data["tailnet"] if "tailnet" in data else data["tailscale_enabled"],
492
496
  checks=[DoctorCheck(**check) for check in data["checks"]],
493
497
  )
494
498
 
@@ -614,6 +618,11 @@ class SandboxAPI:
614
618
  raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))
615
619
 
616
620
  if response.status_code == 503:
621
+ if json_response.get("error_code") == "CAPACITY":
622
+ raise SandboxCapacityError(
623
+ json_response.get("detail", "provider at host limit"),
624
+ retry_after=int(response.headers["Retry-After"]),
625
+ )
617
626
  raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
618
627
 
619
628
  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,
@@ -47,7 +48,7 @@ def _doctor_payload(**overrides: Any) -> dict[str, Any]:
47
48
  payload: dict[str, Any] = {
48
49
  "ok": True,
49
50
  "active_provider": "aws",
50
- "tailscale_enabled": True,
51
+ "tailnet": True,
51
52
  "checks": [
52
53
  {
53
54
  "name": "db",
@@ -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(
@@ -903,6 +925,17 @@ def test_from_env_strips_trailing_slash_via_constructor(
903
925
  assert sandbox.base_url == "https://trailing.test"
904
926
 
905
927
 
928
+ @respx.mock
929
+ async def test_doctor_reads_tailscale_enabled_from_an_older_drukbox(api: SandboxAPI):
930
+ payload = _doctor_payload()
931
+ payload["tailscale_enabled"] = payload.pop("tailnet")
932
+ respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
933
+
934
+ report = await api.doctor()
935
+
936
+ assert report.tailnet is True
937
+
938
+
906
939
  @respx.mock
907
940
  async def test_doctor_parses_report_and_checks(api: SandboxAPI):
908
941
  route = respx.get(f"{BASE_URL}/doctor").mock(
@@ -915,7 +948,7 @@ async def test_doctor_parses_report_and_checks(api: SandboxAPI):
915
948
  assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
916
949
  assert report.ok is True
917
950
  assert report.active_provider == "aws"
918
- assert report.tailscale_enabled is True
951
+ assert report.tailnet is True
919
952
  assert [check.name for check in report.checks] == ["db", "provider", "tailscale"]
920
953
  provider = next(check for check in report.checks if check.name == "provider")
921
954
  assert provider.status == "ok"