labtasker-client 2.0.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.
labtasker/client.py ADDED
@@ -0,0 +1,725 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import sys
5
+ import time
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import TypeVar
9
+
10
+ import httpx
11
+ from pydantic import TypeAdapter, ValidationError
12
+
13
+ from labtasker.config import ResolvedConfig, resolve_config
14
+ from labtasker.errors import APIError, TransportError
15
+ from labtasker.local import (
16
+ ensure_local_server,
17
+ local_paths,
18
+ require_local_capabilities,
19
+ socket_transport,
20
+ )
21
+ from labtasker.models import (
22
+ BulkUpdateResult,
23
+ ClaimResponse,
24
+ CountResponse,
25
+ HealthResponse,
26
+ HeartbeatResponse,
27
+ Queue,
28
+ ResponseModel,
29
+ Task,
30
+ TaskPage,
31
+ )
32
+ from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
33
+ from labtasker.validation import (
34
+ RequestValidationError,
35
+ validate_filter,
36
+ validate_identifier,
37
+ validate_int64,
38
+ validate_json_object,
39
+ validate_order_field,
40
+ validate_routes,
41
+ validate_run_id,
42
+ validate_status,
43
+ validate_task_id,
44
+ validate_task_name,
45
+ validate_task_update,
46
+ validate_unicode_scalar,
47
+ )
48
+
49
+ T = TypeVar("T")
50
+ ModelT = TypeVar("ModelT", bound=ResponseModel)
51
+ REQUEST_TIMEOUT_SECONDS = 10.0
52
+ MAX_RETRY_ATTEMPTS = 3
53
+ RETRY_BACKOFF_SECONDS = (0.05, 0.1)
54
+ QUEUE_LIST_ADAPTER = TypeAdapter(list[Queue])
55
+
56
+
57
+ class Client:
58
+ def __init__(
59
+ self,
60
+ url: str | None = None,
61
+ token: str | None = None,
62
+ queue: str | None = None,
63
+ ) -> None:
64
+ self._initialize(resolve_config(url=url, token=token, queue=queue))
65
+
66
+ @classmethod
67
+ def _from_local_directory(cls, directory: Path, *, queue: str) -> Client:
68
+ require_local_capabilities()
69
+ client = cls.__new__(cls)
70
+ client._initialize(
71
+ ResolvedConfig(
72
+ url=None,
73
+ queue=validate_identifier(queue, field="queue"),
74
+ token=None,
75
+ local=local_paths(directory),
76
+ )
77
+ )
78
+ return client
79
+
80
+ def _initialize(self, config: ResolvedConfig) -> None:
81
+ self._config = config
82
+ headers = {}
83
+ if self._config.token is not None:
84
+ headers["Authorization"] = f"Bearer {self._config.token}"
85
+ if self._config.local is None:
86
+ assert self._config.url is not None
87
+ self._http = httpx.Client(
88
+ base_url=f"{self._config.url}/api/v2/",
89
+ headers=headers,
90
+ timeout=REQUEST_TIMEOUT_SECONDS,
91
+ )
92
+ else:
93
+ self._http = httpx.Client(
94
+ base_url="http://labtasker/api/v2/",
95
+ transport=socket_transport(self._config.local),
96
+ timeout=REQUEST_TIMEOUT_SECONDS,
97
+ )
98
+ self._closed = False
99
+ self._endpoint_announced = False
100
+ self._local_ready = False
101
+
102
+ def __enter__(self) -> Client:
103
+ self._ensure_open()
104
+ return self
105
+
106
+ def __exit__(self, *_: object) -> None:
107
+ self.close()
108
+
109
+ def close(self) -> None:
110
+ if self._closed:
111
+ return
112
+ self._closed = True
113
+ self._http.close()
114
+
115
+ @property
116
+ def configuration(self) -> ResolvedConfig:
117
+ return self._config
118
+
119
+ def submit_task(
120
+ self,
121
+ args: dict[str, JSONValue] | None = None,
122
+ *,
123
+ name: str | None = None,
124
+ metadata: dict[str, JSONValue] | None = None,
125
+ priority: int = 0,
126
+ max_attempts: int = 3,
127
+ routes: list[str] | None = None,
128
+ task_id: str | None = None,
129
+ queue: str | None = None,
130
+ ) -> Task:
131
+ self._ensure_open()
132
+ normalized_args = validate_json_object({} if args is None else args, field="args")
133
+ normalized_metadata = validate_json_object(
134
+ {} if metadata is None else metadata,
135
+ field="metadata",
136
+ )
137
+ normalized_routes = validate_routes(["default"] if routes is None else routes)
138
+ body: dict[str, object] = {
139
+ "name": validate_task_name(name),
140
+ "args": normalized_args,
141
+ "metadata": normalized_metadata,
142
+ "priority": validate_int64(priority, field="priority"),
143
+ "max_attempts": validate_int64(
144
+ max_attempts,
145
+ field="max_attempts",
146
+ positive=True,
147
+ ),
148
+ "routes": normalized_routes,
149
+ }
150
+ queue_name = self._queue(queue)
151
+ if task_id is not None:
152
+ selected_id = validate_task_id(task_id)
153
+ return self._submit_with_id(queue_name, selected_id, body)
154
+
155
+ for _ in range(MAX_RETRY_ATTEMPTS):
156
+ selected_id = _generate_task_id()
157
+ try:
158
+ return self._submit_with_id(queue_name, selected_id, body)
159
+ except APIError as error:
160
+ if error.code != "task_id_conflict":
161
+ raise
162
+ raise TransportError(
163
+ "Could not allocate a unique Task ID.",
164
+ {"operation": "submit_task", **self._operation_endpoint_details},
165
+ )
166
+
167
+ def get_task(self, task_id: str, *, queue: str | None = None) -> Task:
168
+ self._ensure_open()
169
+ queue_name = self._queue(queue)
170
+ task_id = validate_task_id(task_id)
171
+ return self._call(
172
+ operation="get_task",
173
+ method="GET",
174
+ path=f"queues/{queue_name}/tasks/{task_id}",
175
+ parser=lambda response: _parse_model(response, Task, {200}),
176
+ retry=True,
177
+ )
178
+
179
+ def list_tasks(
180
+ self,
181
+ *,
182
+ status: TaskStatus | None = None,
183
+ name: str | None = None,
184
+ filter: str | None = None,
185
+ order_by: TaskOrderField = "created_at",
186
+ descending: bool = True,
187
+ limit: int = 100,
188
+ cursor: str | None = None,
189
+ queue: str | None = None,
190
+ ) -> TaskPage:
191
+ self._ensure_open()
192
+ queue_name = self._queue(queue)
193
+ status = validate_status(status)
194
+ order_by = validate_order_field(order_by)
195
+ filter = validate_filter(filter)
196
+ if not isinstance(descending, bool):
197
+ raise RequestValidationError("descending must be a Boolean.")
198
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
199
+ raise RequestValidationError("limit must be an integer from 1 through 1000.")
200
+ if name is not None and not isinstance(name, str):
201
+ raise RequestValidationError("name selector must be a string or None.")
202
+ if cursor is not None and not isinstance(cursor, str):
203
+ raise RequestValidationError("cursor must be a string or None.")
204
+ params = _without_none(
205
+ {
206
+ "status": status,
207
+ "name": name,
208
+ "filter": filter,
209
+ "order_by": order_by,
210
+ "descending": "true" if descending else "false",
211
+ "limit": limit,
212
+ "cursor": cursor,
213
+ }
214
+ )
215
+ return self._call(
216
+ operation="list_tasks",
217
+ method="GET",
218
+ path=f"queues/{queue_name}/tasks",
219
+ params=params,
220
+ parser=lambda response: _parse_model(response, TaskPage, {200}),
221
+ retry=True,
222
+ )
223
+
224
+ def count_tasks(
225
+ self,
226
+ *,
227
+ status: TaskStatus | None = None,
228
+ name: str | None = None,
229
+ filter: str | None = None,
230
+ queue: str | None = None,
231
+ ) -> int:
232
+ self._ensure_open()
233
+ queue_name = self._queue(queue)
234
+ status = validate_status(status)
235
+ filter = validate_filter(filter)
236
+ if name is not None and not isinstance(name, str):
237
+ raise RequestValidationError("name selector must be a string or None.")
238
+ result = self._call(
239
+ operation="count_tasks",
240
+ method="GET",
241
+ path=f"queues/{queue_name}/tasks/count",
242
+ params=_without_none({"status": status, "name": name, "filter": filter}),
243
+ parser=lambda response: _parse_model(response, CountResponse, {200}),
244
+ retry=True,
245
+ )
246
+ return result.count
247
+
248
+ def update_task(
249
+ self,
250
+ task_id: str,
251
+ changes: TaskUpdate,
252
+ *,
253
+ queue: str | None = None,
254
+ ) -> Task:
255
+ self._ensure_open()
256
+ queue_name = self._queue(queue)
257
+ task_id = validate_task_id(task_id)
258
+ normalized = validate_task_update(changes)
259
+ return self._call(
260
+ operation="update_task",
261
+ method="PATCH",
262
+ path=f"queues/{queue_name}/tasks/{task_id}",
263
+ json=normalized,
264
+ parser=lambda response: _parse_model(response, Task, {200}),
265
+ )
266
+
267
+ def update_tasks(
268
+ self,
269
+ *,
270
+ filter: str,
271
+ changes: TaskUpdate,
272
+ queue: str | None = None,
273
+ ) -> BulkUpdateResult:
274
+ self._ensure_open()
275
+ queue_name = self._queue(queue)
276
+ normalized_filter = validate_filter(filter, required=True)
277
+ normalized_changes = validate_task_update(changes)
278
+ return self._call(
279
+ operation="update_tasks",
280
+ method="PATCH",
281
+ path=f"queues/{queue_name}/tasks",
282
+ json={"filter": normalized_filter, "changes": normalized_changes},
283
+ parser=lambda response: _parse_model(response, BulkUpdateResult, {200}),
284
+ )
285
+
286
+ def cancel_task(self, task_id: str, *, queue: str | None = None) -> Task:
287
+ return self._task_action("cancel", task_id, queue=queue)
288
+
289
+ def requeue_task(self, task_id: str, *, queue: str | None = None) -> Task:
290
+ return self._task_action("requeue", task_id, queue=queue)
291
+
292
+ def delete_task(self, task_id: str, *, queue: str | None = None) -> None:
293
+ self._ensure_open()
294
+ queue_name = self._queue(queue)
295
+ task_id = validate_task_id(task_id)
296
+ self._call(
297
+ operation="delete_task",
298
+ method="DELETE",
299
+ path=f"queues/{queue_name}/tasks/{task_id}",
300
+ parser=lambda response: _parse_none(response, {204}),
301
+ )
302
+
303
+ def create_queue(self, name: str) -> Queue:
304
+ self._ensure_open()
305
+ name = validate_identifier(name, field="queue")
306
+ return self._call(
307
+ operation="create_queue",
308
+ method="PUT",
309
+ path=f"queues/{name}",
310
+ parser=lambda response: _parse_model(response, Queue, {200, 201}),
311
+ )
312
+
313
+ def list_queues(self) -> list[Queue]:
314
+ self._ensure_open()
315
+ return self._call(
316
+ operation="list_queues",
317
+ method="GET",
318
+ path="queues",
319
+ parser=lambda response: _parse_queue_list(response, {200}),
320
+ retry=True,
321
+ )
322
+
323
+ def delete_queue(self, name: str, *, cascade: bool = False) -> None:
324
+ self._ensure_open()
325
+ name = validate_identifier(name, field="queue")
326
+ if not isinstance(cascade, bool):
327
+ raise RequestValidationError("cascade must be a Boolean.")
328
+ self._call(
329
+ operation="delete_queue",
330
+ method="DELETE",
331
+ path=f"queues/{name}",
332
+ params={"cascade": "true" if cascade else "false"},
333
+ parser=lambda response: _parse_none(response, {204}),
334
+ )
335
+
336
+ def _claim(
337
+ self,
338
+ *,
339
+ route: str,
340
+ run_id: str,
341
+ queue: str | None = None,
342
+ ) -> ClaimResponse | None:
343
+ """Claim one Task using a caller-generated idempotency token."""
344
+ self._ensure_open()
345
+ queue_name = self._queue(queue)
346
+ normalized_route = validate_identifier(route, field="route")
347
+ normalized_run_id = validate_run_id(run_id)
348
+ return self._call(
349
+ operation="claim_task",
350
+ method="POST",
351
+ path=f"queues/{queue_name}/tasks/claim",
352
+ json={"route": normalized_route, "run_id": normalized_run_id},
353
+ parser=_parse_claim,
354
+ retry=True,
355
+ )
356
+
357
+ def _health(self) -> HealthResponse:
358
+ """Validate the deployment health and v2 protocol before Worker claims."""
359
+ return self._call(
360
+ operation="worker_health",
361
+ method="GET",
362
+ path=(
363
+ f"{self._config.url}/health"
364
+ if self._config.url is not None
365
+ else "http://labtasker/health"
366
+ ),
367
+ parser=lambda response: _parse_model(response, HealthResponse, {200}),
368
+ retry=True,
369
+ )
370
+
371
+ def _heartbeat(
372
+ self,
373
+ *,
374
+ task_id: str,
375
+ run_id: str,
376
+ queue: str | None = None,
377
+ ) -> HeartbeatResponse:
378
+ return self._run_action(
379
+ "heartbeat",
380
+ task_id=task_id,
381
+ run_id=run_id,
382
+ queue=queue,
383
+ body={},
384
+ parser=lambda response: _parse_model(response, HeartbeatResponse, {200}),
385
+ )
386
+
387
+ def _complete(
388
+ self,
389
+ *,
390
+ task_id: str,
391
+ run_id: str,
392
+ result: dict[str, JSONValue],
393
+ queue: str | None = None,
394
+ ) -> None:
395
+ normalized = validate_json_object(result, field="result")
396
+ self._run_action(
397
+ "complete",
398
+ task_id=task_id,
399
+ run_id=run_id,
400
+ queue=queue,
401
+ body={"result": normalized},
402
+ parser=lambda response: _parse_none(response, {204}),
403
+ )
404
+
405
+ def _fail(
406
+ self,
407
+ *,
408
+ task_id: str,
409
+ run_id: str,
410
+ error_type: str,
411
+ message: str,
412
+ traceback: str | None,
413
+ queue: str | None = None,
414
+ ) -> None:
415
+ error = {
416
+ "type": validate_unicode_scalar(error_type, field="error.type"),
417
+ "message": validate_unicode_scalar(message, field="error.message"),
418
+ "traceback": (
419
+ None
420
+ if traceback is None
421
+ else validate_unicode_scalar(traceback, field="error.traceback")
422
+ ),
423
+ }
424
+ self._run_action(
425
+ "fail",
426
+ task_id=task_id,
427
+ run_id=run_id,
428
+ queue=queue,
429
+ body={"error": error},
430
+ parser=lambda response: _parse_none(response, {204}),
431
+ )
432
+
433
+ def _unclaim(
434
+ self,
435
+ *,
436
+ task_id: str,
437
+ run_id: str,
438
+ queue: str | None = None,
439
+ ) -> None:
440
+ self._run_action(
441
+ "unclaim",
442
+ task_id=task_id,
443
+ run_id=run_id,
444
+ queue=queue,
445
+ body={},
446
+ parser=lambda response: _parse_none(response, {204}),
447
+ )
448
+
449
+ def _submit_with_id(
450
+ self,
451
+ queue: str,
452
+ task_id: str,
453
+ body: dict[str, object],
454
+ ) -> Task:
455
+ return self._call(
456
+ operation="submit_task",
457
+ method="PUT",
458
+ path=f"queues/{queue}/tasks/{task_id}",
459
+ json=body,
460
+ parser=lambda response: _parse_model(response, Task, {200, 201}),
461
+ retry=True,
462
+ )
463
+
464
+ def _task_action(self, action: str, task_id: str, *, queue: str | None) -> Task:
465
+ self._ensure_open()
466
+ queue_name = self._queue(queue)
467
+ task_id = validate_task_id(task_id)
468
+ return self._call(
469
+ operation=f"{action}_task",
470
+ method="POST",
471
+ path=f"queues/{queue_name}/tasks/{task_id}/{action}",
472
+ parser=lambda response: _parse_model(response, Task, {200}),
473
+ )
474
+
475
+ def _run_action(
476
+ self,
477
+ action: str,
478
+ *,
479
+ task_id: str,
480
+ run_id: str,
481
+ queue: str | None,
482
+ body: dict[str, object],
483
+ parser: Callable[[httpx.Response], T],
484
+ ) -> T:
485
+ self._ensure_open()
486
+ queue_name = self._queue(queue)
487
+ normalized_task_id = validate_task_id(task_id)
488
+ normalized_run_id = validate_run_id(run_id)
489
+ return self._call(
490
+ operation=f"{action}_run",
491
+ method="POST",
492
+ path=f"queues/{queue_name}/tasks/{normalized_task_id}/{action}",
493
+ json={"run_id": normalized_run_id, **body},
494
+ parser=parser,
495
+ )
496
+
497
+ def _queue(self, queue: str | None) -> str:
498
+ return validate_identifier(self._config.queue if queue is None else queue, field="queue")
499
+
500
+ def _call(
501
+ self,
502
+ *,
503
+ operation: str,
504
+ method: str,
505
+ path: str,
506
+ parser: Callable[[httpx.Response], T],
507
+ json: object | None = None,
508
+ params: dict[str, str | int] | None = None,
509
+ retry: bool = False,
510
+ ) -> T:
511
+ self._ensure_open()
512
+ self._prepare_endpoint()
513
+ attempts = MAX_RETRY_ATTEMPTS if retry else 1
514
+ last_transport_error: TransportError | None = None
515
+ local_connect_recovery_used = False
516
+ attempt = 0
517
+ while attempt < attempts:
518
+ try:
519
+ response = self._http.request(method, path, json=json, params=params)
520
+ except httpx.RequestError as error:
521
+ last_transport_error = self._connection_error(operation)
522
+ can_recover_local_connect = (
523
+ self._config.local is not None
524
+ and isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout))
525
+ and not local_connect_recovery_used
526
+ )
527
+ if can_recover_local_connect:
528
+ local_connect_recovery_used = True
529
+ self._local_ready = False
530
+ self._ensure_local_available()
531
+ if attempt + 1 == attempts:
532
+ attempts += 1
533
+ elif attempt + 1 == attempts:
534
+ raise last_transport_error from error
535
+ else:
536
+ self._announce_http_endpoint()
537
+ if response.is_error:
538
+ try:
539
+ api_error = _parse_api_error(response)
540
+ except TransportError as error:
541
+ last_transport_error = _with_operation(
542
+ error,
543
+ operation,
544
+ self._operation_endpoint_details,
545
+ )
546
+ if attempt + 1 == attempts:
547
+ raise last_transport_error from error
548
+ _backoff(attempt)
549
+ attempt += 1
550
+ continue
551
+ if retry and api_error.code == "database_busy" and attempt + 1 < attempts:
552
+ _backoff(attempt)
553
+ attempt += 1
554
+ continue
555
+ raise api_error
556
+ try:
557
+ return parser(response)
558
+ except TransportError as error:
559
+ last_transport_error = _with_operation(
560
+ error, operation, self._operation_endpoint_details
561
+ )
562
+ if attempt + 1 == attempts:
563
+ raise last_transport_error from error
564
+ _backoff(attempt)
565
+ attempt += 1
566
+ if last_transport_error is None:
567
+ raise AssertionError("Request loop ended without a result or error.")
568
+ raise last_transport_error
569
+
570
+ def _ensure_open(self) -> None:
571
+ if self._closed:
572
+ raise RuntimeError("Client is closed.")
573
+
574
+ @property
575
+ def _operation_endpoint_details(self) -> dict[str, object]:
576
+ if self._config.local is None:
577
+ return {"url": self._config.url}
578
+ return {
579
+ "directory": str(self._config.local.directory),
580
+ "socket": str(self._config.local.socket),
581
+ }
582
+
583
+ def _prepare_endpoint(self) -> None:
584
+ if self._config.local is not None and not self._local_ready:
585
+ self._ensure_local_available()
586
+
587
+ def _announce_http_endpoint(self) -> None:
588
+ if self._endpoint_announced or self._config.local is not None:
589
+ return
590
+ assert self._config.url is not None
591
+ transport = self._config.url.partition(":")[0]
592
+ print(
593
+ f"[labtasker] connected server=remote transport={transport} url={self._config.url}",
594
+ file=sys.stderr,
595
+ )
596
+ self._endpoint_announced = True
597
+
598
+ def _ensure_local_available(self) -> None:
599
+ paths = self._config.local
600
+ if paths is None:
601
+ return
602
+ result = ensure_local_server(paths, emit=self._emit_local_transition)
603
+ pid = result.pid if result.pid is not None else "unknown"
604
+ version = result.server_version if result.server_version is not None else "unknown"
605
+ print(
606
+ "[labtasker] connected server=local transport=unix "
607
+ f"directory={paths.directory} database={paths.database} socket={paths.socket} "
608
+ f"pid={pid} version={version}",
609
+ file=sys.stderr,
610
+ )
611
+ self._endpoint_announced = True
612
+ self._local_ready = True
613
+
614
+ @staticmethod
615
+ def _emit_local_transition(message: str) -> None:
616
+ print(f"[labtasker] {message}", file=sys.stderr)
617
+
618
+ def _connection_error(self, operation: str) -> TransportError:
619
+ details: dict[str, object] = {"operation": operation}
620
+ if self._config.local is None:
621
+ details["url"] = self._config.url
622
+ else:
623
+ details.update(
624
+ {
625
+ "state": "unhealthy",
626
+ "directory": str(self._config.local.directory),
627
+ "database": str(self._config.local.database),
628
+ "socket": str(self._config.local.socket),
629
+ "log": str(self._config.local.log),
630
+ }
631
+ )
632
+ return TransportError("The Labtasker Server could not be reached.", details)
633
+
634
+
635
+ def _parse_model(
636
+ response: httpx.Response,
637
+ model: type[ModelT],
638
+ statuses: set[int],
639
+ ) -> ModelT:
640
+ _require_status(response, statuses)
641
+ try:
642
+ return model.model_validate_json(response.content, strict=True)
643
+ except (ValidationError, ValueError, TypeError) as error:
644
+ raise TransportError(
645
+ "The Server returned an invalid success response.",
646
+ {"http_status": response.status_code},
647
+ ) from error
648
+
649
+
650
+ def _parse_queue_list(response: httpx.Response, statuses: set[int]) -> list[Queue]:
651
+ _require_status(response, statuses)
652
+ try:
653
+ return QUEUE_LIST_ADAPTER.validate_json(response.content, strict=True)
654
+ except (ValidationError, ValueError, TypeError) as error:
655
+ raise TransportError(
656
+ "The Server returned an invalid Queue list.",
657
+ {"http_status": response.status_code},
658
+ ) from error
659
+
660
+
661
+ def _parse_none(response: httpx.Response, statuses: set[int]) -> None:
662
+ _require_status(response, statuses)
663
+ if response.content:
664
+ raise TransportError(
665
+ "The Server returned an unexpected response body.",
666
+ {"http_status": response.status_code},
667
+ )
668
+
669
+
670
+ def _parse_claim(response: httpx.Response) -> ClaimResponse | None:
671
+ if response.status_code == 204:
672
+ _parse_none(response, {204})
673
+ return None
674
+ return _parse_model(response, ClaimResponse, {200})
675
+
676
+
677
+ def _require_status(response: httpx.Response, statuses: set[int]) -> None:
678
+ if response.status_code not in statuses:
679
+ raise TransportError(
680
+ "The Server returned an unexpected success status.",
681
+ {"http_status": response.status_code},
682
+ )
683
+
684
+
685
+ def _parse_api_error(response: httpx.Response) -> APIError:
686
+ try:
687
+ payload = response.json()
688
+ if not isinstance(payload, dict) or set(payload) != {"error"}:
689
+ raise ValueError
690
+ error = payload["error"]
691
+ if not isinstance(error, dict) or set(error) != {"code", "message", "details"}:
692
+ raise ValueError
693
+ code = error["code"]
694
+ message = error["message"]
695
+ details = error["details"]
696
+ if not isinstance(code, str) or not isinstance(message, str):
697
+ raise ValueError
698
+ normalized_details = validate_json_object(details, field="error.details")
699
+ except (ValueError, TypeError) as error:
700
+ raise TransportError(
701
+ "The Server returned an invalid error response.",
702
+ {"http_status": response.status_code},
703
+ ) from error
704
+ return APIError(response.status_code, code, message, normalized_details)
705
+
706
+
707
+ def _with_operation(
708
+ error: TransportError,
709
+ operation: str,
710
+ endpoint: dict[str, object],
711
+ ) -> TransportError:
712
+ return TransportError(error.message, {**error.details, "operation": operation, **endpoint})
713
+
714
+
715
+ def _generate_task_id() -> str:
716
+ return f"t_{secrets.token_urlsafe(9)}"
717
+
718
+
719
+ def _without_none(values: dict[str, str | int | None]) -> dict[str, str | int]:
720
+ return {key: value for key, value in values.items() if value is not None}
721
+
722
+
723
+ def _backoff(attempt: int) -> None:
724
+ if attempt < len(RETRY_BACKOFF_SECONDS):
725
+ time.sleep(RETRY_BACKOFF_SECONDS[attempt])