ringo-task-queue 0.1.0.dev0__py3-none-win_amd64.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.
@@ -0,0 +1,507 @@
1
+ """Lifecycle of the SDK-managed embedded Go daemon (ADR 0003).
2
+
3
+ The daemon is spawned as an asyncio subprocess in ``embedded`` mode with both
4
+ ownership signals required by the frozen CLI contract: ``--parent-pid`` set to
5
+ the current process and ``--lifecycle-stdin`` with the child's stdin kept
6
+ open for the daemon's whole lifetime. Readiness is a single JSON line on
7
+ stdout carrying the loopback endpoint and protocol version; stderr is
8
+ collected asynchronously into a bounded ring buffer for diagnostics.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ from collections import deque
19
+ from pathlib import Path
20
+ from typing import Deque, TextIO
21
+
22
+ from ._version import (
23
+ PROTOCOL_MAJOR,
24
+ PROTOCOL_MINOR,
25
+ SCHEMA_VERSION,
26
+ daemon_version_for_sdk,
27
+ )
28
+ from .binary import DAEMON_NAME, BinaryMetadata, packaged_binary
29
+ from .errors import ConflictError, IncompatibleVersionError, RingoError
30
+
31
+ DEFAULT_STARTUP_TIMEOUT = 15.0
32
+ DEFAULT_STOP_TIMEOUT = 10.0
33
+ _STDERR_TAIL_LINES = 200
34
+ _MAX_VERSION_PROBE_OUTPUT = 1024 * 1024
35
+
36
+ _EXE_NAMES = (
37
+ ("ringo-task-queue.exe", "ringo-task-queue")
38
+ if sys.platform == "win32"
39
+ else (
40
+ "ringo-task-queue",
41
+ "ringo-task-queue.exe",
42
+ )
43
+ )
44
+
45
+
46
+ def _resolve_daemon(
47
+ explicit: str | os.PathLike[str] | None = None,
48
+ ) -> tuple[Path, BinaryMetadata | None]:
49
+ """Resolve a daemon and optional packaged metadata.
50
+
51
+ Explicit and environment overrides intentionally win over package
52
+ resources. Overrides are development artifacts and therefore have no
53
+ required manifest; packaged resources are always checksum validated.
54
+ """
55
+ if explicit:
56
+ path = Path(explicit)
57
+ if not path.is_file() or path.is_symlink():
58
+ raise RingoError(f"daemon binary is not a regular file: {path}")
59
+ return path, None
60
+
61
+ env = os.environ.get("RINGO_DAEMON_PATH")
62
+ if env:
63
+ path = Path(env)
64
+ if not path.is_file() or path.is_symlink():
65
+ raise RingoError(f"RINGO_DAEMON_PATH is not a regular file: {path}")
66
+ return path, None
67
+
68
+ # Packaged platform resource (only present in platform wheels). A
69
+ # manifest is the marker that this is a platform wheel; malformed or
70
+ # mismatched package data must fail loudly instead of falling through to a
71
+ # random development binary.
72
+ try:
73
+ from importlib import resources
74
+
75
+ package_bin = Path(str(resources.files("ringo_task_queue") / "bin"))
76
+ if not (package_bin / "manifest.json").is_file():
77
+ raise FileNotFoundError(package_bin / "manifest.json")
78
+ return packaged_binary()
79
+ except (FileNotFoundError, ModuleNotFoundError):
80
+ pass
81
+
82
+ # Development tree: search this file's ancestors for the binary, which
83
+ # covers both the repo root and ``.tmp/bin`` checkouts.
84
+ here = Path(__file__).resolve()
85
+ for parent in here.parents:
86
+ for name in _EXE_NAMES:
87
+ candidate = parent / name
88
+ if candidate.is_file():
89
+ return candidate, None
90
+ nested = parent / ".tmp" / "bin"
91
+ for name in _EXE_NAMES:
92
+ candidate = nested / name
93
+ if candidate.is_file():
94
+ return candidate, None
95
+ raise RingoError(
96
+ "could not locate the ringo-task-queue daemon binary; pass"
97
+ " daemon_path=..., set RINGO_DAEMON_PATH, or install a platform wheel"
98
+ )
99
+
100
+
101
+ def resolve_daemon_path(explicit: str | os.PathLike[str] | None = None) -> Path:
102
+ """Locate and (for packaged resources) verify the daemon binary."""
103
+ return _resolve_daemon(explicit)[0]
104
+
105
+
106
+ async def _read_bounded_probe_stream(
107
+ stream: asyncio.StreamReader | None,
108
+ ) -> bytes:
109
+ if stream is None:
110
+ return b""
111
+ result = bytearray()
112
+ while chunk := await stream.read(64 * 1024):
113
+ if len(result) + len(chunk) > _MAX_VERSION_PROBE_OUTPUT:
114
+ raise IncompatibleVersionError(
115
+ f"daemon version probe output exceeds {_MAX_VERSION_PROBE_OUTPUT} bytes"
116
+ )
117
+ result.extend(chunk)
118
+ return bytes(result)
119
+
120
+
121
+ async def _probe_binary_version(
122
+ binary: Path, timeout: float, *, allow_legacy: bool = True
123
+ ) -> dict[str, object]:
124
+ """Run the daemon's read-only version contract before spawning it.
125
+
126
+ Source-tree development overrides may still use the historical text line.
127
+ Verified wheel resources must return the release ``version --json`` object.
128
+ """
129
+ proc: asyncio.subprocess.Process | None = None
130
+ try:
131
+ proc = await asyncio.create_subprocess_exec(
132
+ str(binary),
133
+ "version",
134
+ "--json",
135
+ stdout=asyncio.subprocess.PIPE,
136
+ stderr=asyncio.subprocess.PIPE,
137
+ )
138
+ stdout_task = asyncio.create_task(_read_bounded_probe_stream(proc.stdout))
139
+ stderr_task = asyncio.create_task(_read_bounded_probe_stream(proc.stderr))
140
+ stdout, stderr, _ = await asyncio.wait_for(
141
+ asyncio.gather(stdout_task, stderr_task, proc.wait()), timeout=timeout
142
+ )
143
+ except BaseException as exc:
144
+ if proc is not None and proc.returncode is None:
145
+ try:
146
+ proc.kill()
147
+ except OSError:
148
+ pass
149
+ try:
150
+ await asyncio.shield(asyncio.wait_for(proc.wait(), timeout=5.0))
151
+ except (asyncio.CancelledError, asyncio.TimeoutError, OSError):
152
+ pass
153
+ if isinstance(exc, asyncio.CancelledError):
154
+ raise
155
+ if isinstance(exc, (OSError, asyncio.TimeoutError)):
156
+ raise IncompatibleVersionError(
157
+ f"cannot probe daemon compatibility at {binary}: {exc}"
158
+ ) from exc
159
+ raise
160
+ if proc.returncode != 0:
161
+ detail = stderr.decode("utf-8", errors="replace").strip()
162
+ raise IncompatibleVersionError(
163
+ f"daemon version probe failed (code {proc.returncode}): {detail}"
164
+ )
165
+ try:
166
+ result = json.loads(stdout.decode("utf-8"))
167
+ except (ValueError, UnicodeDecodeError) as exc:
168
+ if not allow_legacy:
169
+ raise IncompatibleVersionError(
170
+ f"packaged daemon version probe returned invalid JSON: {stdout!r}"
171
+ ) from exc
172
+ # Development binaries built before the JSON contract may only emit
173
+ # the documented human-readable line. Preserve source-tree override
174
+ # compatibility while release wheels fail closed above.
175
+ legacy = re.fullmatch(
176
+ r"(?P<name>\S+) (?P<version>\S+) protocol=(?P<major>\d+)\.(?P<minor>\d+) schema=(?P<schema>\d+)(?:.*)",
177
+ stdout.decode("utf-8", errors="replace").strip(),
178
+ )
179
+ if legacy is None:
180
+ raise IncompatibleVersionError(
181
+ f"daemon version probe returned invalid JSON: {stdout!r}"
182
+ ) from exc
183
+ result = {
184
+ "name": legacy.group("name"),
185
+ "version": legacy.group("version"),
186
+ "protocolMajor": int(legacy.group("major")),
187
+ "protocolMinor": int(legacy.group("minor")),
188
+ "schemaVersion": int(legacy.group("schema")),
189
+ }
190
+ if not isinstance(result, dict):
191
+ raise IncompatibleVersionError("daemon version probe returned a non-object")
192
+ return result
193
+
194
+
195
+ class LocalDaemon:
196
+ """A spawned embedded daemon subprocess owned by this process."""
197
+
198
+ def __init__(
199
+ self,
200
+ data_dir: str | os.PathLike[str],
201
+ *,
202
+ storage: str = "sqlite",
203
+ postgres_dsn: str | None = None,
204
+ daemon_path: str | os.PathLike[str] | None = None,
205
+ startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
206
+ stop_timeout: float = DEFAULT_STOP_TIMEOUT,
207
+ ) -> None:
208
+ self.data_dir = Path(data_dir)
209
+ self._storage = storage
210
+ self._postgres_dsn = postgres_dsn
211
+ self._daemon_path = daemon_path
212
+ self._binary_metadata: BinaryMetadata | None = None
213
+ self._startup_timeout = startup_timeout
214
+ self._stop_timeout = stop_timeout
215
+ self._proc: asyncio.subprocess.Process | None = None
216
+ self._stderr_task: asyncio.Task | None = None
217
+ self._stderr_tail: Deque[str] = deque(maxlen=_STDERR_TAIL_LINES)
218
+ self._log_file: TextIO | None = None
219
+ self.endpoint: str | None = None
220
+ self.pid: int | None = None
221
+ self.daemon_version: str | None = None
222
+
223
+ @property
224
+ def is_dead(self) -> bool:
225
+ proc = self._proc
226
+ return proc is None or proc.returncode is not None
227
+
228
+ @property
229
+ def stderr_tail(self) -> list[str]:
230
+ return list(self._stderr_tail)
231
+
232
+ async def start(self) -> str:
233
+ """Spawn the daemon and return its loopback gRPC endpoint."""
234
+ if self._proc is not None and not self.is_dead:
235
+ raise RingoError("daemon already started")
236
+ if self._proc is not None:
237
+ # A previous daemon died; reap its helpers before respawning.
238
+ await self._finish_stderr()
239
+ self._proc = None
240
+ binary, self._binary_metadata = _resolve_daemon(self._daemon_path)
241
+ version_info = await _probe_binary_version(
242
+ binary,
243
+ self._startup_timeout,
244
+ allow_legacy=self._binary_metadata is None,
245
+ )
246
+ self._validate_version_info(version_info, self._binary_metadata, binary)
247
+ try:
248
+ self.data_dir.mkdir(parents=True, exist_ok=True)
249
+ except OSError as exc:
250
+ raise RingoError(
251
+ f"cannot create data directory {self.data_dir}: {exc}"
252
+ ) from exc
253
+
254
+ command = [
255
+ str(binary),
256
+ "embedded",
257
+ "--data-dir",
258
+ str(self.data_dir),
259
+ "--listen",
260
+ "127.0.0.1:0",
261
+ "--storage",
262
+ self._storage,
263
+ "--parent-pid",
264
+ str(os.getpid()),
265
+ "--lifecycle-stdin",
266
+ ]
267
+ child_env = None
268
+ if self._postgres_dsn is not None:
269
+ child_env = dict(os.environ)
270
+ child_env["RINGO_POSTGRES_DSN"] = self._postgres_dsn
271
+ try:
272
+ self._proc = await asyncio.create_subprocess_exec(
273
+ *command,
274
+ env=child_env,
275
+ stdin=asyncio.subprocess.PIPE,
276
+ stdout=asyncio.subprocess.PIPE,
277
+ stderr=asyncio.subprocess.PIPE,
278
+ )
279
+ except OSError as exc:
280
+ raise RingoError(f"failed to spawn daemon {binary}: {exc}") from exc
281
+
282
+ try:
283
+ self._log_file = open(
284
+ self.data_dir / "ringo-daemon.stderr.log", "a", encoding="utf-8"
285
+ )
286
+ except OSError:
287
+ self._log_file = None
288
+ self._stderr_task = asyncio.create_task(self._collect_stderr())
289
+ try:
290
+ ready = await self._await_ready()
291
+ endpoint = self._validate_ready(ready)
292
+ self._validate_binary_compatibility(ready, self._binary_metadata, binary)
293
+ except BaseException:
294
+ await self._kill_and_reap()
295
+ raise
296
+
297
+ self.endpoint = endpoint
298
+ self.pid = ready["pid"]
299
+ self.daemon_version = ready["version"]
300
+ return endpoint
301
+
302
+ @staticmethod
303
+ def _validate_ready(ready: object) -> str:
304
+ """Validate the readiness JSON shape before trusting any field."""
305
+ if not isinstance(ready, dict):
306
+ raise RingoError(f"malformed daemon readiness payload: {ready!r}")
307
+ endpoint = ready.get("endpoint")
308
+ if not isinstance(endpoint, str) or not endpoint:
309
+ raise RingoError(f"daemon readiness missing endpoint: {ready!r}")
310
+ match = re.fullmatch(r"(?:\[([^]]+)\]|([^:]+)):(\d+)", endpoint)
311
+ host = (match.group(1) or match.group(2)) if match else ""
312
+ port = int(match.group(3)) if match else 0
313
+ if host not in ("127.0.0.1", "::1", "localhost") or not 1 <= port <= 65535:
314
+ raise RingoError(
315
+ f"embedded daemon must announce a loopback endpoint with a positive port, got {endpoint!r}"
316
+ )
317
+ for key in ("pid", "protocolMajor", "protocolMinor", "schemaVersion"):
318
+ value = ready.get(key)
319
+ if type(value) is not int or value < 0 or (key == "pid" and value == 0):
320
+ raise RingoError(
321
+ f"daemon readiness field {key!r} is missing or invalid: {ready!r}"
322
+ )
323
+ if not isinstance(ready.get("version"), str):
324
+ raise RingoError(
325
+ f"daemon readiness field 'version' is missing or not a string: {ready!r}"
326
+ )
327
+ return endpoint
328
+
329
+ @staticmethod
330
+ def _validate_version_info(
331
+ info: dict[str, object], metadata: BinaryMetadata | None, binary: Path
332
+ ) -> None:
333
+ if info.get("name") != DAEMON_NAME:
334
+ raise IncompatibleVersionError(
335
+ f"unexpected daemon name from {binary}: {info.get('name')!r}"
336
+ )
337
+ version = info.get("version")
338
+ major = info.get("protocolMajor")
339
+ minor = info.get("protocolMinor")
340
+ schema = info.get("schemaVersion")
341
+ if (
342
+ not isinstance(version, str)
343
+ or not version
344
+ or not isinstance(major, int)
345
+ or not isinstance(minor, int)
346
+ or not isinstance(schema, int)
347
+ ):
348
+ raise IncompatibleVersionError(
349
+ f"daemon version probe is missing compatibility fields: {info!r}"
350
+ )
351
+ expected_version = daemon_version_for_sdk()
352
+ if version != expected_version:
353
+ raise IncompatibleVersionError(
354
+ f"daemon at {binary} reports version {version!r};"
355
+ f" SDK requires {expected_version!r}"
356
+ )
357
+ if major != PROTOCOL_MAJOR or minor != PROTOCOL_MINOR or schema != SCHEMA_VERSION:
358
+ raise IncompatibleVersionError(
359
+ f"daemon at {binary} reports protocol {major}.{minor}, schema {schema};"
360
+ f" SDK requires protocol {PROTOCOL_MAJOR}.{PROTOCOL_MINOR} and schema {SCHEMA_VERSION}"
361
+ )
362
+ if metadata is not None and (
363
+ version != metadata.version
364
+ or major != metadata.protocol_major
365
+ or minor != metadata.protocol_minor
366
+ ):
367
+ raise IncompatibleVersionError(
368
+ f"daemon version probe disagrees with manifest for {binary}: {info!r}"
369
+ )
370
+
371
+ @staticmethod
372
+ def _validate_binary_compatibility(
373
+ ready: dict, metadata: BinaryMetadata | None, binary: Path
374
+ ) -> None:
375
+ """Check readiness against SDK and, when packaged, manifest metadata."""
376
+ if (
377
+ ready["protocolMajor"] != PROTOCOL_MAJOR
378
+ or ready["protocolMinor"] != PROTOCOL_MINOR
379
+ or ready["schemaVersion"] != SCHEMA_VERSION
380
+ ):
381
+ raise IncompatibleVersionError(
382
+ f"daemon at {binary} speaks protocol {ready['protocolMajor']}.{ready['protocolMinor']}, "
383
+ f"schema {ready['schemaVersion']}; this SDK requires protocol "
384
+ f"{PROTOCOL_MAJOR}.{PROTOCOL_MINOR} and schema {SCHEMA_VERSION}"
385
+ )
386
+ if metadata is not None:
387
+ if ready["version"] != metadata.version:
388
+ raise IncompatibleVersionError(
389
+ f"daemon at {binary} reports version {ready['version']!r};"
390
+ f" manifest requires {metadata.version!r}"
391
+ )
392
+ if (
393
+ ready["protocolMajor"] != metadata.protocol_major
394
+ or ready["protocolMinor"] != metadata.protocol_minor
395
+ ):
396
+ raise IncompatibleVersionError(
397
+ f"daemon at {binary} reports protocol "
398
+ f"{ready['protocolMajor']}.{ready['protocolMinor']};"
399
+ f" manifest requires {metadata.protocol_major}.{metadata.protocol_minor}"
400
+ )
401
+
402
+ async def _await_ready(self) -> dict:
403
+ assert self._proc is not None and self._proc.stdout is not None
404
+ try:
405
+ line = await asyncio.wait_for(
406
+ self._proc.stdout.readline(), timeout=self._startup_timeout
407
+ )
408
+ except asyncio.TimeoutError as exc:
409
+ tail = "\n".join(self.stderr_tail[-20:])
410
+ raise RingoError(
411
+ f"daemon did not become ready within {self._startup_timeout:.0f}s;"
412
+ f" stderr tail:\n{tail}"
413
+ ) from exc
414
+ if not line:
415
+ # Process exited before announcing readiness.
416
+ returncode = await self._proc.wait()
417
+ if self._stderr_task is not None:
418
+ await self._stderr_task
419
+ tail = "\n".join(self.stderr_tail[-20:])
420
+ message = (
421
+ f"daemon exited during startup (code {returncode});"
422
+ f" stderr tail:\n{tail}"
423
+ )
424
+ if "conflict:" in tail.lower():
425
+ raise ConflictError(message)
426
+ raise RingoError(message)
427
+ try:
428
+ return json.loads(line.decode("utf-8", errors="replace"))
429
+ except ValueError as exc:
430
+ raise RingoError(f"malformed daemon readiness line: {line!r}") from exc
431
+
432
+ async def _collect_stderr(self) -> None:
433
+ assert self._proc is not None and self._proc.stderr is not None
434
+ while True:
435
+ try:
436
+ line = await self._proc.stderr.readline()
437
+ except Exception:
438
+ return
439
+ if not line:
440
+ return
441
+ text = line.decode("utf-8", errors="replace").rstrip("\r\n")
442
+ self._stderr_tail.append(text)
443
+ log = self._log_file
444
+ if log is not None:
445
+ try:
446
+ log.write(text + "\n")
447
+ log.flush()
448
+ except OSError:
449
+ pass
450
+
451
+ async def stop(self) -> None:
452
+ """Close the lifecycle pipe and wait for exit, escalating if needed."""
453
+ proc, self._proc = self._proc, None
454
+ if proc is None:
455
+ return
456
+ if proc.returncode is not None:
457
+ await self._finish_stderr()
458
+ return
459
+ # Closing stdin (the lifecycle pipe) starts the daemon's graceful
460
+ # shutdown; the daemon owns its own bounded grace period.
461
+ if proc.stdin is not None:
462
+ try:
463
+ proc.stdin.close()
464
+ await proc.stdin.wait_closed()
465
+ except (BrokenPipeError, ConnectionResetError, OSError):
466
+ pass
467
+ try:
468
+ await asyncio.wait_for(proc.wait(), timeout=self._stop_timeout)
469
+ except asyncio.TimeoutError:
470
+ proc.terminate()
471
+ try:
472
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
473
+ except asyncio.TimeoutError:
474
+ proc.kill()
475
+ try:
476
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
477
+ except asyncio.TimeoutError as exc: # pragma: no cover - OS fault
478
+ raise RingoError("daemon did not exit after kill") from exc
479
+ finally:
480
+ await self._finish_stderr()
481
+
482
+ async def _finish_stderr(self) -> None:
483
+ task, self._stderr_task = self._stderr_task, None
484
+ if task is not None:
485
+ task.cancel()
486
+ try:
487
+ await task
488
+ except (asyncio.CancelledError, Exception):
489
+ pass
490
+ log, self._log_file = self._log_file, None
491
+ if log is not None:
492
+ try:
493
+ log.close()
494
+ except OSError:
495
+ pass
496
+
497
+ async def _kill_and_reap(self) -> None:
498
+ proc, self._proc = self._proc, None
499
+ if proc is None:
500
+ return
501
+ if proc.returncode is None:
502
+ proc.kill()
503
+ try:
504
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
505
+ except asyncio.TimeoutError:
506
+ pass
507
+ await self._finish_stderr()
@@ -0,0 +1,152 @@
1
+ """Public exception hierarchy for the Ringo Task Queue Python SDK.
2
+
3
+ All SDK transport failures derive from :class:`RingoError` and carry one of
4
+ the stable public codes from ``docs/contracts/errors.md``. gRPC status
5
+ details (``ringo.v1.ErrorDetail``) are preferred over the coarser gRPC code,
6
+ exactly as the error contract requires.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Mapping
12
+
13
+ import grpc
14
+ from grpc_status import rpc_status
15
+
16
+ from ._proto.ringo.v1 import queue_pb2
17
+
18
+
19
+ class RingoError(Exception):
20
+ """Base class for every SDK-raised transport/protocol error."""
21
+
22
+ #: Stable public code (see docs/contracts/errors.md).
23
+ code = "INTERNAL"
24
+
25
+ def __init__(
26
+ self,
27
+ message: str = "",
28
+ *,
29
+ request_id: str | None = None,
30
+ metadata: Mapping[str, str] | None = None,
31
+ grpc_code: grpc.StatusCode | None = None,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.message = message
35
+ self.request_id = request_id
36
+ self.metadata = dict(metadata) if metadata else {}
37
+ self.grpc_code = grpc_code
38
+
39
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
40
+ return f"{type(self).__name__}(code={self.code!r}, message={self.message!r})"
41
+
42
+
43
+ class InvalidArgumentError(RingoError):
44
+ code = "INVALID_ARGUMENT"
45
+
46
+
47
+ class DuplicateError(RingoError):
48
+ code = "DUPLICATE"
49
+
50
+
51
+ class NotFoundError(RingoError):
52
+ code = "NOT_FOUND"
53
+
54
+
55
+ class ConflictError(RingoError):
56
+ code = "CONFLICT"
57
+
58
+
59
+ class LeaseLostError(RingoError):
60
+ code = "LEASE_LOST"
61
+
62
+
63
+ class UnavailableError(RingoError):
64
+ code = "UNAVAILABLE"
65
+
66
+
67
+ class DeadlineExceededError(RingoError):
68
+ code = "DEADLINE_EXCEEDED"
69
+
70
+
71
+ class IncompatibleVersionError(RingoError):
72
+ code = "INCOMPATIBLE_VERSION"
73
+
74
+
75
+ class InternalError(RingoError):
76
+ code = "INTERNAL"
77
+
78
+
79
+ _CODE_TO_ERROR: dict[int, type[RingoError]] = {
80
+ queue_pb2.ERROR_CODE_INVALID_ARGUMENT: InvalidArgumentError,
81
+ queue_pb2.ERROR_CODE_DUPLICATE: DuplicateError,
82
+ queue_pb2.ERROR_CODE_NOT_FOUND: NotFoundError,
83
+ queue_pb2.ERROR_CODE_CONFLICT: ConflictError,
84
+ queue_pb2.ERROR_CODE_LEASE_LOST: LeaseLostError,
85
+ queue_pb2.ERROR_CODE_UNAVAILABLE: UnavailableError,
86
+ queue_pb2.ERROR_CODE_DEADLINE_EXCEEDED: DeadlineExceededError,
87
+ queue_pb2.ERROR_CODE_INCOMPATIBLE_VERSION: IncompatibleVersionError,
88
+ queue_pb2.ERROR_CODE_INTERNAL: InternalError,
89
+ }
90
+
91
+ # gRPC codes that map unambiguously onto a single public Ringo code. Other
92
+ # gRPC codes raise the base RingoError without inventing a more specific
93
+ # public code (docs/contracts/errors.md).
94
+ _GRPC_FALLBACK: dict[grpc.StatusCode, type[RingoError]] = {
95
+ grpc.StatusCode.INVALID_ARGUMENT: InvalidArgumentError,
96
+ grpc.StatusCode.NOT_FOUND: NotFoundError,
97
+ grpc.StatusCode.ALREADY_EXISTS: DuplicateError,
98
+ grpc.StatusCode.ABORTED: LeaseLostError,
99
+ grpc.StatusCode.UNAVAILABLE: UnavailableError,
100
+ grpc.StatusCode.DEADLINE_EXCEEDED: DeadlineExceededError,
101
+ grpc.StatusCode.INTERNAL: InternalError,
102
+ }
103
+
104
+
105
+ def error_from_detail(detail: queue_pb2.ErrorDetail) -> RingoError:
106
+ """Build the mapped exception from a ``ringo.v1.ErrorDetail`` message."""
107
+ cls = _CODE_TO_ERROR.get(detail.code, RingoError)
108
+ return cls(
109
+ detail.message,
110
+ request_id=detail.request_id or None,
111
+ metadata=detail.metadata,
112
+ )
113
+
114
+
115
+ def map_rpc_error(exc: grpc.aio.AioRpcError) -> RingoError:
116
+ """Map a gRPC call failure to the stable public exception.
117
+
118
+ Prefers the ``ringo.v1.ErrorDetail`` serialized in ``google.rpc.Status``
119
+ details; falls back to the gRPC code only when details are unavailable.
120
+ """
121
+ rich = None
122
+ try:
123
+ rich = rpc_status.from_call(exc)
124
+ except Exception:
125
+ rich = None
126
+ if rich is not None:
127
+ for any_msg in rich.details:
128
+ if any_msg.Is(queue_pb2.ErrorDetail.DESCRIPTOR):
129
+ detail = queue_pb2.ErrorDetail()
130
+ any_msg.Unpack(detail)
131
+ err = error_from_detail(detail)
132
+ err.grpc_code = exc.code()
133
+ return err
134
+ cls = _GRPC_FALLBACK.get(exc.code(), RingoError)
135
+ message = exc.details() or str(exc)
136
+ return cls(message, grpc_code=exc.code())
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Handler signalling errors (execution outcomes, not transport errors).
141
+
142
+
143
+ class RetryTaskError(Exception):
144
+ """Raise from a handler to retry with an optional per-attempt delay."""
145
+
146
+ def __init__(self, message: str = "", *, delay: float | None = None) -> None:
147
+ super().__init__(message)
148
+ self.delay = delay
149
+
150
+
151
+ class PermanentTaskError(Exception):
152
+ """Raise from a handler to mark the task permanently failed (DEAD)."""