briq-cloud 0.1.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.
- briq_cloud/__init__.py +21 -0
- briq_cloud/_async.py +300 -0
- briq_cloud/_config.py +59 -0
- briq_cloud/_errors.py +153 -0
- briq_cloud/_flows.py +691 -0
- briq_cloud/_models.py +89 -0
- briq_cloud/_sync.py +285 -0
- briq_cloud/_version.py +1 -0
- briq_cloud/py.typed +0 -0
- briq_cloud-0.1.1.dist-info/METADATA +150 -0
- briq_cloud-0.1.1.dist-info/RECORD +13 -0
- briq_cloud-0.1.1.dist-info/WHEEL +4 -0
- briq_cloud-0.1.1.dist-info/licenses/LICENSE +21 -0
briq_cloud/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Python client for briq: sandboxes you create, run commands in, and read files from."""
|
|
2
|
+
|
|
3
|
+
from ._async import AsyncBriq, AsyncSandbox
|
|
4
|
+
from ._errors import BriqError, Code
|
|
5
|
+
from ._models import ExecResult, FileInfo, OutputChunk, SandboxInfo
|
|
6
|
+
from ._sync import Briq, Sandbox
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AsyncBriq",
|
|
11
|
+
"AsyncSandbox",
|
|
12
|
+
"Briq",
|
|
13
|
+
"BriqError",
|
|
14
|
+
"Code",
|
|
15
|
+
"ExecResult",
|
|
16
|
+
"FileInfo",
|
|
17
|
+
"OutputChunk",
|
|
18
|
+
"Sandbox",
|
|
19
|
+
"SandboxInfo",
|
|
20
|
+
"__version__",
|
|
21
|
+
]
|
briq_cloud/_async.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""The asynchronous client: the flows of ``_flows`` driven over ``httpx.AsyncClient``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from . import _flows as f
|
|
14
|
+
from ._config import headers, resolve, timeout_of
|
|
15
|
+
from ._errors import BriqError
|
|
16
|
+
from ._models import ExecResult, FileInfo, OutputChunk, SandboxInfo
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _pause(seconds: float) -> None: # a name of its own, so tests can skip the waits
|
|
22
|
+
await asyncio.sleep(seconds)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncBriq:
|
|
26
|
+
"""A client for the briq API.
|
|
27
|
+
|
|
28
|
+
``api_key`` falls back to ``BRIQ_API_KEY`` and ``base_url`` to ``BRIQ_API_URL``, then to the
|
|
29
|
+
public API. ``timeout`` is the seconds each request may take, a command and a sandbox's start
|
|
30
|
+
each add their own. ``http_client`` is an ``httpx.AsyncClient`` of your own (a proxy, a CA
|
|
31
|
+
bundle, a mounted transport): it is used as it is, except that no redirect is ever followed,
|
|
32
|
+
its ``base_url`` is ignored, and ``close`` leaves it open. ``transport`` is for tests.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: str | None = None,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str | None = None,
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
42
|
+
http_client: httpx.AsyncClient | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._key, self._origin = resolve(api_key, base_url)
|
|
45
|
+
self._timeout = timeout
|
|
46
|
+
# The environment's proxy and CA settings apply (``HTTP(S)_PROXY``, ``SSL_CERT_FILE``).
|
|
47
|
+
self._owns_http = http_client is None
|
|
48
|
+
self._http = http_client or httpx.AsyncClient(transport=transport)
|
|
49
|
+
|
|
50
|
+
async def close(self) -> None:
|
|
51
|
+
if self._owns_http:
|
|
52
|
+
await self._http.aclose()
|
|
53
|
+
|
|
54
|
+
async def __aenter__(self) -> AsyncBriq:
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
async def __aexit__(
|
|
58
|
+
self,
|
|
59
|
+
exc_type: type[BaseException] | None,
|
|
60
|
+
exc: BaseException | None,
|
|
61
|
+
tb: TracebackType | None,
|
|
62
|
+
) -> None:
|
|
63
|
+
await self.close()
|
|
64
|
+
|
|
65
|
+
async def create_sandbox(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
name: str | None = None,
|
|
69
|
+
size: str | None = None,
|
|
70
|
+
image: str | None = None,
|
|
71
|
+
env: Mapping[str, str] | None = None,
|
|
72
|
+
secret_env: Mapping[str, str] | None = None,
|
|
73
|
+
sleep: str | None = None,
|
|
74
|
+
wait_timeout: float = 120.0,
|
|
75
|
+
idempotency_key: str | None = None,
|
|
76
|
+
) -> AsyncSandbox:
|
|
77
|
+
"""Make a sandbox and wait until it runs.
|
|
78
|
+
|
|
79
|
+
``env`` values show in the sandbox's status; ``secret_env`` values are kept write-only
|
|
80
|
+
(the API must support it, else ``unsupported``). The request is sent once. When no
|
|
81
|
+
usable answer comes, the error is ``outcome_unknown``: the sandbox may exist, and the
|
|
82
|
+
same ``idempotency_key`` makes a second call return it instead of another.
|
|
83
|
+
"""
|
|
84
|
+
info = await self._run(
|
|
85
|
+
f.create_sandbox(
|
|
86
|
+
name=name,
|
|
87
|
+
size=size,
|
|
88
|
+
env=env,
|
|
89
|
+
secret_env=secret_env,
|
|
90
|
+
sleep=sleep,
|
|
91
|
+
image=image,
|
|
92
|
+
wait_timeout=wait_timeout,
|
|
93
|
+
idempotency_key=idempotency_key,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return AsyncSandbox(self, info)
|
|
97
|
+
|
|
98
|
+
async def sandbox(self, id: str) -> AsyncSandbox:
|
|
99
|
+
"""Reconnect to a sandbox by id. It must exist: ``not_found`` otherwise."""
|
|
100
|
+
return AsyncSandbox(self, await self._run(f.get_sandbox(id)))
|
|
101
|
+
|
|
102
|
+
# --- the driver ---
|
|
103
|
+
|
|
104
|
+
async def _send(self, request: f.Request) -> f.Reply:
|
|
105
|
+
built = self._http.build_request(
|
|
106
|
+
request.method,
|
|
107
|
+
self._origin + request.path,
|
|
108
|
+
params=request.query,
|
|
109
|
+
json=request.json,
|
|
110
|
+
content=request.content,
|
|
111
|
+
headers=headers(self._key, request.headers),
|
|
112
|
+
timeout=timeout_of(request.timeout or self._timeout),
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
response = await self._http.send(built, stream=True, follow_redirects=False)
|
|
116
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
|
|
117
|
+
raise f.NoAnswer(never_sent=True) from exc
|
|
118
|
+
except httpx.HTTPError as exc:
|
|
119
|
+
raise f.NoAnswer(never_sent=False) from exc
|
|
120
|
+
try:
|
|
121
|
+
body = bytearray()
|
|
122
|
+
too_large = False
|
|
123
|
+
async for chunk in response.aiter_bytes():
|
|
124
|
+
body += chunk
|
|
125
|
+
if len(body) > request.max_bytes:
|
|
126
|
+
too_large = True
|
|
127
|
+
break
|
|
128
|
+
except httpx.HTTPError as exc:
|
|
129
|
+
raise f.NoAnswer(never_sent=False) from exc
|
|
130
|
+
finally:
|
|
131
|
+
await response.aclose()
|
|
132
|
+
return f.Reply(
|
|
133
|
+
response.status_code,
|
|
134
|
+
dict(response.headers),
|
|
135
|
+
bytes(body),
|
|
136
|
+
too_large,
|
|
137
|
+
response.num_bytes_downloaded,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def _perform(self, step: f.Request | f.Pause | f.Emit) -> Any:
|
|
141
|
+
if isinstance(step, f.Request):
|
|
142
|
+
return await self._send(step)
|
|
143
|
+
if isinstance(step, f.Pause):
|
|
144
|
+
await _pause(step.seconds)
|
|
145
|
+
else:
|
|
146
|
+
result = step.callback(step.chunk)
|
|
147
|
+
if inspect.isawaitable(result):
|
|
148
|
+
await result
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
async def _run(self, flow: f.Flow[T]) -> T:
|
|
152
|
+
"""Drive a flow: perform what it yields, send back the answer, throw in what fails."""
|
|
153
|
+
answer: Any = None
|
|
154
|
+
error: Exception | None = None
|
|
155
|
+
try:
|
|
156
|
+
while True:
|
|
157
|
+
try:
|
|
158
|
+
step = flow.throw(error) if error else flow.send(answer)
|
|
159
|
+
except StopIteration as done:
|
|
160
|
+
return done.value # type: ignore[no-any-return]
|
|
161
|
+
answer, error = None, None
|
|
162
|
+
try:
|
|
163
|
+
answer = await self._perform(step)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
error = exc
|
|
166
|
+
except BaseException as interrupt: # a cancel, a timeout, an interrupt
|
|
167
|
+
await self._unwind(flow, interrupt)
|
|
168
|
+
finally:
|
|
169
|
+
flow.close()
|
|
170
|
+
|
|
171
|
+
async def _unwind(self, flow: f.Flow[T], interrupt: BaseException) -> None:
|
|
172
|
+
"""A cancel (or interrupt) reached the driver: let the flow clean up, shielded from it.
|
|
173
|
+
|
|
174
|
+
The flow gets it thrown in, may yield more steps to undo what it started (destroy a
|
|
175
|
+
sandbox, stop a command), and ends by raising it again. Each step runs as a task of its
|
|
176
|
+
own, so a second cancel does not cut the cleanup short.
|
|
177
|
+
"""
|
|
178
|
+
thrown: BaseException | None = interrupt
|
|
179
|
+
answer: Any = None
|
|
180
|
+
while True:
|
|
181
|
+
try:
|
|
182
|
+
step = flow.throw(thrown) if thrown else flow.send(answer)
|
|
183
|
+
except StopIteration:
|
|
184
|
+
raise interrupt from None
|
|
185
|
+
thrown, answer = None, None
|
|
186
|
+
task = asyncio.ensure_future(self._perform(step))
|
|
187
|
+
while not task.done():
|
|
188
|
+
try:
|
|
189
|
+
await asyncio.wait({task})
|
|
190
|
+
except asyncio.CancelledError:
|
|
191
|
+
continue # the first cancel is what is re-raised; this one waits too
|
|
192
|
+
if task.cancelled():
|
|
193
|
+
continue
|
|
194
|
+
if (failure := task.exception()) is not None:
|
|
195
|
+
if not isinstance(failure, Exception):
|
|
196
|
+
raise failure
|
|
197
|
+
thrown = failure
|
|
198
|
+
else:
|
|
199
|
+
answer = task.result()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class AsyncSandbox:
|
|
203
|
+
"""One sandbox. ``id`` is all that is needed to reconnect to it with ``AsyncBriq.sandbox``."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, client: AsyncBriq, info: SandboxInfo) -> None:
|
|
206
|
+
self._client = client
|
|
207
|
+
self.info = info
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def id(self) -> str:
|
|
211
|
+
return self.info.id
|
|
212
|
+
|
|
213
|
+
async def refresh(self) -> SandboxInfo:
|
|
214
|
+
"""Read the sandbox's state again."""
|
|
215
|
+
self.info = await self._client._run(f.get_sandbox(self.id))
|
|
216
|
+
return self.info
|
|
217
|
+
|
|
218
|
+
async def exec(
|
|
219
|
+
self,
|
|
220
|
+
command: str | Sequence[str],
|
|
221
|
+
*,
|
|
222
|
+
cwd: str | None = None,
|
|
223
|
+
env: Mapping[str, str] | None = None,
|
|
224
|
+
stdin: str | None = None,
|
|
225
|
+
timeout: float = 60.0,
|
|
226
|
+
on_output: Callable[[OutputChunk], Any] | None = None,
|
|
227
|
+
) -> ExecResult:
|
|
228
|
+
"""Run a command and wait for it.
|
|
229
|
+
|
|
230
|
+
A string runs in a shell, a list is the program and its arguments. With ``on_output`` the
|
|
231
|
+
output reaches it as it is written, and the command may run up to 24 hours. A command that
|
|
232
|
+
outlives ``timeout`` is stopped: ``timed_out`` is true and ``exit_code`` is ``None``.
|
|
233
|
+
A command is sent once; when its end is not known the error is ``outcome_unknown``.
|
|
234
|
+
"""
|
|
235
|
+
return await self._client._run(
|
|
236
|
+
f.exec_command(
|
|
237
|
+
self.id,
|
|
238
|
+
command,
|
|
239
|
+
cwd=cwd,
|
|
240
|
+
env=env,
|
|
241
|
+
stdin=stdin,
|
|
242
|
+
timeout=timeout,
|
|
243
|
+
on_output=on_output,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
async def write_file(self, path: str, data: bytes, *, mode: int | None = None) -> None:
|
|
248
|
+
"""Write a file, and any directory above it that is missing. ``mode`` is e.g. ``0o755``."""
|
|
249
|
+
await self._client._run(f.write_file(self.id, path, data, mode))
|
|
250
|
+
|
|
251
|
+
async def read_file(self, path: str) -> bytes:
|
|
252
|
+
"""The bytes of a file. ``file_not_found`` when it is not there."""
|
|
253
|
+
return await self._client._run(f.read_file(self.id, path))
|
|
254
|
+
|
|
255
|
+
async def list_files(self, path: str) -> list[FileInfo]:
|
|
256
|
+
"""The entries of a directory. Needs ``python3`` in the sandbox."""
|
|
257
|
+
return await self._client._run(f.list_files(self.id, path))
|
|
258
|
+
|
|
259
|
+
async def remove(self, path: str, *, recursive: bool = False) -> None:
|
|
260
|
+
"""Remove a file, or with ``recursive`` a directory. A path that is not there is fine."""
|
|
261
|
+
await self._client._run(f.remove(self.id, path, recursive))
|
|
262
|
+
|
|
263
|
+
async def preview_url(self, port: int, *, public: bool = False) -> str:
|
|
264
|
+
"""A URL for a port of the sandbox. Private: only whoever holds the URL can open it."""
|
|
265
|
+
return await self._client._run(f.preview_url(self.id, port, public))
|
|
266
|
+
|
|
267
|
+
async def stop(self) -> SandboxInfo:
|
|
268
|
+
"""Stop it; the disk is kept and nothing is billed until ``start``."""
|
|
269
|
+
self.info = await self._client._run(f.stop_sandbox(self.id))
|
|
270
|
+
return self.info
|
|
271
|
+
|
|
272
|
+
async def start(self, *, wait_timeout: float = 120.0) -> SandboxInfo:
|
|
273
|
+
"""Start a stopped or sleeping sandbox and wait until it runs. Any call wakes it too."""
|
|
274
|
+
self.info = await self._client._run(f.start_sandbox(self.id, wait_timeout))
|
|
275
|
+
return self.info
|
|
276
|
+
|
|
277
|
+
async def set_sleep(self, after: str) -> SandboxInfo:
|
|
278
|
+
"""How long without use before it sleeps: ``"30m"``, ``"3h"``, ``"7d"`` or ``"never"``."""
|
|
279
|
+
self.info = await self._client._run(f.set_sleep(self.id, after))
|
|
280
|
+
return self.info
|
|
281
|
+
|
|
282
|
+
async def destroy(self) -> None:
|
|
283
|
+
"""Destroy it and wait until it is gone. Destroying one that is gone is fine."""
|
|
284
|
+
await self._client._run(f.destroy(self.id))
|
|
285
|
+
|
|
286
|
+
async def __aenter__(self) -> AsyncSandbox:
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
async def __aexit__(
|
|
290
|
+
self,
|
|
291
|
+
exc_type: type[BaseException] | None,
|
|
292
|
+
exc: BaseException | None,
|
|
293
|
+
tb: TracebackType | None,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Destroy it when the block ends."""
|
|
296
|
+
try:
|
|
297
|
+
await self.destroy()
|
|
298
|
+
except BriqError:
|
|
299
|
+
if exc is None:
|
|
300
|
+
raise
|
briq_cloud/_config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The key and the API origin: arguments first, then the environment, then the public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._errors import BriqError
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
|
|
13
|
+
DEFAULT_URL = "https://api.briq.cloud"
|
|
14
|
+
USER_AGENT = f"briq-cloud-python/{__version__}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve(api_key: str | None, base_url: str | None) -> tuple[str, str]:
|
|
18
|
+
"""The key and the origin to use. A bad one fails here, before anything is sent."""
|
|
19
|
+
key = api_key or os.environ.get("BRIQ_API_KEY") or ""
|
|
20
|
+
if not key:
|
|
21
|
+
raise BriqError("auth", "A briq API key is required: pass api_key or set BRIQ_API_KEY")
|
|
22
|
+
if any(c.isspace() or ord(c) < 32 or ord(c) > 126 for c in key):
|
|
23
|
+
raise BriqError("auth", "The API key holds a character a key never has")
|
|
24
|
+
return key, origin_of(base_url or os.environ.get("BRIQ_API_URL") or DEFAULT_URL)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def origin_of(url: str) -> str:
|
|
28
|
+
"""``scheme://host[:port]`` of an API URL. It is nothing more: the client builds the paths."""
|
|
29
|
+
try:
|
|
30
|
+
parts = urlsplit(url)
|
|
31
|
+
valid = (
|
|
32
|
+
parts.scheme in ("http", "https")
|
|
33
|
+
and bool(parts.hostname)
|
|
34
|
+
and not parts.username
|
|
35
|
+
and not parts.password
|
|
36
|
+
and not parts.query
|
|
37
|
+
and not parts.fragment
|
|
38
|
+
and parts.path in ("", "/")
|
|
39
|
+
and parts.port != 0
|
|
40
|
+
)
|
|
41
|
+
except ValueError:
|
|
42
|
+
valid = False
|
|
43
|
+
if not valid:
|
|
44
|
+
raise BriqError("invalid_argument", "The API URL must be an HTTP(S) origin")
|
|
45
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def headers(key: str, extra: dict[str, str]) -> dict[str, str]:
|
|
49
|
+
return {
|
|
50
|
+
"authorization": f"Bearer {key}",
|
|
51
|
+
"user-agent": USER_AGENT,
|
|
52
|
+
"accept": "application/json, */*",
|
|
53
|
+
**extra,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def timeout_of(seconds: float) -> httpx.Timeout:
|
|
58
|
+
"""A deadline for each step of a request; connecting is never given more than ten seconds."""
|
|
59
|
+
return httpx.Timeout(seconds, connect=min(seconds, 10.0))
|
briq_cloud/_errors.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The one exception the client raises, and how an HTTP answer becomes one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
#: What went wrong, as a stable word. The same words as the TypeScript client's.
|
|
11
|
+
#:
|
|
12
|
+
#: - ``auth``: no key, a wrong key, or a key that may not do this.
|
|
13
|
+
#: - ``not_found``: the sandbox does not exist (or is not yours).
|
|
14
|
+
#: - ``file_not_found``: the file or directory does not exist.
|
|
15
|
+
#: - ``quota``: a limit stopped it: credits, plan, spend cap, rate limit, size.
|
|
16
|
+
#: - ``unavailable``: briq could not take the call; it was not applied. Safe to try again.
|
|
17
|
+
#: - ``invalid_argument``: the call is wrong; trying again changes nothing.
|
|
18
|
+
#: - ``provider``: briq answered with a refusal that fits none of the others.
|
|
19
|
+
#: - ``outcome_unknown``: the call was sent and its result is not known (a command may have run,
|
|
20
|
+
#: a sandbox may exist). The client never repeats such a call itself.
|
|
21
|
+
#: - ``timeout``: a deadline of the client ended the call.
|
|
22
|
+
#: - ``unsupported``: this API or image cannot do what was asked.
|
|
23
|
+
#: - ``aborted``: kept for parity with the TypeScript client, which cancels with an abort signal.
|
|
24
|
+
Code = Literal[
|
|
25
|
+
"auth",
|
|
26
|
+
"not_found",
|
|
27
|
+
"file_not_found",
|
|
28
|
+
"quota",
|
|
29
|
+
"unavailable",
|
|
30
|
+
"invalid_argument",
|
|
31
|
+
"provider",
|
|
32
|
+
"outcome_unknown",
|
|
33
|
+
"timeout",
|
|
34
|
+
"unsupported",
|
|
35
|
+
"aborted",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BriqError(Exception):
|
|
40
|
+
"""A call to briq failed. ``code`` says how; the other fields say what briq answered."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
code: Code,
|
|
45
|
+
message: str,
|
|
46
|
+
*,
|
|
47
|
+
status: int | None = None,
|
|
48
|
+
api_code: str | None = None,
|
|
49
|
+
request_id: str | None = None,
|
|
50
|
+
retry_after: int | None = None,
|
|
51
|
+
details: Any = None,
|
|
52
|
+
idempotency_key: str | None = None,
|
|
53
|
+
sandbox_id: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.code: Code = code
|
|
57
|
+
self.message = message
|
|
58
|
+
#: The HTTP status, when briq answered.
|
|
59
|
+
self.status = status
|
|
60
|
+
#: The API's own error code (``payment_required``, ``plan_limit``, ``invalid_state``...).
|
|
61
|
+
self.api_code = api_code
|
|
62
|
+
#: The API's id for the failed request: quote it when asking for help.
|
|
63
|
+
self.request_id = request_id
|
|
64
|
+
#: On a rate limit (429): the seconds until the window ends.
|
|
65
|
+
self.retry_after = retry_after
|
|
66
|
+
#: What the API said about the limit or the operation, as codes, ids and numbers only
|
|
67
|
+
#: (``{"limit": "size", "plan": "hobby"}``, ``{"op": "exec"}``). The text a provider
|
|
68
|
+
#: gave is never kept: it is not for a caller's logs.
|
|
69
|
+
self.details = details
|
|
70
|
+
#: On a create whose result is not known: the key it was sent with. Pass it as
|
|
71
|
+
#: ``idempotency_key`` to repeat the call; the API then returns the sandbox it made.
|
|
72
|
+
self.idempotency_key = idempotency_key
|
|
73
|
+
#: The sandbox the error is about, when it is known (a create that could not clean up).
|
|
74
|
+
self.sandbox_id = sandbox_id
|
|
75
|
+
|
|
76
|
+
def __str__(self) -> str:
|
|
77
|
+
return f"{self.message} ({self.code})"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def status_code(status: int, api_code: str | None = None) -> Code:
|
|
81
|
+
"""The code for an HTTP status, as the TypeScript client maps it."""
|
|
82
|
+
if status in (402, 429, 507, 413) or api_code == "plan_limit":
|
|
83
|
+
return "quota"
|
|
84
|
+
if status in (401, 403):
|
|
85
|
+
return "auth"
|
|
86
|
+
if status == 404:
|
|
87
|
+
return "not_found"
|
|
88
|
+
if status >= 500:
|
|
89
|
+
return "unavailable"
|
|
90
|
+
if status in (400, 422):
|
|
91
|
+
return "invalid_argument"
|
|
92
|
+
return "provider"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_ID = re.compile(r"^[\w.-]{1,64}$")
|
|
96
|
+
#: A value of ``details`` worth keeping: a code, an id, a plan or an operation name.
|
|
97
|
+
_WORD = re.compile(r"^[\w.:-]{1,64}$")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def request_id_of(value: object) -> str | None:
|
|
101
|
+
"""A request id worth repeating in an error: the API's own, shaped as ours."""
|
|
102
|
+
return value if isinstance(value, str) and _ID.match(value) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def safe_details(details: Any) -> Any:
|
|
106
|
+
"""The codes, ids and numbers of an error's ``details``; the text of a provider is dropped."""
|
|
107
|
+
if not isinstance(details, dict):
|
|
108
|
+
return None
|
|
109
|
+
kept = {
|
|
110
|
+
key: value
|
|
111
|
+
for key, value in details.items()
|
|
112
|
+
if isinstance(key, str)
|
|
113
|
+
and _WORD.match(key)
|
|
114
|
+
and key != "providerSaid"
|
|
115
|
+
and (
|
|
116
|
+
isinstance(value, (bool, int, float))
|
|
117
|
+
or (isinstance(value, str) and _WORD.match(value) is not None)
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
return kept or None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def envelope(content: bytes) -> dict[str, Any]:
|
|
124
|
+
"""The ``error`` object of an error answer; empty when the body is not ours (a proxy's page)."""
|
|
125
|
+
try:
|
|
126
|
+
error = json.loads(content).get("error")
|
|
127
|
+
except (ValueError, AttributeError):
|
|
128
|
+
return {}
|
|
129
|
+
return error if isinstance(error, dict) else {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def refusal(
|
|
133
|
+
status: int,
|
|
134
|
+
headers: Mapping[str, str],
|
|
135
|
+
content: bytes,
|
|
136
|
+
code: Code | None = None,
|
|
137
|
+
) -> BriqError:
|
|
138
|
+
"""An error answer as a ``BriqError``: ``code`` by status unless the caller knows better."""
|
|
139
|
+
error = envelope(content)
|
|
140
|
+
said_code, said = error.get("code"), error.get("message")
|
|
141
|
+
api_code = said_code if isinstance(said_code, str) else None
|
|
142
|
+
message = said if isinstance(said, str) else f"HTTP {status}"
|
|
143
|
+
retry = headers.get("retry-after", "")
|
|
144
|
+
return BriqError(
|
|
145
|
+
code or status_code(status, api_code),
|
|
146
|
+
message,
|
|
147
|
+
status=status,
|
|
148
|
+
api_code=api_code,
|
|
149
|
+
request_id=request_id_of(headers.get("x-request-id"))
|
|
150
|
+
or request_id_of(error.get("requestId")),
|
|
151
|
+
retry_after=int(retry) if status == 429 and re.fullmatch(r"\d{1,4}", retry) else None,
|
|
152
|
+
details=safe_details(error.get("details")),
|
|
153
|
+
)
|