drukbox-python-sdk 0.0.1__py3-none-any.whl
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.
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drukbox-python-sdk
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Async Python client for Drukbox.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.28
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# drukbox-python-sdk
|
|
10
|
+
|
|
11
|
+
Async Python client for the [Drukbox] host API.
|
|
12
|
+
|
|
13
|
+
The SDK provisions sandbox VMs, reads host state, deletes hosts, and
|
|
14
|
+
returns the SSH connection details a caller needs. It speaks HTTP only:
|
|
15
|
+
SSH sessions, file transfer, command execution, and retry orchestration
|
|
16
|
+
belong in the caller.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install drukbox-python-sdk
|
|
22
|
+
uv add drukbox-python-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from drukbox_sdk import SandboxAPI
|
|
29
|
+
|
|
30
|
+
sandbox = SandboxAPI(
|
|
31
|
+
base_url="https://sandbox.internal.ts.net",
|
|
32
|
+
token="...",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
host = await sandbox.create_host(
|
|
37
|
+
image="ghcr.io/drukbox/sandbox:abc123",
|
|
38
|
+
env={"FOO": "bar"},
|
|
39
|
+
idempotency_key="agent-run-42",
|
|
40
|
+
)
|
|
41
|
+
# Use host.external_ssh_host (or host.internal_ssh_host when the
|
|
42
|
+
# service runs with Tailscale enabled), host.external_ssh_port,
|
|
43
|
+
# and host.known_hosts with asyncssh or another SSH client.
|
|
44
|
+
finally:
|
|
45
|
+
await sandbox.delete_host(host.id)
|
|
46
|
+
await sandbox.aclose()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`create_host` blocks until the host is `active` — typically ~10–30s, up to a
|
|
50
|
+
few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
|
|
51
|
+
an `idempotency_key` for retry safety: a retry with the same key after a
|
|
52
|
+
successful provision returns the original host instead of creating a duplicate.
|
|
53
|
+
|
|
54
|
+
`SandboxAPI.from_env(prefix="SANDBOX_")` reads
|
|
55
|
+
`SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
|
|
56
|
+
`SANDBOX_SERVICE_TIMEOUT`.
|
|
57
|
+
|
|
58
|
+
## Contract
|
|
59
|
+
|
|
60
|
+
Public exports live in `drukbox_sdk`:
|
|
61
|
+
|
|
62
|
+
- `SandboxAPI`
|
|
63
|
+
- `SandboxHost`
|
|
64
|
+
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
65
|
+
unavailable, and unclassified response errors
|
|
66
|
+
|
|
67
|
+
Supported host operations:
|
|
68
|
+
|
|
69
|
+
- `create_host`
|
|
70
|
+
- `get_host`
|
|
71
|
+
- `attach`
|
|
72
|
+
- `list_hosts`
|
|
73
|
+
- `delete_host`
|
|
74
|
+
- `aclose`
|
|
75
|
+
|
|
76
|
+
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
77
|
+
and `Idempotency-Key` inputs.
|
|
78
|
+
|
|
79
|
+
The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
|
|
80
|
+
provision Linux users, transfer files, or run remote commands.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv sync
|
|
86
|
+
uv run ruff check
|
|
87
|
+
uv run ruff format --check
|
|
88
|
+
uv run pyright
|
|
89
|
+
uv run pytest
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Tests use `respx` to fake the Drukbox HTTP API. They do not need a
|
|
93
|
+
real network, VM provider, or Drukbox service.
|
|
94
|
+
|
|
95
|
+
[Drukbox]: https://github.com/clawhaven/drukbox
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
drukbox_sdk/__init__.py,sha256=KJmc7_s4WLMBbD_IlMoILfGvfFNcaR8cu7TbPeKHDs4,658
|
|
2
|
+
drukbox_sdk/api.py,sha256=wz-xjcg04FdLm8DIFSJhC-OQaaX5K0R7sZRJrTuD5RE,11940
|
|
3
|
+
drukbox_sdk/exceptions.py,sha256=eGtoDqmyt5vF0f4AJ4XmOatEe1vVl9KDOfI2YygdA2k,1811
|
|
4
|
+
drukbox_python_sdk-0.0.1.dist-info/METADATA,sha256=PAEEpxMHuVJIuxp_dR6l4380XBSUA_bhuO3diqmInbg,2456
|
|
5
|
+
drukbox_python_sdk-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
drukbox_python_sdk-0.0.1.dist-info/RECORD,,
|
drukbox_sdk/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Async Python client for Drukbox.
|
|
2
|
+
|
|
3
|
+
Public surface — anything not re-exported here is an implementation
|
|
4
|
+
detail and may change without notice.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .api import (
|
|
8
|
+
SandboxAPI,
|
|
9
|
+
SandboxHost,
|
|
10
|
+
)
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
SandboxAPIError,
|
|
13
|
+
SandboxAuthError,
|
|
14
|
+
SandboxConflictError,
|
|
15
|
+
SandboxNotFoundError,
|
|
16
|
+
SandboxProvisioningError,
|
|
17
|
+
SandboxResponseError,
|
|
18
|
+
SandboxUnavailableError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"SandboxAPI",
|
|
23
|
+
"SandboxAPIError",
|
|
24
|
+
"SandboxAuthError",
|
|
25
|
+
"SandboxConflictError",
|
|
26
|
+
"SandboxHost",
|
|
27
|
+
"SandboxNotFoundError",
|
|
28
|
+
"SandboxProvisioningError",
|
|
29
|
+
"SandboxResponseError",
|
|
30
|
+
"SandboxUnavailableError",
|
|
31
|
+
]
|
drukbox_sdk/api.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Async HTTP client for Drukbox.
|
|
2
|
+
|
|
3
|
+
The service is the canonical owner of host provisioning, Tailscale
|
|
4
|
+
auth-key minting and device discovery, exe.dev VM lifecycle, and host
|
|
5
|
+
teardown. This SDK is a thin wrapper around its HTTP API; everything
|
|
6
|
+
that needs to happen *inside* a provisioned VM (SSH, file transfer,
|
|
7
|
+
command execution) is the caller's responsibility — the SDK hands back
|
|
8
|
+
the host record with ``external_ssh_host`` / ``external_ssh_port`` /
|
|
9
|
+
``internal_ssh_host`` / ``known_hosts`` and stops there.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from drukbox_sdk import SandboxAPI
|
|
14
|
+
|
|
15
|
+
sandbox = SandboxAPI(
|
|
16
|
+
base_url="https://sandbox.internal.ts.net",
|
|
17
|
+
token="...",
|
|
18
|
+
)
|
|
19
|
+
try:
|
|
20
|
+
host = await sandbox.create_host(
|
|
21
|
+
image="ghcr.io/.../sandbox:abc123",
|
|
22
|
+
idempotency_key="agent-run-42",
|
|
23
|
+
)
|
|
24
|
+
# ... use host.external_ssh_host etc. with asyncssh ...
|
|
25
|
+
finally:
|
|
26
|
+
await sandbox.delete_host(host.id)
|
|
27
|
+
await sandbox.aclose()
|
|
28
|
+
|
|
29
|
+
For env-backed config use :meth:`SandboxAPI.from_env`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import os
|
|
34
|
+
import uuid
|
|
35
|
+
from dataclasses import dataclass, fields
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from typing import Any, Self
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
from .exceptions import (
|
|
42
|
+
SandboxAuthError,
|
|
43
|
+
SandboxConflictError,
|
|
44
|
+
SandboxNotFoundError,
|
|
45
|
+
SandboxProvisioningError,
|
|
46
|
+
SandboxResponseError,
|
|
47
|
+
SandboxUnavailableError,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Explicit pool budget. The httpx defaults (100 / 20) are plenty for
|
|
51
|
+
# the SDK's traffic shape (a handful of provisioning calls per agent
|
|
52
|
+
# run), but keeping the values visible at the import site means a
|
|
53
|
+
# future bump in concurrency does not silently exhaust the pool.
|
|
54
|
+
_SANDBOX_HTTP_LIMITS = httpx.Limits(
|
|
55
|
+
max_connections=20,
|
|
56
|
+
max_keepalive_connections=5,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class SandboxHost:
|
|
62
|
+
"""Snapshot of a provisioned host as returned by the service.
|
|
63
|
+
|
|
64
|
+
The shape mirrors the Drukbox ``Host`` schema. ``external_ssh_host``
|
|
65
|
+
is always populated by the VM provider; ``internal_ssh_host`` is
|
|
66
|
+
populated only when the service runs with Tailscale enabled (MagicDNS
|
|
67
|
+
name on the tailnet). The internal path is always reached on port 22
|
|
68
|
+
by Tailscale convention, so there is no ``internal_ssh_port``.
|
|
69
|
+
Callers pick whichever path they can reach and dial it themselves —
|
|
70
|
+
this SDK doesn't speak SSH.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
id: str
|
|
74
|
+
name: str
|
|
75
|
+
status: str
|
|
76
|
+
provider: str
|
|
77
|
+
image: str
|
|
78
|
+
external_ssh_host: str
|
|
79
|
+
external_ssh_port: int
|
|
80
|
+
internal_ssh_host: str | None
|
|
81
|
+
known_hosts: str
|
|
82
|
+
tailscale_device_id: str | None
|
|
83
|
+
last_error: str
|
|
84
|
+
created_at: str
|
|
85
|
+
updated_at: str
|
|
86
|
+
activated_at: str | None
|
|
87
|
+
expires_at: str | None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_SANDBOX_HOST_FIELDS = {field.name for field in fields(SandboxHost)}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
|
|
94
|
+
"""Build a :class:`SandboxHost` picking only known fields.
|
|
95
|
+
|
|
96
|
+
Defensive against the service adding new fields — those flow
|
|
97
|
+
through harmlessly without breaking the SDK on the unsuspecting
|
|
98
|
+
caller's side.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SandboxAPI:
|
|
105
|
+
"""Async client for Drukbox.
|
|
106
|
+
|
|
107
|
+
Construct one per process (or one per consuming subsystem) and
|
|
108
|
+
reuse it. The internal ``httpx.AsyncClient`` is loop-aware: if the
|
|
109
|
+
client gets used from a different event loop than the one it was
|
|
110
|
+
created on (e.g. an ASGI request handler vs. a CLI command vs. a
|
|
111
|
+
cron job all using the same SDK instance via module-level state),
|
|
112
|
+
it transparently rebinds to the running loop on next use. This is
|
|
113
|
+
a known foot-gun with long-lived ``httpx.AsyncClient`` instances;
|
|
114
|
+
we handle it here so callers don't have to.
|
|
115
|
+
|
|
116
|
+
Always call :meth:`aclose` during graceful shutdown.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, *, base_url: str, token: str, timeout: float = 300.0) -> None:
|
|
120
|
+
# The default covers the worst-case server-side budget for inline
|
|
121
|
+
# `POST /hosts`: Tailscale device discovery (~180s) + ssh-keyscan
|
|
122
|
+
# retries (~30s) + provider + network jitter. A tighter timeout that
|
|
123
|
+
# fires while the server is still provisioning leaves an orphan VM
|
|
124
|
+
# on the provider side until the janitor reaps it.
|
|
125
|
+
self.base_url = base_url.rstrip("/")
|
|
126
|
+
self.token = token
|
|
127
|
+
self.timeout = timeout
|
|
128
|
+
self._client: httpx.AsyncClient | None = None
|
|
129
|
+
# The bound event loop is recorded the first time a client is
|
|
130
|
+
# created. httpx clients are tied to the loop they were
|
|
131
|
+
# instantiated on; reusing one across loops raises at request
|
|
132
|
+
# time. Track the loop here so cross-loop reuse rebinds
|
|
133
|
+
# instead of crashing.
|
|
134
|
+
self._client_loop: asyncio.AbstractEventLoop | None = None
|
|
135
|
+
# Concurrent first-callers can both observe ``self._client is
|
|
136
|
+
# None`` and race to create two clients, leaking one. Serialize
|
|
137
|
+
# the initialization path with a lazy-allocated lock.
|
|
138
|
+
self._client_lock: asyncio.Lock | None = None
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_env(cls, *, prefix: str = "SANDBOX_") -> Self:
|
|
142
|
+
"""Build from ``{prefix}SERVICE_URL`` / ``{prefix}SERVICE_TOKEN``
|
|
143
|
+
/ ``{prefix}SERVICE_TIMEOUT`` env vars.
|
|
144
|
+
|
|
145
|
+
Convenience for callers that don't want to thread settings
|
|
146
|
+
through their own config layer. The constructor stays the
|
|
147
|
+
canonical entry point — this just reads the env once.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
url = os.environ[f"{prefix}SERVICE_URL"]
|
|
151
|
+
token = os.environ[f"{prefix}SERVICE_TOKEN"]
|
|
152
|
+
timeout = float(os.environ.get(f"{prefix}SERVICE_TIMEOUT", "300"))
|
|
153
|
+
return cls(base_url=url, token=token, timeout=timeout)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Host lifecycle
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def create_host(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
env: dict[str, str] | None = None,
|
|
163
|
+
expires_at: datetime | None = None,
|
|
164
|
+
idempotency_key: str | None = None,
|
|
165
|
+
image: str | None = None,
|
|
166
|
+
) -> SandboxHost:
|
|
167
|
+
"""Provision a new host.
|
|
168
|
+
|
|
169
|
+
The service provisions inline; this call blocks until the host
|
|
170
|
+
is ``active`` (~10-30s typical, up to a few minutes in the worst
|
|
171
|
+
case). Raises :class:`SandboxProvisioningError` if the service
|
|
172
|
+
reaches its own provisioning failure (502); raises
|
|
173
|
+
:class:`SandboxResponseError` on transport failure or unexpected
|
|
174
|
+
status.
|
|
175
|
+
|
|
176
|
+
``idempotency_key`` is sent as the service's ``Idempotency-Key``
|
|
177
|
+
header. Useful for retry safety on flaky networks — a retry with
|
|
178
|
+
the same key after a successful provision returns the original
|
|
179
|
+
host instead of creating a duplicate.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
payload: dict[str, Any] = {}
|
|
183
|
+
if env is not None:
|
|
184
|
+
payload["env"] = env
|
|
185
|
+
if expires_at is not None:
|
|
186
|
+
payload["expires_at"] = expires_at.isoformat()
|
|
187
|
+
if image is not None:
|
|
188
|
+
payload["image"] = image
|
|
189
|
+
|
|
190
|
+
headers: dict[str, str] = {}
|
|
191
|
+
if idempotency_key is not None:
|
|
192
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
193
|
+
|
|
194
|
+
data = await self._request("POST", "/hosts", json=payload, headers=headers)
|
|
195
|
+
assert isinstance(data, dict)
|
|
196
|
+
return _parse_sandbox_host(data)
|
|
197
|
+
|
|
198
|
+
async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
199
|
+
data = await self._request("GET", f"/hosts/{host_id}")
|
|
200
|
+
assert isinstance(data, dict)
|
|
201
|
+
return _parse_sandbox_host(data)
|
|
202
|
+
|
|
203
|
+
async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
204
|
+
"""Alias for :meth:`get_host` that reads better at call sites
|
|
205
|
+
coming back to a host they provisioned in an earlier process.
|
|
206
|
+
|
|
207
|
+
Same wire call; the rename only exists so a restart-resumption
|
|
208
|
+
path doesn't have to start with ``get_host`` (which would read
|
|
209
|
+
like "go discover a host" when the intent is "reattach to one
|
|
210
|
+
I already know about").
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
return await self.get_host(host_id)
|
|
214
|
+
|
|
215
|
+
async def list_hosts(self) -> list[SandboxHost]:
|
|
216
|
+
data = await self._request("GET", "/hosts")
|
|
217
|
+
assert isinstance(data, list)
|
|
218
|
+
return [_parse_sandbox_host(item) for item in data]
|
|
219
|
+
|
|
220
|
+
async def delete_host(self, host_id: uuid.UUID | str) -> None:
|
|
221
|
+
await self._request("DELETE", f"/hosts/{host_id}")
|
|
222
|
+
|
|
223
|
+
async def aclose(self) -> None:
|
|
224
|
+
if self._client is None:
|
|
225
|
+
return
|
|
226
|
+
await self._client.aclose()
|
|
227
|
+
self._client = None
|
|
228
|
+
self._client_loop = None
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# Internals
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
async def _request(
|
|
235
|
+
self,
|
|
236
|
+
method: str,
|
|
237
|
+
path: str,
|
|
238
|
+
*,
|
|
239
|
+
headers: dict[str, str] | None = None,
|
|
240
|
+
json: dict[str, Any] | None = None,
|
|
241
|
+
) -> dict[str, Any] | list[Any]:
|
|
242
|
+
client = await self._get_client()
|
|
243
|
+
request_headers = {
|
|
244
|
+
"Authorization": f"Bearer {self.token}",
|
|
245
|
+
"Accept": "application/json",
|
|
246
|
+
}
|
|
247
|
+
if headers is not None:
|
|
248
|
+
request_headers.update(headers)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
response = await client.request(
|
|
252
|
+
method,
|
|
253
|
+
f"{self.base_url}{path}",
|
|
254
|
+
json=json,
|
|
255
|
+
headers=request_headers,
|
|
256
|
+
timeout=self.timeout,
|
|
257
|
+
)
|
|
258
|
+
except httpx.RequestError as exc:
|
|
259
|
+
raise SandboxUnavailableError(f"Sandbox service transport failed: {exc}") from exc
|
|
260
|
+
|
|
261
|
+
if response.status_code == 204:
|
|
262
|
+
return {}
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
json_response = response.json()
|
|
266
|
+
except ValueError as exc:
|
|
267
|
+
raise SandboxResponseError("Sandbox service returned non-JSON output") from exc
|
|
268
|
+
|
|
269
|
+
if response.status_code in {401, 403}:
|
|
270
|
+
raise SandboxAuthError(json_response.get("detail", "auth failed"))
|
|
271
|
+
|
|
272
|
+
if response.status_code == 404:
|
|
273
|
+
raise SandboxNotFoundError(json_response.get("detail", "not found"))
|
|
274
|
+
|
|
275
|
+
if response.status_code == 409:
|
|
276
|
+
raise SandboxConflictError(json_response.get("detail", "conflict"))
|
|
277
|
+
|
|
278
|
+
if response.status_code == 502:
|
|
279
|
+
raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))
|
|
280
|
+
|
|
281
|
+
if response.status_code == 503:
|
|
282
|
+
raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
|
|
283
|
+
|
|
284
|
+
if response.status_code >= 400:
|
|
285
|
+
raise SandboxResponseError(json_response.get("detail", "error"))
|
|
286
|
+
return json_response
|
|
287
|
+
|
|
288
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
289
|
+
running_loop = asyncio.get_running_loop()
|
|
290
|
+
# Fast-path: client exists and is bound to the current loop.
|
|
291
|
+
if self._client is not None and self._client_loop is running_loop:
|
|
292
|
+
return self._client
|
|
293
|
+
# Slow-path: either no client yet, or the client is bound to a
|
|
294
|
+
# stale loop (e.g. fixture teardown). Serialize through the
|
|
295
|
+
# lock.
|
|
296
|
+
if self._client_lock is None:
|
|
297
|
+
self._client_lock = asyncio.Lock()
|
|
298
|
+
async with self._client_lock:
|
|
299
|
+
# Re-check under the lock; another coroutine may have raced
|
|
300
|
+
# ahead.
|
|
301
|
+
if self._client is not None and self._client_loop is running_loop:
|
|
302
|
+
return self._client
|
|
303
|
+
if self._client is not None:
|
|
304
|
+
# Stale-loop client: closing on its own loop is unsafe,
|
|
305
|
+
# so drop the reference and rely on GC. httpx will emit
|
|
306
|
+
# an "unclosed client" warning, which is the correct
|
|
307
|
+
# signal that the process-level lifecycle hook
|
|
308
|
+
# (e.g. ASGI lifespan) didn't run on the previous loop.
|
|
309
|
+
self._client = None
|
|
310
|
+
self._client_loop = None
|
|
311
|
+
self._client = httpx.AsyncClient(limits=_SANDBOX_HTTP_LIMITS)
|
|
312
|
+
self._client_loop = running_loop
|
|
313
|
+
return self._client
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Typed errors for Drukbox interactions.
|
|
2
|
+
|
|
3
|
+
The hierarchy lets callers distinguish "I can't reach the service at
|
|
4
|
+
all" from "the service told me no" and lets them narrow on common HTTP
|
|
5
|
+
shapes (auth, not-found, conflict) without parsing status codes
|
|
6
|
+
themselves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SandboxUnavailableError(RuntimeError):
|
|
11
|
+
"""Sandbox service is unreachable or transiently broken.
|
|
12
|
+
|
|
13
|
+
Raised for both transport-level failures (DNS, connection refused,
|
|
14
|
+
TLS, timeouts) and 503 responses from the service itself. Both
|
|
15
|
+
signal "retry with backoff" rather than "the request was rejected."
|
|
16
|
+
Distinct from :class:`SandboxAPIError`, which represents the
|
|
17
|
+
service deliberately refusing a request.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SandboxAPIError(RuntimeError):
|
|
22
|
+
"""Base for any error the sandbox service returned via HTTP."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SandboxAuthError(SandboxAPIError):
|
|
26
|
+
"""401/403 from the sandbox service. Token wrong, missing, or revoked."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SandboxNotFoundError(SandboxAPIError):
|
|
30
|
+
"""404 — the resource ID isn't known to the sandbox service.
|
|
31
|
+
|
|
32
|
+
Common cause during host-attach paths: the host was already torn
|
|
33
|
+
down by a sibling worker / housekeeping sweep.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SandboxConflictError(SandboxAPIError):
|
|
38
|
+
"""409 — the operation conflicts with the current state.
|
|
39
|
+
|
|
40
|
+
e.g. provisioning a Linux user on a host that already has one with
|
|
41
|
+
the same name.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SandboxProvisioningError(SandboxAPIError):
|
|
46
|
+
"""502 — the service tried to provision the host and the provider,
|
|
47
|
+
Tailscale, or ssh-keyscan step failed. The host row stays in
|
|
48
|
+
``error`` state on the service side; call :meth:`delete_host` to
|
|
49
|
+
release any partial provider state.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SandboxResponseError(SandboxAPIError):
|
|
54
|
+
"""Everything else the service returned that the SDK doesn't classify."""
|