b3os 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.
b3os/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """b3os: the Python SDK for B3OS beta.
2
+
3
+ from b3os import B3OS
4
+ b3 = B3OS() # B3OS_API_KEY, or the node socket / sandbox URL
5
+ page = b3.vision.scrape({"url": "https://example.com"})
6
+ sbx = b3.sandboxes.create_sandbox({"template": "base"})
7
+ out = b3.sandboxes.exec(sbx["id"], {"cmd": "python3 -c 'print(2 + 2)'"})
8
+
9
+ Async: `from b3os import AsyncB3OS`, same namespaces with `await`.
10
+ """
11
+
12
+ from ._async import AsyncEventStream, AsyncJobEventStream, AsyncPage
13
+ from ._base import DEFAULT_BASE_URL, DEFAULT_SOCKET_PATH
14
+ from ._errors import CLIENT_ERROR_CODES, B3OSConnectionError, B3OSError, B3OSJobError, B3OSTimeoutError
15
+ from ._generated import types
16
+ from ._generated.clients import AsyncB3OS, B3OS
17
+ from ._generated.errors import ERROR_CATALOG
18
+ from ._options import CdpEndpoint, RequestOptions, TicketedUrl
19
+ from ._pty import AsyncPtySession, PtyExit, PtySession
20
+ from ._response import ResponseInfo, response_of
21
+ from ._sse import SSEDecoder, SSEMessage
22
+ from ._sync import EventStream, JobEventStream, Page
23
+ from ._timing import ServerTiming, ServerTimingEntry, parse_server_timing
24
+ from ._util import SDK_VERSION
25
+ from ._webhooks import WebhookVerificationError, sign_webhook, verify_webhook
26
+
27
+ __version__ = SDK_VERSION
28
+
29
+ __all__ = [
30
+ "B3OS",
31
+ "AsyncB3OS",
32
+ "B3OSError",
33
+ "B3OSConnectionError",
34
+ "B3OSTimeoutError",
35
+ "B3OSJobError",
36
+ "CLIENT_ERROR_CODES",
37
+ "ERROR_CATALOG",
38
+ "EventStream",
39
+ "JobEventStream",
40
+ "AsyncEventStream",
41
+ "AsyncJobEventStream",
42
+ "Page",
43
+ "AsyncPage",
44
+ "RequestOptions",
45
+ "ResponseInfo",
46
+ "response_of",
47
+ "ServerTiming",
48
+ "ServerTimingEntry",
49
+ "parse_server_timing",
50
+ "SSEDecoder",
51
+ "SSEMessage",
52
+ "TicketedUrl",
53
+ "CdpEndpoint",
54
+ "PtySession",
55
+ "AsyncPtySession",
56
+ "PtyExit",
57
+ "verify_webhook",
58
+ "sign_webhook",
59
+ "WebhookVerificationError",
60
+ "DEFAULT_BASE_URL",
61
+ "DEFAULT_SOCKET_PATH",
62
+ "SDK_VERSION",
63
+ "types",
64
+ ]
b3os/_async.py ADDED
@@ -0,0 +1,563 @@
1
+ """The asynchronous client core (httpx.AsyncClient; asyncio or trio through anyio). Same behavior as _sync.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import json
7
+ import time
8
+ from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Generic, Iterable, Mapping, Optional, Sequence, Tuple, TypeVar, Union
9
+
10
+ import anyio
11
+ import httpx
12
+
13
+ from ._base import (
14
+ TERMINAL_JOB_EVENTS,
15
+ Config,
16
+ Route,
17
+ build_path,
18
+ build_query,
19
+ encode_body,
20
+ error_from_response,
21
+ idempotency_key,
22
+ is_json,
23
+ is_terminal_job,
24
+ job_failure,
25
+ job_id_of,
26
+ job_ops_for,
27
+ per_attempt_timeout,
28
+ request_headers,
29
+ retry_safe,
30
+ should_retry,
31
+ )
32
+ from ._errors import B3OSConnectionError, B3OSError, B3OSJobError, B3OSTimeoutError
33
+ from ._ops import JobOps, Op
34
+ from ._response import ResponseInfo, ResultDict, attach
35
+ from ._sse import SSEDecoder, SSEMessage
36
+ from ._sync import _CONNECT_ERRORS, _connect_refused
37
+ from ._util import backoff, new_idempotency_key, parse_retry_after
38
+
39
+ T = TypeVar("T")
40
+ E = TypeVar("E")
41
+ J = TypeVar("J")
42
+
43
+
44
+ class AsyncPage(ResultDict, Generic[T]):
45
+ """One page of a list (a dict). `async for x in page.auto_paging_iter()` walks every item."""
46
+
47
+ __slots__ = ("_next",)
48
+ _next: Callable[[str], Any]
49
+
50
+ async def auto_paging_iter(self) -> AsyncIterator[T]:
51
+ page: AsyncPage[T] = self
52
+ while True:
53
+ for item in page.get("data", []):
54
+ yield item
55
+ cursor = page.get("next_cursor")
56
+ if not cursor:
57
+ return
58
+ page = await page._next(cursor)
59
+
60
+
61
+ class AsyncEventStream(Generic[E]):
62
+ """`async for ev in stream: ...`, once. Resumes after drops with Last-Event-ID where the stream allows it."""
63
+
64
+ def __init__(
65
+ self,
66
+ open_fn: Callable[[int, Optional[str], Any], Awaitable[httpx.Response]],
67
+ *,
68
+ first: Optional[httpx.Response] = None,
69
+ is_terminal: Optional[Callable[[Any], bool]] = None,
70
+ resumable: bool,
71
+ reconnect_on_end: bool = False,
72
+ max_reconnects: int = 6,
73
+ done_sentinel: bool = False,
74
+ operation_id: str = "",
75
+ ) -> None:
76
+ self._open = open_fn
77
+ self._first = first
78
+ self._is_terminal = is_terminal
79
+ self._resumable = resumable
80
+ self._reconnect_on_end = reconnect_on_end
81
+ self._max_reconnects = max_reconnects
82
+ self._done_sentinel = done_sentinel
83
+ self._op = operation_id
84
+ self._resp: Optional[httpx.Response] = None
85
+ self._closed = False
86
+ self._iterating = False
87
+ self.last_event_id: Optional[str] = None
88
+ self.last_event: Optional[E] = None
89
+ self.completed = False
90
+
91
+ async def aclose(self) -> None:
92
+ self._closed = True
93
+ r = self._resp or self._first
94
+ if r is not None:
95
+ await r.aclose()
96
+
97
+ async def __aenter__(self) -> "AsyncEventStream[E]":
98
+ return self
99
+
100
+ async def __aexit__(self, *exc: Any) -> None:
101
+ await self.aclose()
102
+
103
+ def _parse(self, msg: SSEMessage) -> Any:
104
+ try:
105
+ return json.loads(msg.data)
106
+ except ValueError as e:
107
+ raise B3OSError("invalid_response", detail=f"Could not parse the {msg.event} event: {e}", operation_id=self._op)
108
+
109
+ async def __aiter__(self) -> AsyncIterator[E]:
110
+ if self._iterating:
111
+ raise RuntimeError("b3os: an AsyncEventStream can be iterated only once")
112
+ self._iterating = True
113
+ attempt = 0
114
+ failures = 0
115
+ retry_ms: Optional[int] = None
116
+ while not self._closed:
117
+ if attempt == 0 and self._first is not None:
118
+ resp = self._first
119
+ else:
120
+ try:
121
+ resp = await self._open(attempt, self.last_event_id, self.last_event)
122
+ except B3OSError as e:
123
+ if attempt == 0 or not self._resumable or not (e.retryable or e.code == "connection_error"):
124
+ raise
125
+ failures += 1
126
+ if failures > self._max_reconnects:
127
+ raise B3OSError("stream_interrupted", detail=f"Reconnecting failed {failures} times", operation_id=self._op)
128
+ attempt += 1
129
+ await anyio.sleep((retry_ms / 1000) if retry_ms is not None else backoff(failures - 1, 0.5, 10))
130
+ continue
131
+ self._resp = resp
132
+ decoder = SSEDecoder()
133
+ utf8 = codecs.getincrementaldecoder("utf-8")(errors="replace")
134
+ got = False
135
+ broken: Optional[BaseException] = None
136
+ idle = False
137
+ try:
138
+ async for chunk in resp.aiter_bytes():
139
+ for msg in decoder.feed(utf8.decode(chunk)):
140
+ if msg.retry is not None:
141
+ retry_ms = msg.retry
142
+ if msg.id is not None:
143
+ self.last_event_id = msg.id
144
+ if self._done_sentinel and msg.data.strip() == "[DONE]":
145
+ self.completed = True
146
+ return
147
+ ev = self._parse(msg)
148
+ got = True
149
+ failures = 0
150
+ self.last_event = ev
151
+ terminal = bool(self._is_terminal and self._is_terminal(ev))
152
+ if terminal:
153
+ self.completed = True
154
+ yield ev
155
+ if terminal or self._closed:
156
+ return
157
+ except httpx.ReadTimeout as e:
158
+ idle, broken = True, e
159
+ except (httpx.TransportError, httpx.StreamError) as e:
160
+ broken = e
161
+ finally:
162
+ await resp.aclose()
163
+ if self._closed:
164
+ return
165
+ if broken is not None:
166
+ if not self._resumable:
167
+ raise B3OSError(
168
+ "timeout" if idle else "stream_interrupted",
169
+ detail=("No data within the idle timeout" if idle else f"The event stream broke: {broken}"),
170
+ operation_id=self._op,
171
+ )
172
+ failures += 1
173
+ if failures > self._max_reconnects:
174
+ raise B3OSError("stream_interrupted", detail=f"The event stream broke {failures} times: {broken}", operation_id=self._op)
175
+ else:
176
+ if not self._resumable or not self._reconnect_on_end:
177
+ return
178
+ if not got:
179
+ failures += 1
180
+ if failures > self._max_reconnects:
181
+ raise B3OSError("stream_interrupted", detail="The event stream kept closing without events", operation_id=self._op)
182
+ attempt += 1
183
+ delay = (retry_ms / 1000) if retry_ms is not None else (0.0 if got else backoff(max(0, failures - 1), 0.5, 10))
184
+ if delay:
185
+ await anyio.sleep(delay)
186
+
187
+
188
+ class AsyncJobEventStream(AsyncEventStream[E], Generic[E, J]):
189
+ def __init__(self, *args: Any, final_job: Callable[[Any], Awaitable[J]], **kw: Any) -> None:
190
+ super().__init__(*args, **kw)
191
+ self._final_job = final_job
192
+ self._result: Optional[J] = None
193
+
194
+ async def __aiter__(self) -> AsyncIterator[E]:
195
+ async for ev in super().__aiter__():
196
+ if isinstance(ev, dict) and ev.get("type") in TERMINAL_JOB_EVENTS:
197
+ self._result = await self._final_job(ev)
198
+ yield ev
199
+
200
+ @property
201
+ def result(self) -> Optional[J]:
202
+ return self._result
203
+
204
+ async def final(self) -> J:
205
+ if not self._iterating:
206
+ async for _ in self:
207
+ pass
208
+ if self._result is None:
209
+ raise B3OSError("stream_interrupted", detail="The event stream ended before the job finished", operation_id=self._op)
210
+ return self._result
211
+
212
+
213
+ class AsyncCore:
214
+ def __init__(self, cfg: Config, on_response: Optional[Callable[[ResponseInfo], None]] = None, http_client: Optional[httpx.AsyncClient] = None) -> None:
215
+ self._cfg = cfg
216
+ self._on_response = on_response
217
+ self._clients: Dict[str, httpx.AsyncClient] = {}
218
+ self._shared = http_client
219
+
220
+ @property
221
+ def api_key(self) -> Optional[str]:
222
+ return self._cfg.api_key
223
+
224
+ @property
225
+ def routes(self) -> Dict[str, str]:
226
+ return {"node": self._cfg.node.label, "edge": self._cfg.edge.label, "node_kind": self._cfg.node.kind}
227
+
228
+ def _http(self, route: Route) -> httpx.AsyncClient:
229
+ if self._shared is not None and not route.uds:
230
+ return self._shared
231
+ c = self._clients.get(route.label)
232
+ if c is None:
233
+ transport = httpx.AsyncHTTPTransport(uds=route.uds) if route.uds else None
234
+ c = httpx.AsyncClient(transport=transport, follow_redirects=True, timeout=None)
235
+ self._clients[route.label] = c
236
+ return c
237
+
238
+ async def aclose(self) -> None:
239
+ for c in self._clients.values():
240
+ await c.aclose()
241
+ self._clients.clear()
242
+
243
+ async def _send(
244
+ self,
245
+ op: Op,
246
+ *,
247
+ path: Sequence[Union[str, int]] = (),
248
+ query: Optional[Mapping[str, Any]] = None,
249
+ header_params: Optional[Mapping[str, Any]] = None,
250
+ body: Any = None,
251
+ body_type: Optional[str] = None,
252
+ accept: str = "application/json",
253
+ sse: bool = False,
254
+ extra_query: Optional[Mapping[str, str]] = None,
255
+ options: Optional[Mapping[str, Any]] = None,
256
+ ) -> Tuple[httpx.Response, ResponseInfo]:
257
+ opts = dict(options or {})
258
+ path_str = build_path(op, path)
259
+ params = build_query(query, extra_query)
260
+ key = idempotency_key(op, opts)
261
+ streaming_body = hasattr(body, "__aiter__") or hasattr(body, "read")
262
+ safe = retry_safe(op, key, streaming_body)
263
+ max_retries = 0 if streaming_body else int(opts.get("max_retries", self._cfg.max_retries))
264
+ tmo = per_attempt_timeout(self._cfg, op, opts, sse)
265
+ route = self._cfg.route(op)
266
+ attempt = 0
267
+ while True:
268
+ headers = request_headers(self._cfg, route, op, opts, accept, header_params, key)
269
+ content = encode_body(op, body, body_type, headers)
270
+ client = self._http(route)
271
+ req = client.build_request(
272
+ op.method,
273
+ route.base + path_str,
274
+ params=httpx.QueryParams(params) if params else None,
275
+ headers=headers,
276
+ content=content,
277
+ timeout=httpx.Timeout(tmo, connect=min(tmo, 10.0)),
278
+ )
279
+ t0 = time.perf_counter()
280
+ try:
281
+ resp = await client.send(req, stream=True)
282
+ except httpx.TimeoutException as e:
283
+ if isinstance(e, httpx.ConnectTimeout) and attempt < max_retries:
284
+ await anyio.sleep(backoff(attempt))
285
+ attempt += 1
286
+ continue
287
+ raise B3OSTimeoutError(f"{op.id} did not answer within {tmo} s ({route.label})", operation_id=op.id) from e
288
+ except httpx.TransportError as e:
289
+ if route.auto and _connect_refused(e):
290
+ self._cfg.fall_back_to_edge()
291
+ route = self._cfg.edge
292
+ continue
293
+ if attempt < max_retries and (safe or isinstance(e, _CONNECT_ERRORS)):
294
+ await anyio.sleep(backoff(attempt))
295
+ attempt += 1
296
+ continue
297
+ raise B3OSConnectionError(f"Could not reach {route.label}: {e}", operation_id=op.id) from e
298
+ info = ResponseInfo(
299
+ operation_id=op.id,
300
+ method=op.method,
301
+ url=(f"{route.label}{path_str}" if route.uds else str(resp.request.url)),
302
+ status=resp.status_code,
303
+ headers=resp.headers,
304
+ transport="uds" if route.uds else "http",
305
+ attempts=attempt + 1,
306
+ elapsed_ms=(time.perf_counter() - t0) * 1000,
307
+ idempotency_key=key,
308
+ )
309
+ if self._on_response:
310
+ try:
311
+ self._on_response(info)
312
+ except Exception:
313
+ pass
314
+ if 200 <= resp.status_code < 300 or resp.status_code == 101:
315
+ return resp, info
316
+ try:
317
+ text = (await resp.aread()).decode("utf-8", errors="replace")
318
+ except httpx.HTTPError:
319
+ text = ""
320
+ finally:
321
+ await resp.aclose()
322
+ err = error_from_response(self._cfg, route, op, resp.status_code, resp.headers, text)
323
+ if attempt < max_retries and should_retry(err, safe):
324
+ delay = err.retry_after if err.retry_after is not None else backoff(attempt)
325
+ if delay <= self._cfg.max_retry_delay:
326
+ await anyio.sleep(delay)
327
+ attempt += 1
328
+ continue
329
+ raise err
330
+
331
+ async def _read(self, resp: httpx.Response, info: ResponseInfo, op: Op, expect: str) -> Any:
332
+ try:
333
+ if resp.status_code in (204, 101) or op.method == "HEAD":
334
+ return None
335
+ ct = resp.headers.get("content-type", "")
336
+ data = await resp.aread()
337
+ except httpx.TimeoutException as e:
338
+ raise B3OSTimeoutError(f"{op.id} body did not arrive in time", operation_id=op.id) from e
339
+ except httpx.TransportError as e:
340
+ raise B3OSConnectionError(f"The response body of {op.id} broke: {e}", operation_id=op.id) from e
341
+ finally:
342
+ await resp.aclose()
343
+ if expect == "bytes" or (expect == "auto" and not is_json(ct) and not ct.startswith("text/")):
344
+ return attach(bytes(data), info)
345
+ text = data.decode("utf-8", errors="replace")
346
+ if expect == "text" or (expect == "auto" and not is_json(ct)):
347
+ return attach(text, info)
348
+ if not text:
349
+ return None
350
+ try:
351
+ return attach(json.loads(text), info)
352
+ except ValueError as e:
353
+ raise B3OSError(
354
+ "invalid_response",
355
+ status=resp.status_code,
356
+ detail=f"{op.id} answered {resp.status_code} with a body that is not JSON ({ct or 'no content type'})",
357
+ request_id=resp.headers.get("x-request-id"),
358
+ operation_id=op.id,
359
+ ) from e
360
+
361
+ async def _call(self, op: Op, *, accept: str = "application/json", expect: str = "auto", **kw: Any) -> Any:
362
+ resp, info = await self._send(op, accept=accept, **kw)
363
+ return await self._read(resp, info, op, expect)
364
+
365
+ async def _list(self, op: Op, **kw: Any) -> AsyncPage[Any]:
366
+ data = await self._call(op, **kw)
367
+ page: AsyncPage[Any] = AsyncPage(data or {})
368
+ page._b3os_response = getattr(data, "_b3os_response", None)
369
+ query = dict(kw.get("query") or {})
370
+
371
+ async def nxt(cursor: str) -> AsyncPage[Any]:
372
+ kw2 = dict(kw)
373
+ kw2["query"] = {**query, "cursor": cursor}
374
+ return await self._list(op, **kw2)
375
+
376
+ page._next = nxt
377
+ return page
378
+
379
+ def _expect_sse(self, resp: httpx.Response, info: ResponseInfo, op: Op) -> httpx.Response:
380
+ ct = resp.headers.get("content-type", "")
381
+ if not ct.startswith("text/event-stream"):
382
+ raise B3OSError(
383
+ "invalid_response",
384
+ status=resp.status_code,
385
+ detail=f"{op.id} answered {resp.status_code} with {ct or 'no content type'} instead of an event stream",
386
+ request_id=info.request_id,
387
+ operation_id=op.id,
388
+ )
389
+ return resp
390
+
391
+ async def _open_get_stream(self, op: Op, kw: Mapping[str, Any], last_event_id: Optional[str], first_attempt: bool) -> httpx.Response:
392
+ opts = dict(kw.get("options") or {})
393
+ headers = dict(opts.get("extra_headers") or {})
394
+ if last_event_id is not None:
395
+ headers["last-event-id"] = last_event_id
396
+ opts["extra_headers"] = headers
397
+ if not first_attempt:
398
+ opts["max_retries"] = 0
399
+ resp, info = await self._send(op, accept="text/event-stream", sse=True, **{**kw, "options": opts})
400
+ try:
401
+ return self._expect_sse(resp, info, op)
402
+ except B3OSError:
403
+ await resp.aclose()
404
+ raise
405
+
406
+ async def _stream(self, op: Op, *, terminal: Optional[Callable[[Any], bool]] = None, reconnect_on_end: bool = False, last_event_id: Optional[str] = None, **kw: Any) -> AsyncEventStream[Any]:
407
+ first = await self._open_get_stream(op, kw, last_event_id, True)
408
+
409
+ async def reopen(attempt: int, lid: Optional[str], _ev: Any) -> httpx.Response:
410
+ return await self._open_get_stream(op, kw, lid, False)
411
+
412
+ return AsyncEventStream(reopen, first=first, is_terminal=terminal, resumable=True, reconnect_on_end=reconnect_on_end, operation_id=op.id)
413
+
414
+ async def _post_stream(self, op: Op, *, terminal: Optional[Callable[[Any], bool]] = None, done_sentinel: bool = False, **kw: Any) -> AsyncEventStream[Any]:
415
+ resp, info = await self._send(op, accept="text/event-stream", sse=True, **kw)
416
+ try:
417
+ first = self._expect_sse(resp, info, op)
418
+ except B3OSError:
419
+ await resp.aclose()
420
+ raise
421
+
422
+ async def reopen(attempt: int, lid: Optional[str], ev: Any) -> httpx.Response:
423
+ raise B3OSError("stream_interrupted", detail=f"{op.id} streams cannot resume", operation_id=op.id)
424
+
425
+ return AsyncEventStream(reopen, first=first, is_terminal=terminal, resumable=False, done_sentinel=done_sentinel, operation_id=op.id)
426
+
427
+ async def _final_job(self, ops: JobOps, ev: Mapping[str, Any]) -> Any:
428
+ job = ev.get("job")
429
+ if is_terminal_job(job):
430
+ return job
431
+ jid = ev.get("job_id")
432
+ if not isinstance(jid, str):
433
+ raise B3OSError("invalid_response", detail="terminal event without a job", operation_id=ops.events.id)
434
+ return await self._call(ops.get, path=[jid])
435
+
436
+ async def _job_events(self, ops: JobOps, job: Any, *, last_event_id: Optional[str] = None, options: Optional[Mapping[str, Any]] = None) -> AsyncJobEventStream[Any, Any]:
437
+ jid = job_id_of(job)
438
+ kw: Dict[str, Any] = {"path": [jid], "options": options or {}}
439
+ first = await self._open_get_stream(ops.events, kw, last_event_id, True)
440
+
441
+ async def reopen(attempt: int, lid: Optional[str], _ev: Any) -> httpx.Response:
442
+ return await self._open_get_stream(ops.events, kw, lid, False)
443
+
444
+ async def final(e: Any) -> Any:
445
+ return await self._final_job(ops, e)
446
+
447
+ return AsyncJobEventStream(
448
+ reopen,
449
+ first=first,
450
+ is_terminal=lambda e: isinstance(e, dict) and e.get("type") in TERMINAL_JOB_EVENTS,
451
+ resumable=True,
452
+ reconnect_on_end=True,
453
+ operation_id=ops.events.id,
454
+ final_job=final,
455
+ )
456
+
457
+ def _finish(self, job: Any, raise_on_failure: bool) -> Any:
458
+ if job.get("status") == "succeeded" or not raise_on_failure:
459
+ return job
460
+ raise B3OSJobError(job, **job_failure(job))
461
+
462
+ async def _wait_job(self, ops: JobOps, job: Any, *, timeout: Optional[float] = None, raise_on_failure: bool = True, on_event: Optional[Callable[[Any], None]] = None) -> Any:
463
+ if not isinstance(job, str) and is_terminal_job(job):
464
+ return self._finish(job, raise_on_failure)
465
+ jid = job_id_of(job)
466
+ budget = timeout if timeout is not None else ops.events.timeout_ms / 1000
467
+ deadline = time.monotonic() + budget
468
+ polling = False
469
+ poll_attempt = 0
470
+ while time.monotonic() < deadline:
471
+ if not polling:
472
+ try:
473
+ remaining = max(1.0, deadline - time.monotonic())
474
+ stream = await self._job_events(ops, jid, options={"timeout": min(remaining, 45.0)})
475
+ async for ev in stream:
476
+ if on_event:
477
+ on_event(ev)
478
+ if isinstance(ev, dict) and ev.get("type") in TERMINAL_JOB_EVENTS:
479
+ return self._finish(await self._final_job(ops, ev), raise_on_failure)
480
+ if time.monotonic() >= deadline:
481
+ await stream.aclose()
482
+ break
483
+ except B3OSError as e:
484
+ if e.code in ("route_not_found", "not_implemented", "invalid_response") or e.status in (405, 406):
485
+ polling = True
486
+ elif e.status in (401, 403, 404, 410):
487
+ raise
488
+ elif not e.retryable and e.code not in ("stream_interrupted", "timeout"):
489
+ raise
490
+ resp, info = await self._send(ops.get, path=[jid])
491
+ cur = await self._read(resp, info, ops.get, "json")
492
+ if is_terminal_job(cur):
493
+ return self._finish(cur, raise_on_failure)
494
+ ra = parse_retry_after(info.headers.get("retry-after"))
495
+ await anyio.sleep(max(0.0, min(deadline - time.monotonic(), ra if ra is not None else backoff(poll_attempt, 1.0, 5.0))))
496
+ poll_attempt += 1
497
+ raise B3OSTimeoutError(f"{jid} was not finished after {budget} s; it keeps running on the server", operation_id=ops.get.id)
498
+
499
+ async def _job(self, op: Op, *, wait: Union[bool, float] = False, stream: bool = False, raise_on_failure: bool = True, on_event: Optional[Callable[[Any], None]] = None, **kw: Any) -> Any:
500
+ assert op.job is not None
501
+ ops = op.job
502
+ opts = dict(kw.pop("options", None) or {})
503
+ if op.idem and not opts.get("idempotency_key"):
504
+ opts["idempotency_key"] = new_idempotency_key()
505
+ if stream:
506
+ resp, info = await self._send(op, accept="text/event-stream", sse=True, options=opts, **kw)
507
+ first = self._expect_sse(resp, info, op)
508
+
509
+ async def reopen(attempt: int, lid: Optional[str], ev: Any) -> httpx.Response:
510
+ jid = ev.get("job_id") if isinstance(ev, dict) else None
511
+ if not isinstance(jid, str):
512
+ raise B3OSError("stream_interrupted", detail=f"{op.id}: the stream broke before the first event", operation_id=op.id)
513
+ return await self._open_get_stream(ops.events, {"path": [jid], "options": {}}, lid, False)
514
+
515
+ async def final(e: Any) -> Any:
516
+ return await self._final_job(ops, e)
517
+
518
+ return AsyncJobEventStream(
519
+ reopen,
520
+ first=first,
521
+ is_terminal=lambda e: isinstance(e, dict) and e.get("type") in TERMINAL_JOB_EVENTS,
522
+ resumable=True,
523
+ reconnect_on_end=True,
524
+ operation_id=op.id,
525
+ final_job=final,
526
+ )
527
+ if not wait:
528
+ return await self._call(op, options=opts, **kw)
529
+ budget = float(ops.events.timeout_ms / 1000 if wait is True else wait)
530
+ start = time.monotonic()
531
+ wait_s = int(max(0, min(60, budget)))
532
+ job = await self._call(op, extra_query={"wait": str(wait_s)}, options=opts, **kw)
533
+ remaining = budget - (time.monotonic() - start)
534
+ return await self._wait_job(ops, job, timeout=max(0.0, remaining), raise_on_failure=raise_on_failure, on_event=on_event)
535
+
536
+
537
+ class AsyncJobs:
538
+ def __init__(
539
+ self,
540
+ core: AsyncCore,
541
+ table: Mapping[str, JobOps],
542
+ kinds: Optional[Mapping[str, JobOps]] = None,
543
+ generic_prefixes: Iterable[str] = (),
544
+ ) -> None:
545
+ self._core = core
546
+ self._table = table
547
+ self._kinds = kinds or {}
548
+ self._generic = frozenset(generic_prefixes)
549
+
550
+ def ops_for(self, job: Any) -> JobOps:
551
+ return job_ops_for(self._table, self._kinds, self._generic, job)
552
+
553
+ async def get(self, job: Any) -> Any:
554
+ return await self._core._call(self.ops_for(job).get, path=[job_id_of(job)])
555
+
556
+ async def wait(self, job: Any, *, timeout: Optional[float] = None, raise_on_failure: bool = True, on_event: Optional[Callable[[Any], None]] = None) -> Any:
557
+ return await self._core._wait_job(self.ops_for(job), job, timeout=timeout, raise_on_failure=raise_on_failure, on_event=on_event)
558
+
559
+ async def events(self, job: Any, *, last_event_id: Optional[str] = None) -> AsyncJobEventStream[Any, Any]:
560
+ return await self._core._job_events(self.ops_for(job), job, last_event_id=last_event_id)
561
+
562
+ async def cancel(self, job: Any) -> Any:
563
+ return await self._core._call(self.ops_for(job).cancel, path=[job_id_of(job)])