bladekit 4.5.0__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.
- bladekit/__init__.py +219 -0
- bladekit/_app.py +3241 -0
- bladekit/_banner.py +640 -0
- bladekit/_batch.py +150 -0
- bladekit/_bucket.py +211 -0
- bladekit/_build.py +337 -0
- bladekit/_bundle.py +459 -0
- bladekit/_client.py +1152 -0
- bladekit/_cls.py +574 -0
- bladekit/_constants.py +121 -0
- bladekit/_cron.py +88 -0
- bladekit/_dict.py +174 -0
- bladekit/_env.py +98 -0
- bladekit/_image.py +577 -0
- bladekit/_loader.py +79 -0
- bladekit/_map.py +555 -0
- bladekit/_preempt.py +94 -0
- bladekit/_queue.py +137 -0
- bladekit/_render.py +683 -0
- bladekit/_runner_api.py +148 -0
- bladekit/_sandbox.py +1051 -0
- bladekit/_secret.py +52 -0
- bladekit/_serialize.py +88 -0
- bladekit/_serve.py +188 -0
- bladekit/_volume.py +232 -0
- bladekit/_webhook.py +90 -0
- bladekit/cli.py +2282 -0
- bladekit/config.py +181 -0
- bladekit/exceptions.py +544 -0
- bladekit-4.5.0.dist-info/METADATA +373 -0
- bladekit-4.5.0.dist-info/RECORD +33 -0
- bladekit-4.5.0.dist-info/WHEEL +4 -0
- bladekit-4.5.0.dist-info/entry_points.txt +3 -0
bladekit/exceptions.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Exception hierarchy for the Blade AI Fabric SDK.
|
|
2
|
+
|
|
3
|
+
Design rule (task requirement 6 / SPEC §3.3): a *remote user exception* must be
|
|
4
|
+
clearly distinct from a *platform error*.
|
|
5
|
+
|
|
6
|
+
- ``RemoteFunctionError`` wraps an exception raised by the user's function on the
|
|
7
|
+
remote side. When the original exception can be unpickled it is re-raised
|
|
8
|
+
directly (with the remote traceback attached); otherwise this generic wrapper
|
|
9
|
+
carries the remote traceback so the user still sees a readable message.
|
|
10
|
+
- ``FabricError`` and its subclasses signal that *the platform* failed (API
|
|
11
|
+
envelope errors, run-level infra errors such as NODE_LOST / CONTAINER_FAILED /
|
|
12
|
+
VERSION_MISMATCH). These are never confused with a legitimate user exception.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import warnings
|
|
18
|
+
from typing import Any, Mapping, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FabricError(Exception):
|
|
22
|
+
"""Base class for every platform-side error surfaced by the SDK.
|
|
23
|
+
|
|
24
|
+
Was ``ForgeError`` before the AI Fabric rename (PAAS-771); that name is kept
|
|
25
|
+
as a deprecated alias of this very class, so ``except ForgeError`` still
|
|
26
|
+
catches everything.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Old public name -> new public name. Resolved lazily by __getattr__ below so
|
|
31
|
+
# each use of an old name emits a DeprecationWarning, while returning the SAME
|
|
32
|
+
# object (isinstance / except keep working across the rename).
|
|
33
|
+
DEPRECATED_ALIASES = {"ForgeError": "FabricError"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_deprecated(name: str, *, stacklevel: int = 3) -> Any:
|
|
37
|
+
"""Return the object behind a deprecated alias, warning once per call site.
|
|
38
|
+
|
|
39
|
+
``stacklevel=3`` points the warning at the code that used the old name when
|
|
40
|
+
called from a module ``__getattr__`` (this frame, __getattr__, the caller).
|
|
41
|
+
"""
|
|
42
|
+
new = DEPRECATED_ALIASES[name]
|
|
43
|
+
warnings.warn(
|
|
44
|
+
f"{name} is deprecated, use {new} instead (renamed with the bladekit "
|
|
45
|
+
"package; the old name will be removed in a future major release).",
|
|
46
|
+
DeprecationWarning,
|
|
47
|
+
stacklevel=stacklevel,
|
|
48
|
+
)
|
|
49
|
+
return globals()[new]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __getattr__(name: str) -> Any:
|
|
53
|
+
if name in DEPRECATED_ALIASES:
|
|
54
|
+
return resolve_deprecated(name)
|
|
55
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- API envelope errors (conventions.md §2) --------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class APIError(FabricError):
|
|
62
|
+
"""An error returned in the standard JSON error envelope.
|
|
63
|
+
|
|
64
|
+
``code`` is the stable machine code (SCREAMING_SNAKE_CASE). Unknown codes are
|
|
65
|
+
tolerated and treated like any other API error (conventions.md §2/§5).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
code: str,
|
|
71
|
+
message: str,
|
|
72
|
+
*,
|
|
73
|
+
status_code: Optional[int] = None,
|
|
74
|
+
details: Optional[Mapping[str, Any]] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.code = code
|
|
77
|
+
self.api_message = message
|
|
78
|
+
self.status_code = status_code
|
|
79
|
+
self.details = dict(details or {})
|
|
80
|
+
super().__init__(f"[{code}] {message}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AuthenticationError(APIError):
|
|
84
|
+
"""UNAUTHENTICATED — token missing/invalid/revoked (HTTP 401)."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PermissionDeniedError(APIError):
|
|
88
|
+
"""PERMISSION_DENIED — token valid but operation forbidden (HTTP 403)."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NotFoundError(APIError):
|
|
92
|
+
"""*_NOT_FOUND — object does not exist or belongs to another user (404)."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InvalidArgumentError(APIError):
|
|
96
|
+
"""INVALID_ARGUMENT / INVALID_CURSOR / IMAGE_ALIAS_UNKNOWN (400)."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ConflictError(APIError):
|
|
100
|
+
"""409 family — BUNDLE_NOT_READY, RUN_NOT_CANCELLABLE, etc."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PayloadTooLargeError(APIError):
|
|
104
|
+
"""413 family — BUNDLE_TOO_LARGE / PAYLOAD_TOO_LARGE."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class UnavailableError(APIError):
|
|
108
|
+
"""UNAVAILABLE (503) — a dependency is down; the caller may retry."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- Connection / transport -------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ConnectionFailed(FabricError):
|
|
115
|
+
"""Could not reach the control plane at all (DNS, refused, timeout)."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- Run-level infrastructure errors (conventions.md RunError registry) -----
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RunError(FabricError):
|
|
122
|
+
"""A run/call failed for an *infrastructure* reason (not a user exception).
|
|
123
|
+
|
|
124
|
+
``code`` mirrors ``RunError.code`` from the contract (NODE_LOST,
|
|
125
|
+
CONTAINER_FAILED, IMAGE_PULL_FAILED, …). Specific subclasses give nicer,
|
|
126
|
+
actionable messages for the common cases; unknown codes fall back to this
|
|
127
|
+
base class.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
code: str = "INTERNAL"
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
message: str,
|
|
135
|
+
*,
|
|
136
|
+
code: Optional[str] = None,
|
|
137
|
+
run_id: Optional[str] = None,
|
|
138
|
+
details: Optional[Mapping[str, Any]] = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
if code is not None:
|
|
141
|
+
self.code = code
|
|
142
|
+
self.run_id = run_id
|
|
143
|
+
self.details = dict(details or {})
|
|
144
|
+
super().__init__(message)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class NodeLostError(RunError):
|
|
148
|
+
code = "NODE_LOST"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ContainerFailedError(RunError):
|
|
152
|
+
code = "CONTAINER_FAILED"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ImagePullFailedError(RunError):
|
|
156
|
+
code = "IMAGE_PULL_FAILED"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RunnerCrashedError(RunError):
|
|
160
|
+
code = "RUNNER_CRASHED"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class BundleFetchFailedError(RunError):
|
|
164
|
+
code = "BUNDLE_FETCH_FAILED"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class VersionMismatchError(RunError):
|
|
168
|
+
"""PYTHON_VERSION_MISMATCH or CLOUDPICKLE_VERSION_MISMATCH (handshake §5)."""
|
|
169
|
+
|
|
170
|
+
code = "PYTHON_VERSION_MISMATCH"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TimeoutError_(RunError): # noqa: N801 - explicit, avoid shadowing builtin
|
|
174
|
+
code = "TIMEOUT"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class RunCancelled(FabricError):
|
|
178
|
+
"""The run was cancelled (locally via Ctrl-C, or by the timeout watcher)."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, run_id: str, *, message: Optional[str] = None) -> None:
|
|
181
|
+
self.run_id = run_id
|
|
182
|
+
super().__init__(message or f"run {run_id} was cancelled")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RunLostError(NodeLostError):
|
|
186
|
+
"""Raised to the caller when a run/call transitions to ``lost``."""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- Remote user exception --------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class RemoteError(FabricError):
|
|
193
|
+
"""Carries a remote user exception that could not be reconstructed locally.
|
|
194
|
+
|
|
195
|
+
Holds the remote traceback so the message is still readable. Alias kept for
|
|
196
|
+
parity with the runner-protocol wording (``bladekit.RemoteError``).
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
exc_type: str,
|
|
202
|
+
message: str,
|
|
203
|
+
remote_traceback: str,
|
|
204
|
+
) -> None:
|
|
205
|
+
self.exc_type = exc_type
|
|
206
|
+
self.remote_message = message
|
|
207
|
+
self.remote_traceback = remote_traceback
|
|
208
|
+
super().__init__(self._format())
|
|
209
|
+
|
|
210
|
+
def _format(self) -> str:
|
|
211
|
+
return (
|
|
212
|
+
f"remote function raised {self.exc_type}: {self.remote_message}\n\n"
|
|
213
|
+
f"--- remote traceback ---\n{self.remote_traceback}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Task requirement names the user-exception wrapper ``RemoteFunctionError``.
|
|
218
|
+
# It is the generic carrier used when the original exception cannot be
|
|
219
|
+
# re-raised (pickled is null or unpickling failed).
|
|
220
|
+
RemoteFunctionError = RemoteError
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --- image builds -----------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ImageBuildError(FabricError):
|
|
227
|
+
"""A ``bladekit.Image`` build did not reach ``succeeded``.
|
|
228
|
+
|
|
229
|
+
Actionable: points the user at ``blade image logs <build_id>``. ``code`` is
|
|
230
|
+
the build's own error code (IMAGE_BUILD_FAILED / IMAGE_BUILD_TIMEOUT /
|
|
231
|
+
IMAGE_PUSH_FAILED / INTERNAL — conventions.md §2, extensible).
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
build_id: str,
|
|
237
|
+
code: str,
|
|
238
|
+
message: str,
|
|
239
|
+
*,
|
|
240
|
+
definition_hash: Optional[str] = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
self.build_id = build_id
|
|
243
|
+
self.code = code
|
|
244
|
+
self.build_message = message
|
|
245
|
+
self.definition_hash = definition_hash
|
|
246
|
+
super().__init__(
|
|
247
|
+
f"image build {build_id} failed ({code}): {message}\n"
|
|
248
|
+
f" see build logs with: blade image logs {build_id}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --- volumes ----------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class VolumeError(FabricError):
|
|
256
|
+
"""A volume operation is invalid (e.g. ``commit()`` on an unmounted volume)."""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class VolumeCommitError(FabricError):
|
|
260
|
+
"""``volume.commit()`` failed or was refused.
|
|
261
|
+
|
|
262
|
+
``code`` mirrors the server (VOLUME_COMMIT_FAILED, VOLUME_READ_ONLY,
|
|
263
|
+
VOLUME_COMMIT_IN_PROGRESS, NODE_LOST, RUNNER_CRASHED, …). Never fatal to the
|
|
264
|
+
container — the caller may retry (runner-protocol.md §11).
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def __init__(self, code: str, message: str) -> None:
|
|
268
|
+
self.code = code
|
|
269
|
+
super().__init__(f"[{code}] {message}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class PreemptedError(RunError):
|
|
273
|
+
"""PREEMPTED — the run/call was preempted by a higher-priority job.
|
|
274
|
+
|
|
275
|
+
For map workers: the input is automatically re-queued by the server and
|
|
276
|
+
will be retried without consuming the retries budget.
|
|
277
|
+
For simple runs: the run is terminal (cancelled). Re-run it or use map()
|
|
278
|
+
for automatic re-queue on preemption.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
code = "PREEMPTED"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class InputTimeoutError(RunError):
|
|
285
|
+
"""INPUT_TIMEOUT — an input exceeded its per-input timeout.
|
|
286
|
+
|
|
287
|
+
Distinct from TIMEOUT (run global). Raised when the map() call had
|
|
288
|
+
``input_timeout_seconds`` set and a single input took too long.
|
|
289
|
+
Increase ``input_timeout_seconds=`` or optimize the function.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
code = "INPUT_TIMEOUT"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class RetriesExhaustedError(RunError):
|
|
296
|
+
"""RETRIES_EXHAUSTED — an input exhausted all retry attempts.
|
|
297
|
+
|
|
298
|
+
The last error code is in ``details["last_error_code"]``. To avoid this,
|
|
299
|
+
make your function idempotent and increase ``retries=`` on the map call.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
code = "RETRIES_EXHAUSTED"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class GPUQuotaExceededError(APIError):
|
|
306
|
+
"""GPU_QUOTA_EXCEEDED — the namespace quota would be exceeded (strict mode).
|
|
307
|
+
|
|
308
|
+
Only raised when the namespace is configured with ``quota_mode=enforce``.
|
|
309
|
+
In the default ``quota_mode=queue``, the run is queued instead and a
|
|
310
|
+
``GPU_QUOTA_REACHED`` warning is emitted.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class OrgGPUQuotaExceededError(APIError):
|
|
315
|
+
"""ORG_GPU_QUOTA_EXCEEDED — the organization's concurrent-GPU quota would
|
|
316
|
+
be exceeded (strict mode).
|
|
317
|
+
|
|
318
|
+
The org tier of the GPU quota: ``details`` carries ``org_id``,
|
|
319
|
+
``gpu_type`` (``'*'`` = the total cap across every type), ``limit``,
|
|
320
|
+
``in_use`` and ``requested``. Only raised when the org is configured with
|
|
321
|
+
``quota_mode=enforce``; in the default ``quota_mode=queue`` the work is
|
|
322
|
+
queued instead and an ``ORG_GPU_QUOTA_REACHED`` warning is emitted.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --- sandbox errors ---------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class SandboxNotRunningError(APIError):
|
|
330
|
+
"""SANDBOX_NOT_RUNNING — sandbox is not in the ``running`` state (409).
|
|
331
|
+
|
|
332
|
+
Raised when exec/put_file/get_file/ls/tunnel is called on a sandbox that is
|
|
333
|
+
creating, terminating, terminated, or failed.
|
|
334
|
+
``details["state"]`` carries the current state.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class SandboxExecLimitError(APIError):
|
|
339
|
+
"""SANDBOX_EXEC_LIMIT — too many concurrent execs in this sandbox (429).
|
|
340
|
+
|
|
341
|
+
The sandbox has reached ``max_concurrent_execs`` (16 by default).
|
|
342
|
+
Wait for an exec to finish before starting a new one.
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class SandboxFileNotFoundError(NotFoundError):
|
|
347
|
+
"""SANDBOX_FILE_NOT_FOUND — path does not exist in the sandbox rootfs (404)."""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class SandboxFileTooLargeError(PayloadTooLargeError):
|
|
351
|
+
"""SANDBOX_FILE_TOO_LARGE — file exceeds the 5 GiB per-file limit (413)."""
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class SandboxPathInvalidError(InvalidArgumentError):
|
|
355
|
+
"""SANDBOX_PATH_INVALID — path is non-absolute, contains '..', or a symlink
|
|
356
|
+
hostile resolution would exit the rootfs (400).
|
|
357
|
+
|
|
358
|
+
Always use absolute paths; never construct paths from untrusted input.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class SandboxDiskQuotaExceededError(PayloadTooLargeError):
|
|
363
|
+
"""SANDBOX_DISK_QUOTA_EXCEEDED — write would exceed the sandbox's cumulative
|
|
364
|
+
disk quota (413). Distinct from SANDBOX_FILE_TOO_LARGE (per-file limit).
|
|
365
|
+
|
|
366
|
+
Remove files you no longer need, or reduce the amount of data written.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class SandboxPortInUseError(ConflictError):
|
|
371
|
+
"""SANDBOX_PORT_IN_USE — another tunnel already exposes this port (409).
|
|
372
|
+
|
|
373
|
+
Re-POST the same port returns the existing tunnel (idempotent); this error
|
|
374
|
+
only fires when the port is in use by a *different* tunnel object.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class RuntimeUnavailableWarning(FabricError):
|
|
379
|
+
"""RUNTIME_UNAVAILABLE — no schedulable node announces the requested runtime.
|
|
380
|
+
|
|
381
|
+
Not raised as an exception; surfaced as a warning in the Sandbox.create()
|
|
382
|
+
response. The sandbox stays ``creating`` until a node with the runtime
|
|
383
|
+
appears. This is the safe default: a ``runsc`` sandbox on a runc-only fleet
|
|
384
|
+
waits rather than falling back to runc silently.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class SandboxTerminatedError(FabricError):
|
|
389
|
+
"""SANDBOX_TERMINATED — the sandbox died while an exec was in flight.
|
|
390
|
+
|
|
391
|
+
Raised when a WebSocket exec stream receives an ``error`` frame with code
|
|
392
|
+
``SANDBOX_TERMINATED`` (timeout, terminate(), preemption, node lost, OOM).
|
|
393
|
+
The sandbox's exec is in state ``terminated`` (no exit_code).
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
def __init__(self, sandbox_id: str, code: str, message: str) -> None:
|
|
397
|
+
self.sandbox_id = sandbox_id
|
|
398
|
+
self.code = code
|
|
399
|
+
super().__init__(
|
|
400
|
+
f"sandbox {sandbox_id} terminated during exec ({code}): {message}\n"
|
|
401
|
+
"The sandbox can no longer accept exec calls. Create a new sandbox."
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class SandboxFailedError(FabricError):
|
|
406
|
+
"""The sandbox transitioned to ``failed`` before becoming running.
|
|
407
|
+
|
|
408
|
+
``code`` is the infrastructure error code (IMAGE_PULL_FAILED, CONTAINER_FAILED,
|
|
409
|
+
GPU_QUOTA_EXCEEDED, INTERNAL, …). These reuse the run error registry
|
|
410
|
+
(sandbox-protocol.md §7.1 / conventions.md §2).
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
def __init__(self, sandbox_id: str, code: str, message: str) -> None:
|
|
414
|
+
self.sandbox_id = sandbox_id
|
|
415
|
+
self.code = code
|
|
416
|
+
super().__init__(
|
|
417
|
+
f"sandbox {sandbox_id} failed ({code}): {message}"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# Map RunError.code -> SDK exception class for friendly, actionable messages.
|
|
422
|
+
RUN_ERROR_CLASSES = {
|
|
423
|
+
"NODE_LOST": NodeLostError,
|
|
424
|
+
"CONTAINER_FAILED": ContainerFailedError,
|
|
425
|
+
"IMAGE_PULL_FAILED": ImagePullFailedError,
|
|
426
|
+
"RUNNER_CRASHED": RunnerCrashedError,
|
|
427
|
+
"RUNNER_INTERNAL": RunnerCrashedError,
|
|
428
|
+
"BUNDLE_FETCH_FAILED": BundleFetchFailedError,
|
|
429
|
+
"PYTHON_VERSION_MISMATCH": VersionMismatchError,
|
|
430
|
+
"CLOUDPICKLE_VERSION_MISMATCH": VersionMismatchError,
|
|
431
|
+
"TIMEOUT": TimeoutError_,
|
|
432
|
+
"PREEMPTED": PreemptedError,
|
|
433
|
+
"INPUT_TIMEOUT": InputTimeoutError,
|
|
434
|
+
"RETRIES_EXHAUSTED": RetriesExhaustedError,
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def run_error_from_payload(
|
|
439
|
+
payload: Mapping[str, Any], *, run_id: Optional[str] = None
|
|
440
|
+
) -> RunError:
|
|
441
|
+
"""Build the most specific RunError subclass for an ``error`` object."""
|
|
442
|
+
code = str(payload.get("code", "INTERNAL"))
|
|
443
|
+
message = str(payload.get("message", code))
|
|
444
|
+
details = payload.get("details") or {}
|
|
445
|
+
cls = RUN_ERROR_CLASSES.get(code, RunError)
|
|
446
|
+
actionable = _actionable_hint(code, details)
|
|
447
|
+
if actionable:
|
|
448
|
+
message = f"{message}\n{actionable}"
|
|
449
|
+
return cls(message, code=code, run_id=run_id, details=details)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def run_lost_from_payload(
|
|
453
|
+
payload: Mapping[str, Any], *, run_id: Optional[str] = None
|
|
454
|
+
) -> "RunLostError":
|
|
455
|
+
"""Build a ``RunLostError`` for a run/call that transitioned to ``lost``.
|
|
456
|
+
|
|
457
|
+
A ``lost`` outcome is the node monitor declaring the node silent (>60 s); it is
|
|
458
|
+
always a NODE_LOST-class condition. ``RunLostError`` subclasses ``NodeLostError``
|
|
459
|
+
so existing ``except NodeLostError`` handlers still catch it, while the more
|
|
460
|
+
specific type is the one actually raised (it is documented/exported).
|
|
461
|
+
"""
|
|
462
|
+
message = str(payload.get("message", "run lost"))
|
|
463
|
+
details = payload.get("details") or {}
|
|
464
|
+
hint = _actionable_hint("NODE_LOST", details)
|
|
465
|
+
if hint:
|
|
466
|
+
message = f"{message}\n{hint}"
|
|
467
|
+
return RunLostError(
|
|
468
|
+
message, code=str(payload.get("code", "NODE_LOST")), run_id=run_id, details=details
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _actionable_hint(code: str, details: Mapping[str, Any]) -> str:
|
|
473
|
+
"""Return a short, actionable hint for known infra error codes."""
|
|
474
|
+
if code == "PYTHON_VERSION_MISMATCH":
|
|
475
|
+
exp = details.get("expected")
|
|
476
|
+
act = details.get("actual")
|
|
477
|
+
hint = details.get("image_ref_hint")
|
|
478
|
+
base = (
|
|
479
|
+
f"The remote Python ({act}) does not match the one declared by the "
|
|
480
|
+
f"client ({exp})."
|
|
481
|
+
)
|
|
482
|
+
if hint:
|
|
483
|
+
base += f" {hint}"
|
|
484
|
+
else:
|
|
485
|
+
base += " Pick a catalogue image whose Python matches your client."
|
|
486
|
+
return base
|
|
487
|
+
if code == "CLOUDPICKLE_VERSION_MISMATCH":
|
|
488
|
+
return (
|
|
489
|
+
"Client and remote cloudpickle major.minor differ. Pin the same "
|
|
490
|
+
"cloudpickle version locally (pip install 'cloudpickle==<remote>')."
|
|
491
|
+
)
|
|
492
|
+
if code == "NODE_LOST":
|
|
493
|
+
return (
|
|
494
|
+
"The GPU node went silent for more than 60s; the run was lost. "
|
|
495
|
+
"This is usually transient — re-run it."
|
|
496
|
+
)
|
|
497
|
+
if code == "CONTAINER_FAILED":
|
|
498
|
+
return (
|
|
499
|
+
"The container never started or vanished. Check the image ref and "
|
|
500
|
+
"the run logs for an interpreter/startup error."
|
|
501
|
+
)
|
|
502
|
+
if code == "IMAGE_PULL_FAILED":
|
|
503
|
+
return (
|
|
504
|
+
"The node could not pull the image. Check the image alias/registry "
|
|
505
|
+
"ref and that it is reachable from the fleet."
|
|
506
|
+
)
|
|
507
|
+
if code == "BUNDLE_FETCH_FAILED":
|
|
508
|
+
return "The runner could not download/verify your code bundle. Re-run to re-upload."
|
|
509
|
+
if code == "TIMEOUT":
|
|
510
|
+
return "The run exceeded its timeout. Increase timeout= on @app.function."
|
|
511
|
+
if code == "PREEMPTED":
|
|
512
|
+
return (
|
|
513
|
+
"The run/worker was preempted by a higher-priority job. "
|
|
514
|
+
"For map() batches the input is automatically re-queued. "
|
|
515
|
+
"For simple runs: re-run with a higher priority= or wait for the "
|
|
516
|
+
"high-priority job to finish."
|
|
517
|
+
)
|
|
518
|
+
if code == "INPUT_TIMEOUT":
|
|
519
|
+
return (
|
|
520
|
+
"An input exceeded its per-input timeout. Increase input_timeout_seconds= "
|
|
521
|
+
"on the map() call, or optimize the function."
|
|
522
|
+
)
|
|
523
|
+
if code == "RETRIES_EXHAUSTED":
|
|
524
|
+
last = details.get("last_error_code", "")
|
|
525
|
+
base = "The input exhausted all retry attempts."
|
|
526
|
+
if last:
|
|
527
|
+
base += f" Last infra error: {last}."
|
|
528
|
+
base += " Increase retries= or fix the underlying error."
|
|
529
|
+
return base
|
|
530
|
+
if code == "GPU_QUOTA_EXCEEDED":
|
|
531
|
+
ns = details.get("namespace", "")
|
|
532
|
+
gpu_type = details.get("gpu_type", "")
|
|
533
|
+
hint = f"Namespace {ns!r} quota for {gpu_type!r} is full (quota_mode=enforce)."
|
|
534
|
+
hint += " Wait for quota to free up or ask an admin to raise the limit."
|
|
535
|
+
return hint
|
|
536
|
+
if code == "ORG_GPU_QUOTA_EXCEEDED":
|
|
537
|
+
gpu_type = details.get("gpu_type", "")
|
|
538
|
+
in_use = details.get("in_use", "?")
|
|
539
|
+
limit = details.get("limit", "?")
|
|
540
|
+
scope = "GPUs" if gpu_type in ("", "*") else f"GPUs of type {gpu_type!r}"
|
|
541
|
+
hint = f"Your organization is using {in_use}/{limit} {scope}."
|
|
542
|
+
hint += " Wait for a run to finish or ask an admin to raise the org limit."
|
|
543
|
+
return hint
|
|
544
|
+
return ""
|