cwtrain 0.1.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.
cwtrain/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ # SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # SPDX-PackageName: inference-cookbook
4
+
5
+ """cwtrain: train against CoreWeave Dedicated Inference.
6
+
7
+ A training loop whose rollouts come from a Dedicated Inference deployment needs three things, one
8
+ subpackage each:
9
+
10
+ * :mod:`cwtrain.infra` - the deployment and its gateway (create them, wait until they serve,
11
+ delete them, find the endpoint) through the Dedicated Inference API, and the object-storage
12
+ client.
13
+ * :mod:`cwtrain.hotload` - publishing the trainer's new weights into the running deployment after
14
+ each step (checkpoint hot-load), as full checkpoints or compressed deltas.
15
+ * :mod:`cwtrain.integrations` - the rollout sampler for the deployment's OpenAI-compatible
16
+ endpoint, and the adapters for miles, slime and NeMo-RL.
17
+
18
+ Top-level names are resolved lazily, so importing a subpackage that only needs torch (for example
19
+ the NeMo-RL worker extension) does not pull in the HTTP and object-storage clients.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from importlib import import_module
25
+ from importlib.metadata import PackageNotFoundError
26
+ from importlib.metadata import version as _distribution_version
27
+ from typing import Any
28
+
29
+ # name -> the subpackage that defines it
30
+ _EXPORTS = {
31
+ "HotLoader": "cwtrain.hotload",
32
+ "InferenceClient": "cwtrain.infra",
33
+ "inspect_snapshot": "cwtrain.hotload",
34
+ "upload_snapshot": "cwtrain.hotload",
35
+ }
36
+ __all__ = list(_EXPORTS)
37
+
38
+ # pyproject.toml is the only place the version is written; read it back from the installed
39
+ # distribution so the two cannot disagree.
40
+ try:
41
+ __version__ = _distribution_version("cwtrain")
42
+ except PackageNotFoundError: # imported from a source tree that was never installed
43
+ __version__ = "0+unknown"
44
+
45
+
46
+ def __getattr__(name: str) -> Any:
47
+ if name in _EXPORTS:
48
+ return getattr(import_module(_EXPORTS[name]), name)
49
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
cwtrain/cli.py ADDED
@@ -0,0 +1,441 @@
1
+ # SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # SPDX-PackageName: inference-cookbook
4
+
5
+ """``cwtrain`` command line: the deployment rollouts come from, and the hot-loads into it.
6
+
7
+ Examples::
8
+
9
+ cwtrain deployment create --file deployment.json --wait
10
+ cwtrain hotload publish --deployment <uuid> --checkpoint ./step_000100 --identity step_000100
11
+ cwtrain hotload trigger --deployment <uuid> --identity <delta> --previous-identity <base>
12
+ cwtrain hotload status <hot-load-uuid>
13
+ cwtrain hotload list --deployment <uuid>
14
+ cwtrain hotload check --deployment <uuid>
15
+ cwtrain deployment delete <uuid> --wait
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import logging
23
+ import math
24
+ import re
25
+ import sys
26
+ import traceback
27
+ import uuid
28
+ from collections.abc import Iterator
29
+ from dataclasses import asdict, is_dataclass
30
+ from datetime import datetime
31
+ from enum import Enum
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from boto3.exceptions import RetriesExceededError, S3UploadFailedError
36
+ from botocore.exceptions import (
37
+ ClientError,
38
+ CredentialRetrievalError,
39
+ HTTPClientError,
40
+ NoCredentialsError,
41
+ PartialCredentialsError,
42
+ ProfileNotFound,
43
+ )
44
+ from botocore.exceptions import (
45
+ ConnectionError as StorageConnectionError,
46
+ )
47
+
48
+ from cwtrain.errors import AlreadyExistsError, APIError, CWTrainError, NotFoundError
49
+ from cwtrain.hotload import HotLoader, PromptCachePolicy, inspect_snapshot
50
+ from cwtrain.infra import lifecycle
51
+ from cwtrain.infra.client import (
52
+ DEFAULT_BASE_URL,
53
+ TOKEN_ENV_VAR,
54
+ TOKEN_ENV_VARS,
55
+ InferenceClient,
56
+ token_from_env,
57
+ )
58
+ from cwtrain.infra.models import Deployment
59
+
60
+ log = logging.getLogger(__name__)
61
+
62
+ #: Libraries that log every HTTP request and credential lookup at INFO, which buries the hot-load
63
+ #: progress lines. They log only warnings, unless --verbose is set.
64
+ _CHATTY_LOGGERS = ("httpx", "httpcore", "botocore", "boto3", "s3transfer")
65
+ #: A value left as in the recipes' templates, such as ``<gateway uuid in one zone>`` or
66
+ #: ``<path to Nemotron 3.5 Lightning BF16 checkpoint and tokenizer files>``.
67
+ _PLACEHOLDER = re.compile(r"<[A-Za-z][^<>]*>")
68
+ #: Free-form request fields, which are not checked for placeholders: the engine's own options
69
+ #: and environment can legitimately hold values such as ``<think>`` or ``<s>``.
70
+ _FREE_FORM = frozenset({"runtime.engineConfig", "runtime.engineEnv"})
71
+
72
+
73
+ def _jsonable(obj: Any) -> Any:
74
+ if is_dataclass(obj) and not isinstance(obj, type):
75
+ return {k: _jsonable(v) for k, v in asdict(obj).items() if k not in {"raw", "index_bytes"}}
76
+ if isinstance(obj, Enum):
77
+ return obj.name
78
+ if isinstance(obj, datetime):
79
+ return obj.isoformat()
80
+ if isinstance(obj, (list, tuple)):
81
+ return [_jsonable(v) for v in obj]
82
+ if isinstance(obj, dict):
83
+ return {k: _jsonable(v) for k, v in obj.items()}
84
+ if hasattr(obj, "__fspath__"):
85
+ return str(obj)
86
+ return obj
87
+
88
+
89
+ def _print(obj: Any) -> None:
90
+ json.dump(_jsonable(obj), sys.stdout, indent=2)
91
+ sys.stdout.write("\n")
92
+
93
+
94
+ def _positive_float(value: str) -> float:
95
+ try:
96
+ number = float(value)
97
+ except ValueError:
98
+ raise argparse.ArgumentTypeError("must be a finite number greater than zero") from None
99
+ if not math.isfinite(number) or number <= 0:
100
+ raise argparse.ArgumentTypeError("must be a finite number greater than zero")
101
+ return number
102
+
103
+
104
+ def _positive_int(value: str) -> int:
105
+ try:
106
+ number = int(value)
107
+ except ValueError:
108
+ raise argparse.ArgumentTypeError("must be an integer greater than zero") from None
109
+ if number <= 0:
110
+ raise argparse.ArgumentTypeError("must be an integer greater than zero")
111
+ return number
112
+
113
+
114
+ def _prompt_cache_policy(args: argparse.Namespace) -> PromptCachePolicy:
115
+ """The policy for ``publish`` and ``trigger``, the commands with ``--reset-prompt-cache``."""
116
+ return PromptCachePolicy.RESET_ALL if args.reset_prompt_cache else PromptCachePolicy.UNSPECIFIED
117
+
118
+
119
+ def _read_request(path: str) -> dict[str, Any]:
120
+ """The JSON request body in ``path`` (``-`` for stdin); refused while it holds placeholders."""
121
+ try:
122
+ text = sys.stdin.read() if path == "-" else Path(path).read_text()
123
+ except OSError as exc:
124
+ raise CWTrainError(f"cannot read {path}: {exc.strerror or exc}") from exc
125
+ try:
126
+ body = json.loads(text)
127
+ except ValueError as exc:
128
+ raise CWTrainError(f"{path} is not valid JSON: {exc}") from exc
129
+ if not isinstance(body, dict):
130
+ raise CWTrainError(f"{path} must hold a JSON object, the request body")
131
+ unfilled = list(_placeholders(body))
132
+ if unfilled:
133
+ shown = ", ".join(f"{where} = {value!r}" for where, value in unfilled[:5])
134
+ raise CWTrainError(f"fill in {path} first; it still has placeholders: {shown}")
135
+ return body
136
+
137
+
138
+ def _placeholders(value: Any, where: str = "") -> Iterator[tuple[str, str]]:
139
+ if isinstance(value, dict):
140
+ for key, item in value.items():
141
+ path = f"{where}.{key}" if where else str(key)
142
+ if path not in _FREE_FORM:
143
+ yield from _placeholders(item, path)
144
+ elif isinstance(value, list):
145
+ for i, item in enumerate(value):
146
+ yield from _placeholders(item, f"{where}[{i}]")
147
+ elif isinstance(value, str) and _PLACEHOLDER.search(value):
148
+ yield where, value
149
+
150
+
151
+ def _deployment_view(client: InferenceClient, deployment: Deployment) -> dict[str, Any]:
152
+ """The deployment as JSON, with its gateways' endpoints and the URL rollouts are sent to.
153
+
154
+ If the gateways cannot be read, the deployment is shown without endpoints. After a create, the
155
+ deployment exists either way, and its ID must not get lost.
156
+ """
157
+ try:
158
+ found = lifecycle.endpoints(client, deployment)
159
+ except APIError as exc:
160
+ log.warning(
161
+ "cannot read the gateways of deployment %s, so its endpoints are not shown: %s",
162
+ deployment.id,
163
+ exc,
164
+ )
165
+ found = ()
166
+ view = _jsonable(deployment)
167
+ view["endpoints"] = list(found)
168
+ view["rollout_base_url"] = lifecycle.rollout_base_url(found[0]) if found else None
169
+ return view
170
+
171
+
172
+ def build_parser() -> argparse.ArgumentParser:
173
+ parser = argparse.ArgumentParser(prog="cwtrain")
174
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API origin")
175
+ parser.add_argument(
176
+ "--token",
177
+ default=None,
178
+ help=f"API token (default: ${TOKEN_ENV_VAR}, else ${TOKEN_ENV_VARS[1]})",
179
+ )
180
+ parser.add_argument("-v", "--verbose", action="store_true")
181
+ sub = parser.add_subparsers(dest="group", required=True)
182
+
183
+ dep = sub.add_parser(
184
+ "deployment", help="create, show and delete the deployment rollouts come from"
185
+ ).add_subparsers(dest="cmd", required=True)
186
+
187
+ p = dep.add_parser("create", help="create a deployment from a request body in a JSON file")
188
+ p.add_argument(
189
+ "-f", "--file", required=True, help="CreateDeploymentRequest JSON; '-' reads stdin"
190
+ )
191
+ p.add_argument("--wait", action="store_true", help="block until the deployment is READY")
192
+ p.add_argument("--timeout", type=_positive_float, default=3600.0)
193
+
194
+ p = dep.add_parser("get", help="show a deployment, its hot-load settings and its endpoints")
195
+ p.add_argument("deployment")
196
+
197
+ p = dep.add_parser("delete", help="delete a deployment")
198
+ p.add_argument("deployment")
199
+ p.add_argument("--wait", action="store_true", help="block until the deployment is gone")
200
+ p.add_argument("--timeout", type=_positive_float, default=600.0)
201
+ p.add_argument(
202
+ "--ignore-not-found", action="store_true", help="succeed when there is no such deployment"
203
+ )
204
+
205
+ p = dep.add_parser(
206
+ "parameters", help="list the runtime versions, instance types and engine options"
207
+ )
208
+ p.add_argument(
209
+ "--gateway",
210
+ action="append",
211
+ dest="gateways",
212
+ help="only what the gateway's zone offers; repeat for several gateways",
213
+ )
214
+
215
+ hot = sub.add_parser("hotload", help="checkpoint hot-load operations").add_subparsers(
216
+ dest="cmd", required=True
217
+ )
218
+
219
+ p = hot.add_parser("inspect", help="validate a local checkpoint directory")
220
+ p.add_argument("checkpoint")
221
+ p.add_argument("--previous-identity")
222
+
223
+ p = hot.add_parser("publish", help="upload a checkpoint and hot-load it")
224
+ p.add_argument("--deployment", required=True)
225
+ p.add_argument("--checkpoint", required=True)
226
+ p.add_argument("--identity", required=True)
227
+ p.add_argument("--previous-identity")
228
+ p.add_argument("--reset-prompt-cache", action="store_true")
229
+ p.add_argument("--no-wait", action="store_true")
230
+ p.add_argument("--timeout", type=_positive_float, default=1800.0)
231
+ p.add_argument("--workers", type=_positive_int, default=8)
232
+
233
+ p = hot.add_parser("trigger", help="hot-load an already uploaded snapshot")
234
+ p.add_argument("--deployment", required=True)
235
+ p.add_argument("--identity", required=True)
236
+ p.add_argument("--previous-identity")
237
+ p.add_argument("--reset-prompt-cache", action="store_true")
238
+ p.add_argument("--no-wait", action="store_true")
239
+ p.add_argument("--timeout", type=_positive_float, default=1800.0)
240
+
241
+ p = hot.add_parser("status", help="show a hot-load and its replicas")
242
+ p.add_argument("hot_load_id")
243
+
244
+ p = hot.add_parser("wait", help="block until a hot-load is terminal")
245
+ p.add_argument("hot_load_id")
246
+ p.add_argument("--timeout", type=_positive_float, default=1800.0)
247
+
248
+ p = hot.add_parser("list", help="list hot-loads")
249
+ p.add_argument("--deployment")
250
+ p.add_argument("--limit", type=_positive_int, default=20)
251
+
252
+ p = hot.add_parser(
253
+ "check",
254
+ help="check that the storage credentials can list, read and write the snapshot prefix",
255
+ )
256
+ p.add_argument("--deployment", required=True)
257
+ return parser
258
+
259
+
260
+ def main(argv: list[str] | None = None) -> int:
261
+ args = build_parser().parse_args(argv)
262
+ logging.basicConfig(
263
+ level=logging.DEBUG if args.verbose else logging.INFO,
264
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
265
+ )
266
+ for name in _CHATTY_LOGGERS:
267
+ logging.getLogger(name).setLevel(logging.NOTSET if args.verbose else logging.WARNING)
268
+ try:
269
+ return _run(args)
270
+ except CWTrainError as exc:
271
+ return _error(str(exc), verbose=args.verbose)
272
+ except (NoCredentialsError, PartialCredentialsError, CredentialRetrievalError, ProfileNotFound):
273
+ return _error(
274
+ "object-storage credentials are missing or unavailable; configure "
275
+ "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY or a valid AWS profile",
276
+ verbose=args.verbose,
277
+ )
278
+ except ClientError as exc:
279
+ code = exc.response.get("Error", {}).get("Code", "Unknown")
280
+ return _error(
281
+ f"object storage rejected {exc.operation_name} ({code}); check storage credentials, "
282
+ "the deployment's bucket and prefix, and bucket read/write permissions",
283
+ verbose=args.verbose,
284
+ )
285
+ except (StorageConnectionError, HTTPClientError, S3UploadFailedError, RetriesExceededError):
286
+ return _error(
287
+ "object-storage transfer failed; check the endpoint (CW_S3_ENDPOINT or the AWS "
288
+ "endpoint settings), network connectivity, "
289
+ "storage credentials and bucket permissions, then retry",
290
+ verbose=args.verbose,
291
+ )
292
+
293
+
294
+ def _error(message: str, *, verbose: bool) -> int:
295
+ print(f"error: {message}", file=sys.stderr)
296
+ if verbose:
297
+ traceback.print_exc(file=sys.stderr)
298
+ return 1
299
+
300
+
301
+ def _run(args: argparse.Namespace) -> int:
302
+ if args.group == "hotload" and args.cmd == "inspect":
303
+ _print(inspect_snapshot(args.checkpoint, previous_identity=args.previous_identity))
304
+ return 0
305
+
306
+ token = args.token or token_from_env()
307
+ if not token or not token.strip():
308
+ raise CWTrainError(
309
+ f"a CoreWeave API token is required: set {TOKEN_ENV_VAR} (or {TOKEN_ENV_VARS[1]}) "
310
+ f"or pass --token before {args.group}"
311
+ )
312
+ with InferenceClient(token.strip(), base_url=args.base_url) as client:
313
+ if args.group == "deployment":
314
+ return _run_deployment(args, client)
315
+ return _run_hotload(args, client)
316
+
317
+
318
+ def _run_deployment(args: argparse.Namespace, client: InferenceClient) -> int:
319
+ if args.cmd == "create":
320
+ return _create_deployment(args, client)
321
+ if args.cmd == "get":
322
+ _print(_deployment_view(client, client.get_deployment(args.deployment)))
323
+ return 0
324
+ if args.cmd == "delete":
325
+ if args.wait:
326
+ existed = lifecycle.delete_deployment(client, args.deployment, timeout=args.timeout)
327
+ else:
328
+ try:
329
+ client.delete_deployment(args.deployment)
330
+ existed = True
331
+ except NotFoundError:
332
+ existed = False
333
+ if not existed and not args.ignore_not_found:
334
+ raise CWTrainError(f"deployment {args.deployment} not found")
335
+ result = ("deleted" if args.wait else "deleting") if existed else "not_found"
336
+ _print({"id": args.deployment, "result": result})
337
+ return 0
338
+ if args.cmd == "parameters":
339
+ _print(client.get_deployment_parameters(args.gateways))
340
+ return 0
341
+ raise SystemExit(2) # pragma: no cover - argparse guards this
342
+
343
+
344
+ def _create_deployment(args: argparse.Namespace, client: InferenceClient) -> int:
345
+ spec = _read_request(args.file)
346
+ if not spec.get("hotLoad"):
347
+ log.warning(
348
+ "%s has no hotLoad section, and hot-loading cannot be enabled after creation",
349
+ args.file,
350
+ )
351
+ # Choose the ID here so that it is in the log even if the response never arrives.
352
+ spec["id"] = spec.get("id") or str(uuid.uuid4())
353
+ log.info("creating deployment %r as %s", spec.get("name"), spec["id"])
354
+ try:
355
+ deployment = client.create_deployment(spec)
356
+ except AlreadyExistsError as exc:
357
+ same_name = [d for d in client.list_deployments() if d.name == spec.get("name")]
358
+ if not same_name:
359
+ raise
360
+ raise CWTrainError(
361
+ f"deployment {spec.get('name')!r} already exists as {same_name[0].id}; "
362
+ "`cwtrain deployment get` shows it, or delete it or choose another name"
363
+ ) from exc
364
+ if args.wait:
365
+ try:
366
+ deployment = lifecycle.wait_for_deployment(client, deployment.id, timeout=args.timeout)
367
+ except CWTrainError:
368
+ # The deployment exists and holds GPUs either way. Print it, with its ID, before the
369
+ # error. Use the latest state from the API if it can still be read.
370
+ try:
371
+ deployment = client.get_deployment(deployment.id)
372
+ except CWTrainError:
373
+ pass
374
+ _print(_deployment_view(client, deployment))
375
+ raise
376
+ _print(_deployment_view(client, deployment))
377
+ return 0
378
+
379
+
380
+ def _run_hotload(args: argparse.Namespace, client: InferenceClient) -> int:
381
+ if args.cmd == "status":
382
+ _print(
383
+ {
384
+ "hot_load": client.get_hot_load(args.hot_load_id),
385
+ "replicas": client.list_hot_load_replicas(args.hot_load_id),
386
+ }
387
+ )
388
+ return 0
389
+ if args.cmd == "list":
390
+ items = []
391
+ for hl in client.list_hot_loads(args.deployment):
392
+ items.append(hl)
393
+ if len(items) >= args.limit:
394
+ break
395
+ _print(items)
396
+ return 0
397
+
398
+ if args.cmd == "check":
399
+ loader = HotLoader(client, args.deployment)
400
+ uri = loader.check_snapshot_access()
401
+ cfg = loader.deployment.hot_load
402
+ assert cfg is not None
403
+ _print(
404
+ {
405
+ "deployment": args.deployment,
406
+ "bucket": cfg.bucket,
407
+ "path_prefix": cfg.path_prefix,
408
+ "uri": uri,
409
+ "result": "ok",
410
+ }
411
+ )
412
+ return 0
413
+
414
+ loader = HotLoader(client, getattr(args, "deployment", None) or "", timeout=args.timeout)
415
+ if args.cmd == "publish":
416
+ hl = loader.publish(
417
+ args.checkpoint,
418
+ args.identity,
419
+ previous_identity=args.previous_identity,
420
+ prompt_cache_policy=_prompt_cache_policy(args),
421
+ wait=not args.no_wait,
422
+ workers=args.workers,
423
+ )
424
+ elif args.cmd == "trigger":
425
+ hl = loader.trigger(
426
+ args.identity,
427
+ previous_identity=args.previous_identity,
428
+ prompt_cache_policy=_prompt_cache_policy(args),
429
+ )
430
+ if not args.no_wait:
431
+ hl = loader.wait(hl.id)
432
+ elif args.cmd == "wait":
433
+ hl = loader.wait(args.hot_load_id)
434
+ else: # pragma: no cover - argparse guards this
435
+ raise SystemExit(2)
436
+ _print(hl)
437
+ return 0 if hl.completed or not hl.terminal else 1
438
+
439
+
440
+ if __name__ == "__main__": # pragma: no cover
441
+ sys.exit(main())
cwtrain/errors.py ADDED
@@ -0,0 +1,167 @@
1
+ # SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # SPDX-PackageName: inference-cookbook
4
+
5
+ """The base exception of cwtrain, and the errors the CoreWeave API returns.
6
+
7
+ Each API error class maps to one gRPC / Connect status code. Catch the specific class (for example
8
+ a hot-load already in progress) instead of parsing messages. Errors specific to a subsystem live
9
+ next to it, such as the hot-load errors in :mod:`cwtrain.hotload.errors`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+
17
+ class CWTrainError(Exception):
18
+ """Base class for every error raised by cwtrain."""
19
+
20
+
21
+ class APIError(CWTrainError):
22
+ """The CoreWeave API rejected a request.
23
+
24
+ ``code`` is the canonical lower-snake-case status name (``failed_precondition``), whether the
25
+ server sent it as a Connect string or a numeric gRPC code.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ code: str,
31
+ message: str,
32
+ *,
33
+ http_status: int | None = None,
34
+ details: list[Any] | None = None,
35
+ ) -> None:
36
+ super().__init__(f"{code}: {message}")
37
+ self.code = code
38
+ self.message = message
39
+ self.http_status = http_status
40
+ self.details = details or []
41
+
42
+
43
+ class InvalidArgumentError(APIError):
44
+ """The request was malformed or violates a validation rule."""
45
+
46
+
47
+ class NotFoundError(APIError):
48
+ """The gateway, deployment or hot-load does not exist in the caller's organization."""
49
+
50
+
51
+ class AlreadyExistsError(APIError):
52
+ """The resource already exists: same ID, or for a gateway or deployment, same name."""
53
+
54
+
55
+ class FailedPreconditionError(APIError):
56
+ """The resource is not in a state that admits the request."""
57
+
58
+
59
+ class HotLoadInProgressError(FailedPreconditionError):
60
+ """Another hot-load is still active on the deployment."""
61
+
62
+
63
+ class PermissionDeniedError(APIError):
64
+ """The caller is not allowed to act on this resource."""
65
+
66
+
67
+ class UnauthenticatedError(APIError):
68
+ """The API token is missing or invalid."""
69
+
70
+
71
+ class UnavailableError(APIError):
72
+ """The service could not complete the request; retry later."""
73
+
74
+
75
+ class UnimplementedError(APIError):
76
+ """The RPC exists but is not implemented."""
77
+
78
+
79
+ #: HTTP statuses that a retry can fix. 501 (UNIMPLEMENTED) is a 5xx that never gets better.
80
+ TRANSIENT_HTTP_STATUSES = frozenset({500, 502, 503, 504})
81
+
82
+
83
+ def is_transient(err: BaseException) -> bool:
84
+ """Return whether a retry may succeed.
85
+
86
+ True for ``UNAVAILABLE`` (including a lost connection), and for an HTTP 500, 502, 503 or 504
87
+ whose code is not ``UNIMPLEMENTED``, such as a load balancer's 502 without a code. The client
88
+ retries these errors itself. Pollers such as :mod:`cwtrain.infra.lifecycle` also continue
89
+ through them.
90
+ """
91
+ if isinstance(err, UnavailableError):
92
+ return True
93
+ return (
94
+ isinstance(err, APIError)
95
+ and err.http_status in TRANSIENT_HTTP_STATUSES
96
+ and err.code != "unimplemented"
97
+ )
98
+
99
+
100
+ _CODE_TO_ERROR: dict[str, type[APIError]] = {
101
+ "invalid_argument": InvalidArgumentError,
102
+ "not_found": NotFoundError,
103
+ "already_exists": AlreadyExistsError,
104
+ "failed_precondition": FailedPreconditionError,
105
+ "permission_denied": PermissionDeniedError,
106
+ "unauthenticated": UnauthenticatedError,
107
+ "unavailable": UnavailableError,
108
+ "unimplemented": UnimplementedError,
109
+ }
110
+
111
+ # gRPC numeric codes, for servers that transcode errors as google.rpc.Status.
112
+ _GRPC_CODES: dict[int, str] = {
113
+ 1: "canceled",
114
+ 2: "unknown",
115
+ 3: "invalid_argument",
116
+ 4: "deadline_exceeded",
117
+ 5: "not_found",
118
+ 6: "already_exists",
119
+ 7: "permission_denied",
120
+ 8: "resource_exhausted",
121
+ 9: "failed_precondition",
122
+ 10: "aborted",
123
+ 11: "out_of_range",
124
+ 12: "unimplemented",
125
+ 13: "internal",
126
+ 14: "unavailable",
127
+ 15: "data_loss",
128
+ 16: "unauthenticated",
129
+ }
130
+
131
+ _HTTP_TO_CODE: dict[int, str] = {
132
+ 400: "invalid_argument",
133
+ 401: "unauthenticated",
134
+ 403: "permission_denied",
135
+ 404: "not_found",
136
+ 409: "already_exists",
137
+ 412: "failed_precondition",
138
+ 429: "resource_exhausted",
139
+ 501: "unimplemented",
140
+ 503: "unavailable",
141
+ }
142
+
143
+
144
+ def error_from_response(http_status: int, body: Any) -> APIError:
145
+ """Build the most specific :class:`APIError` for an error response body."""
146
+ code: str | None = None
147
+ message = ""
148
+ details: list[Any] = []
149
+ if isinstance(body, dict):
150
+ raw_code = body.get("code")
151
+ if isinstance(raw_code, int):
152
+ code = _GRPC_CODES.get(raw_code)
153
+ elif isinstance(raw_code, str):
154
+ code = raw_code.lower()
155
+ message = str(body.get("message") or "")
156
+ details = list(body.get("details") or [])
157
+ elif isinstance(body, str):
158
+ message = body
159
+ if not code:
160
+ code = _HTTP_TO_CODE.get(http_status, "internal" if http_status >= 500 else "unknown")
161
+ if not message:
162
+ message = f"HTTP {http_status}"
163
+
164
+ cls = _CODE_TO_ERROR.get(code, APIError)
165
+ if cls is FailedPreconditionError and "already in progress" in message:
166
+ cls = HotLoadInProgressError
167
+ return cls(code, message, http_status=http_status, details=details)