async-task-api-worker 0.1.0rc2__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.
Files changed (40) hide show
  1. async_task_api_worker/__init__.py +42 -0
  2. async_task_api_worker/__main__.py +5 -0
  3. async_task_api_worker/_runtime/__init__.py +1 -0
  4. async_task_api_worker/_runtime/amqp_transport.py +251 -0
  5. async_task_api_worker/_runtime/declare.py +40 -0
  6. async_task_api_worker/_runtime/engine.py +543 -0
  7. async_task_api_worker/_runtime/inflight.py +38 -0
  8. async_task_api_worker/_runtime/lifecycle.py +463 -0
  9. async_task_api_worker/_runtime/log_context.py +20 -0
  10. async_task_api_worker/_runtime/observer.py +78 -0
  11. async_task_api_worker/_runtime/progress.py +143 -0
  12. async_task_api_worker/_runtime/retry.py +29 -0
  13. async_task_api_worker/_runtime/sync_bridge.py +68 -0
  14. async_task_api_worker/_runtime/transport.py +62 -0
  15. async_task_api_worker/_version.py +11 -0
  16. async_task_api_worker/cli.py +88 -0
  17. async_task_api_worker/context.py +70 -0
  18. async_task_api_worker/errors.py +116 -0
  19. async_task_api_worker/observability/__init__.py +1 -0
  20. async_task_api_worker/observability/health.py +299 -0
  21. async_task_api_worker/observability/logging.py +89 -0
  22. async_task_api_worker/observability/loop_lag.py +56 -0
  23. async_task_api_worker/observability/metrics.py +188 -0
  24. async_task_api_worker/protocol/__init__.py +41 -0
  25. async_task_api_worker/protocol/envelope.py +103 -0
  26. async_task_api_worker/protocol/json_types.py +7 -0
  27. async_task_api_worker/protocol/messages.py +121 -0
  28. async_task_api_worker/protocol/version.py +11 -0
  29. async_task_api_worker/py.typed +0 -0
  30. async_task_api_worker/settings.py +127 -0
  31. async_task_api_worker/testing/__init__.py +27 -0
  32. async_task_api_worker/testing/_context.py +93 -0
  33. async_task_api_worker/testing/_harness.py +251 -0
  34. async_task_api_worker/testing/_memory_broker.py +162 -0
  35. async_task_api_worker/worker.py +254 -0
  36. async_task_api_worker-0.1.0rc2.dist-info/METADATA +269 -0
  37. async_task_api_worker-0.1.0rc2.dist-info/RECORD +40 -0
  38. async_task_api_worker-0.1.0rc2.dist-info/WHEEL +4 -0
  39. async_task_api_worker-0.1.0rc2.dist-info/entry_points.txt +2 -0
  40. async_task_api_worker-0.1.0rc2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,143 @@
1
+ """Progress reporting: validation and a time-based throttle.
2
+
3
+ Progress is the **latest state**, not an event stream. Rules:
4
+
5
+ - leading edge: the first update, and any update arriving after ``min_interval_s`` of silence, is published at once;
6
+ - trailing flush: an update arriving sooner is held, a later one replaces it (latest wins), and the survivor is
7
+ published when the interval has elapsed;
8
+ - the fraction stays in ``[0, 1)``: the terminal success or failure message is the 100%;
9
+ - closing the reporter cancels any pending flush and refuses every later update, so no progress is ever published
10
+ after the terminal message.
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import time
16
+ from collections.abc import Awaitable, Callable
17
+ from typing import Final
18
+
19
+ from async_task_api_worker._runtime.observer import ProgressDropReason, RuntimeObserver
20
+ from async_task_api_worker.protocol.json_types import ProgressPayload
21
+ from async_task_api_worker.protocol.messages import InvalidProgressError, MessageEncoder
22
+
23
+ _LOGGER: Final[logging.Logger] = logging.getLogger("async_task_api_worker")
24
+ # A reason that is normal operation, not worth a log line.
25
+ _SILENT_REASONS: Final[frozenset[ProgressDropReason]] = frozenset({ProgressDropReason.COALESCED})
26
+
27
+ type Clock = Callable[[], float]
28
+ type Sleep = Callable[[float], Awaitable[None]]
29
+ type PublishBody = Callable[[bytes], Awaitable[None]]
30
+
31
+
32
+ class ProgressDropLog:
33
+ """Counts every dropped update and warns once per reason per process."""
34
+
35
+ def __init__(self, observer: RuntimeObserver) -> None:
36
+ self._observer = observer
37
+ self._warned: set[ProgressDropReason] = set()
38
+
39
+ def dropped(self, reason: ProgressDropReason, task_id: str) -> None:
40
+ self._observer.progress_dropped(reason)
41
+ if reason in _SILENT_REASONS or reason in self._warned:
42
+ return
43
+ self._warned.add(reason)
44
+ _LOGGER.warning(
45
+ "Progress update dropped (%s), task_id=%s. Further drops of this kind are not logged.",
46
+ reason,
47
+ task_id,
48
+ )
49
+
50
+
51
+ class ProgressReporter:
52
+ """Per-task progress gate. ``publish`` must never raise: a failed progress publish never fails the task."""
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ task_id: str,
58
+ encoder: MessageEncoder,
59
+ publish: PublishBody,
60
+ min_interval_s: float,
61
+ drops: ProgressDropLog,
62
+ clock: Clock = time.monotonic,
63
+ sleep: Sleep = asyncio.sleep,
64
+ ) -> None:
65
+ self._task_id = task_id
66
+ self._encoder = encoder
67
+ self._publish = publish
68
+ self._min_interval_s = min_interval_s
69
+ self._drops = drops
70
+ self._clock = clock
71
+ self._sleep = sleep
72
+ self._lock = asyncio.Lock()
73
+ self._closed = False
74
+ self._high_water = 0.0
75
+ self._last_publish: float | None = None
76
+ self._pending: bytes | None = None
77
+ self._flush_task: asyncio.Task[None] | None = None
78
+
79
+ async def report(self, fraction: float, payload: ProgressPayload | None = None) -> None:
80
+ """Validate, then publish now or hold for the trailing flush. Never raises."""
81
+ if self._closed:
82
+ self._drops.dropped(ProgressDropReason.AFTER_CONCLUSION, self._task_id)
83
+ return
84
+ try:
85
+ body = self._encoder.progress(self._task_id, fraction, payload)
86
+ except InvalidProgressError:
87
+ self._drops.dropped(ProgressDropReason.INVALID_FRACTION, self._task_id)
88
+ return
89
+ except ValueError:
90
+ self._drops.dropped(ProgressDropReason.INVALID_PAYLOAD, self._task_id)
91
+ return
92
+ if fraction < self._high_water:
93
+ self._drops.dropped(ProgressDropReason.NOT_INCREASING, self._task_id)
94
+ return
95
+ self._high_water = fraction
96
+
97
+ now = self._clock()
98
+ if self._flush_task is None and self._is_due(now):
99
+ self._last_publish = now
100
+ await self._publish_now(body)
101
+ return
102
+ if self._pending is not None:
103
+ self._drops.dropped(ProgressDropReason.COALESCED, self._task_id)
104
+ self._pending = body
105
+ if self._flush_task is None:
106
+ self._flush_task = asyncio.get_running_loop().create_task(self._flush_loop())
107
+
108
+ async def close(self) -> None:
109
+ """Cancel the pending flush and wait for a publish in progress: nothing is published afterwards."""
110
+ self._closed = True
111
+ task = self._flush_task
112
+ if task is not None:
113
+ task.cancel()
114
+ await asyncio.wait({task})
115
+ if self._pending is not None:
116
+ self._pending = None
117
+ self._drops.dropped(ProgressDropReason.AFTER_CONCLUSION, self._task_id)
118
+ async with self._lock:
119
+ pass
120
+
121
+ def _is_due(self, now: float) -> bool:
122
+ return self._last_publish is None or now - self._last_publish >= self._min_interval_s
123
+
124
+ async def _publish_now(self, body: bytes) -> None:
125
+ async with self._lock:
126
+ if self._closed:
127
+ self._drops.dropped(ProgressDropReason.AFTER_CONCLUSION, self._task_id)
128
+ return
129
+ await self._publish(body)
130
+
131
+ async def _flush_loop(self) -> None:
132
+ try:
133
+ while self._pending is not None:
134
+ last = self._last_publish if self._last_publish is not None else self._clock()
135
+ delay = last + self._min_interval_s - self._clock()
136
+ if delay > 0:
137
+ await self._sleep(delay)
138
+ body, self._pending = self._pending, None
139
+ assert body is not None # only close() clears it, and close() cancels this task first
140
+ self._last_publish = self._clock()
141
+ await self._publish_now(body)
142
+ finally:
143
+ self._flush_task = None
@@ -0,0 +1,29 @@
1
+ """Attempt numbering and retry backoff."""
2
+
3
+ import random
4
+ from collections.abc import Callable
5
+ from typing import Final
6
+
7
+ from async_task_api_worker._runtime.transport import Delivery
8
+
9
+ _FIRST_ATTEMPT: Final[int] = 1
10
+ _REDELIVERED_ATTEMPT: Final[int] = 2
11
+ # Beyond this the exponential term overflows a float long before it matters: the cap is reached much earlier.
12
+ _MAX_EXPONENT: Final[int] = 60
13
+ _JITTER_LOW: Final[float] = 0.5
14
+ _JITTER_HIGH: Final[float] = 1.0
15
+
16
+ type Uniform = Callable[[float, float], float]
17
+
18
+
19
+ def attempt_of(delivery: Delivery) -> int:
20
+ """1-based delivery attempt: ``x-delivery-count + 1`` when the broker provides it, else a redelivery guess."""
21
+ if delivery.delivery_count is not None:
22
+ return delivery.delivery_count + 1
23
+ return _REDELIVERED_ATTEMPT if delivery.redelivered else _FIRST_ATTEMPT
24
+
25
+
26
+ def backoff_seconds(attempt: int, *, base_s: float, max_s: float, uniform: Uniform = random.uniform) -> float:
27
+ """``min(base * 2**(attempt-1), max) * uniform(0.5, 1.0)``."""
28
+ exponent = min(max(attempt - 1, 0), _MAX_EXPONENT)
29
+ return min(base_s * 2.0**exponent, max_s) * uniform(_JITTER_LOW, _JITTER_HIGH)
@@ -0,0 +1,68 @@
1
+ """Running ``def`` handlers off the event loop, with thread-safe progress and cooperative cancellation."""
2
+
3
+ import asyncio
4
+ import concurrent.futures
5
+ import contextvars
6
+ import logging
7
+ from collections.abc import Callable
8
+ from typing import Final
9
+
10
+ from async_task_api_worker._runtime.progress import ProgressReporter
11
+ from async_task_api_worker.protocol.json_types import ProgressPayload
12
+
13
+ _LOGGER: Final[logging.Logger] = logging.getLogger("async_task_api_worker")
14
+ # A progress publish that takes longer than this is abandoned: the thread must not hang on telemetry.
15
+ PROGRESS_PUBLISH_TIMEOUT_S: Final[float] = 5.0
16
+
17
+ type ThreadSafeProgress = Callable[[float, ProgressPayload | None], None]
18
+
19
+
20
+ class TaskCancelledError(Exception):
21
+ """Raised by ``SyncTaskContext.raise_if_cancelled``. The engine handles it: the task already concluded."""
22
+
23
+
24
+ def make_threadsafe_progress(
25
+ loop: asyncio.AbstractEventLoop,
26
+ reporter: ProgressReporter,
27
+ *,
28
+ timeout_s: float = PROGRESS_PUBLISH_TIMEOUT_S,
29
+ ) -> ThreadSafeProgress:
30
+ """Progress callable for a worker thread: runs on the worker loop, never creates one, never raises."""
31
+
32
+ def report(fraction: float, payload: ProgressPayload | None = None) -> None:
33
+ coroutine = reporter.report(fraction, payload)
34
+ try:
35
+ future = asyncio.run_coroutine_threadsafe(coroutine, loop)
36
+ except RuntimeError:
37
+ coroutine.close()
38
+ _LOGGER.warning("Progress dropped: the event loop is closed.")
39
+ return
40
+ try:
41
+ future.result(timeout=timeout_s)
42
+ except concurrent.futures.TimeoutError:
43
+ future.cancel()
44
+ _LOGGER.warning("Progress dropped: the publish took more than %s s.", timeout_s)
45
+ except Exception:
46
+ _LOGGER.exception("Progress dropped: the publish failed.")
47
+
48
+ return report
49
+
50
+
51
+ class SyncBridge:
52
+ """Dedicated thread pool for sync handlers, sized like the concurrency (never the default executor)."""
53
+
54
+ def __init__(self, max_workers: int) -> None:
55
+ self._executor = concurrent.futures.ThreadPoolExecutor(
56
+ max_workers=max_workers,
57
+ thread_name_prefix="async-task-api-worker",
58
+ )
59
+
60
+ def submit[T](self, loop: asyncio.AbstractEventLoop, call: Callable[[], T]) -> asyncio.Future[T]:
61
+ # ``run_in_executor`` does not carry the context over: the thread runs in a copy of the caller's, so that the
62
+ # task bound for the logs (and anything else a handler's libraries put there) is visible in it.
63
+ context = contextvars.copy_context()
64
+ return loop.run_in_executor(self._executor, context.run, call)
65
+
66
+ def shutdown(self) -> None:
67
+ """Do not wait for threads that are stuck in a handler: the pod restart reclaims them."""
68
+ self._executor.shutdown(wait=False, cancel_futures=True)
@@ -0,0 +1,62 @@
1
+ """The two transport protocols the engine runs on. The broker adapter and the in-memory broker implement them."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Final, Protocol
5
+
6
+ # Closing the connection (or discarding a half-built one) against a broker that is restarting or half-open can wait on
7
+ # the AMQP close handshake: give up after this. The process exit closes the socket and the broker requeues what is
8
+ # unacked.
9
+ CLOSE_TIMEOUT_S: Final[float] = 5.0
10
+
11
+
12
+ class Delivery(Protocol):
13
+ """One message received from the input queue, to be acked or nacked exactly once."""
14
+
15
+ @property
16
+ def body(self) -> bytes: ...
17
+
18
+ @property
19
+ def delivery_count(self) -> int | None:
20
+ """How many times the broker already handed this message out (quorum queues), ``None`` when unknown."""
21
+ ...
22
+
23
+ @property
24
+ def redelivered(self) -> bool: ...
25
+
26
+ async def ack(self) -> None: ...
27
+
28
+ async def nack(self, *, requeue: bool) -> None: ...
29
+
30
+
31
+ class Publisher(Protocol):
32
+ """Publishes on the output queue. Persistent and publisher-confirmed: it raises when the publish failed."""
33
+
34
+ async def publish(self, body: bytes) -> None: ...
35
+
36
+
37
+ type OnDelivery = Callable[[Delivery], None]
38
+
39
+
40
+ class TransportUnavailableError(Exception):
41
+ """The broker cannot be reached (yet): starting the transport may be retried."""
42
+
43
+
44
+ class Transport(Protocol):
45
+ """A broker connection: declares the queues, delivers messages and publishes results."""
46
+
47
+ @property
48
+ def publisher(self) -> Publisher: ...
49
+
50
+ async def start(self, on_delivery: OnDelivery) -> None:
51
+ """Declare the queues, connect and start consuming. Raises :class:`TransportUnavailableError`."""
52
+ ...
53
+
54
+ async def ping(self, timeout_s: float) -> bool:
55
+ """Whether the broker connection is up right now (``False`` before ``start`` and after ``close``)."""
56
+ ...
57
+
58
+ async def stop_consuming(self) -> None:
59
+ """Stop receiving new deliveries (``basic.cancel``). Acks and publishes keep working."""
60
+ ...
61
+
62
+ async def close(self) -> None: ...
@@ -0,0 +1,11 @@
1
+ """Installed distribution version."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ _DISTRIBUTION_NAME = "async-task-api-worker"
6
+ _UNKNOWN_VERSION = "0.0.0+unknown"
7
+
8
+ try:
9
+ __version__: str = version(_DISTRIBUTION_NAME)
10
+ except PackageNotFoundError: # pragma: no cover - only when run from a bare source tree
11
+ __version__ = _UNKNOWN_VERSION
@@ -0,0 +1,88 @@
1
+ """Command line: ``async-task-api-worker run <module:attr>``."""
2
+
3
+ import argparse
4
+ import importlib
5
+ import inspect
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Final
10
+
11
+ from async_task_api_worker._runtime.lifecycle import EXIT_CONFIGURATION, EXIT_FAILURE, EXIT_OK, run_blocking
12
+ from async_task_api_worker.errors import WorkerConfigurationError, WorkerDefinitionError
13
+ from async_task_api_worker.worker import Worker
14
+
15
+ __all__ = ["EXIT_CONFIGURATION", "EXIT_FAILURE", "EXIT_OK", "load_worker", "main"]
16
+
17
+ _PROG: Final[str] = "async-task-api-worker"
18
+ _COMMAND_RUN: Final[str] = "run"
19
+ _TARGET_SEPARATOR: Final[str] = ":"
20
+ _DEFINITION_ERRORS: Final[tuple[type[Exception], ...]] = (WorkerDefinitionError, WorkerConfigurationError)
21
+
22
+
23
+ class TargetError(Exception):
24
+ """The ``module:attr`` target cannot be turned into a worker."""
25
+
26
+
27
+ def _working_directory_first() -> None:
28
+ """Like uvicorn: modules next to where the command is run can be imported."""
29
+ working_directory = str(Path.cwd())
30
+ if working_directory not in sys.path:
31
+ sys.path.insert(0, working_directory)
32
+
33
+
34
+ def load_worker(target: str) -> Worker:
35
+ """Import ``module:attr``: ``attr`` is a ``Worker`` or a zero-argument factory function returning one."""
36
+ module_name, separator, attribute = target.partition(_TARGET_SEPARATOR)
37
+ if not separator or not module_name or not attribute:
38
+ msg = f"Invalid target {target!r}: expected module:attr"
39
+ raise TargetError(msg)
40
+ _working_directory_first()
41
+ try:
42
+ module = importlib.import_module(module_name)
43
+ except ImportError as error:
44
+ msg = f"Cannot import module {module_name!r}: {error}"
45
+ raise TargetError(msg) from error
46
+ except _DEFINITION_ERRORS as error:
47
+ msg = f"Invalid worker definition in module {module_name!r}: {error}"
48
+ raise TargetError(msg) from error
49
+ try:
50
+ candidate = getattr(module, attribute)
51
+ except AttributeError as error:
52
+ msg = f"Module {module_name!r} has no attribute {attribute!r}"
53
+ raise TargetError(msg) from error
54
+ if inspect.isfunction(candidate):
55
+ try:
56
+ candidate = candidate()
57
+ except _DEFINITION_ERRORS as error:
58
+ msg = f"Invalid worker definition in {target!r}: {error}"
59
+ raise TargetError(msg) from error
60
+ except TypeError as error:
61
+ msg = f"{target!r} cannot be called without arguments: {error}"
62
+ raise TargetError(msg) from error
63
+ if not isinstance(candidate, Worker):
64
+ msg = f"{target!r} is neither a Worker nor a zero-argument factory function returning one"
65
+ raise TargetError(msg)
66
+ return candidate
67
+
68
+
69
+ def _parser() -> argparse.ArgumentParser:
70
+ parser = argparse.ArgumentParser(prog=_PROG, description="Tools for AsyncTaskAPI workers.")
71
+ commands = parser.add_subparsers(dest="command", required=True)
72
+ run = commands.add_parser(_COMMAND_RUN, help="run a worker until SIGTERM or SIGINT")
73
+ run.add_argument("target", help="module:attr, a Worker or a zero-argument factory returning one")
74
+ return parser
75
+
76
+
77
+ def main(argv: Sequence[str] | None = None) -> int:
78
+ """Entry point. Exit codes: 0 ok, 1 failure, 2 usage or configuration error."""
79
+ try:
80
+ arguments = _parser().parse_args(argv)
81
+ except SystemExit as error:
82
+ return error.code if isinstance(error.code, int) else EXIT_CONFIGURATION
83
+ try:
84
+ worker = load_worker(arguments.target)
85
+ except TargetError as error:
86
+ sys.stderr.write(f"{_PROG}: {error}\n")
87
+ return EXIT_CONFIGURATION
88
+ return run_blocking(worker)
@@ -0,0 +1,70 @@
1
+ """What a handler receives next to its input: identity, deadline, logger, inputs and progress reporting."""
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+ from collections.abc import Awaitable, Callable, Mapping
7
+ from dataclasses import dataclass, field
8
+
9
+ from async_task_api_worker._runtime.sync_bridge import TaskCancelledError, ThreadSafeProgress
10
+ from async_task_api_worker.protocol.envelope import InputRef
11
+ from async_task_api_worker.protocol.json_types import ProgressPayload
12
+
13
+ type _ReportProgress = Callable[[float, ProgressPayload | None], Awaitable[None]]
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class TaskContext:
18
+ """Context of an ``async def`` handler.
19
+
20
+ ``progress`` publishes the **latest state** of the task, not an event stream: updates are throttled (at most
21
+ one per ``WORKER_PROGRESS_MIN_INTERVAL_S``), a newer update replaces a held one, ``fraction`` must be in
22
+ ``[0, 1)`` and never goes back. The terminal success or failure message is the 100%.
23
+ """
24
+
25
+ task_id: str
26
+ client_id: str | None
27
+ attempt: int
28
+ deadline: float
29
+ logger: logging.LoggerAdapter[logging.Logger]
30
+ inputs: Mapping[str, InputRef]
31
+ _report: _ReportProgress = field(repr=False, compare=False)
32
+
33
+ def remaining(self) -> float:
34
+ """Seconds left before ``deadline``, never negative."""
35
+ return max(0.0, self.deadline - time.monotonic())
36
+
37
+ async def progress(self, fraction: float, payload: ProgressPayload | None = None) -> None:
38
+ """Report progress. Never raises: an invalid or failed update is dropped and counted."""
39
+ await self._report(fraction, payload)
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class SyncTaskContext:
44
+ """Context of a ``def`` handler, which runs in a worker thread. Same fields as :class:`TaskContext`."""
45
+
46
+ task_id: str
47
+ client_id: str | None
48
+ attempt: int
49
+ deadline: float
50
+ logger: logging.LoggerAdapter[logging.Logger]
51
+ inputs: Mapping[str, InputRef]
52
+ _report: ThreadSafeProgress = field(repr=False, compare=False)
53
+ _cancel_event: threading.Event = field(repr=False, compare=False)
54
+
55
+ def remaining(self) -> float:
56
+ """Seconds left before ``deadline``, never negative."""
57
+ return max(0.0, self.deadline - time.monotonic())
58
+
59
+ def progress(self, fraction: float, payload: ProgressPayload | None = None) -> None:
60
+ """Report progress from the thread, with the same rules as :meth:`TaskContext.progress`."""
61
+ self._report(fraction, payload)
62
+
63
+ def cancelled(self) -> bool:
64
+ """True once the task timed out or the worker is shutting down: long loops should stop."""
65
+ return self._cancel_event.is_set()
66
+
67
+ def raise_if_cancelled(self) -> None:
68
+ """Raise the SDK cancellation (handled by the runtime) when :meth:`cancelled` is true."""
69
+ if self.cancelled():
70
+ raise TaskCancelledError
@@ -0,0 +1,116 @@
1
+ """Error types and the stable error codes the SDK itself produces."""
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from enum import StrEnum
6
+ from types import MappingProxyType
7
+ from typing import Final
8
+
9
+ _CODE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$")
10
+ _MIN_MESSAGE_LENGTH: Final[int] = 1
11
+ MAX_MESSAGE_LENGTH: Final[int] = 500
12
+
13
+
14
+ class ErrorCode(StrEnum):
15
+ """Codes produced by the SDK itself. They are stable and documented in the protocol spec."""
16
+
17
+ INPUT_INVALID = "INPUT_INVALID"
18
+ OUTPUT_INVALID = "OUTPUT_INVALID"
19
+ TASK_TIMEOUT = "TASK_TIMEOUT"
20
+ INTERNAL_ERROR = "INTERNAL_ERROR"
21
+ UPSTREAM_UNAVAILABLE = "UPSTREAM_UNAVAILABLE"
22
+ # Same value as ``CODE_ABANDON`` in ``listener/services/rebut_listener.py``.
23
+ TASK_ABANDONED_AFTER_REDELIVERIES = "TASK_ABANDONED_AFTER_REDELIVERIES"
24
+ # Raised by the ``[s3]`` and ``[llm]`` extras.
25
+ DOCUMENT_NOT_FOUND = "DOCUMENT_NOT_FOUND"
26
+ FILE_TOO_LARGE = "FILE_TOO_LARGE"
27
+ LLM_OUTPUT_INVALID = "LLM_OUTPUT_INVALID"
28
+ LLM_OUTPUT_TRUNCATED = "LLM_OUTPUT_TRUNCATED"
29
+ LLM_OUTPUT_FILTERED = "LLM_OUTPUT_FILTERED"
30
+ LLM_REQUEST_REJECTED = "LLM_REQUEST_REJECTED"
31
+
32
+
33
+ # Fixed client-facing text for the codes the SDK emits itself (never derived from an exception).
34
+ DEFAULT_PUBLIC_MESSAGES: Final[Mapping[ErrorCode, str]] = MappingProxyType(
35
+ {
36
+ ErrorCode.INPUT_INVALID: "The task input does not match the service schema.",
37
+ ErrorCode.OUTPUT_INVALID: "The task result does not match the service schema.",
38
+ ErrorCode.TASK_TIMEOUT: "The task exceeded its time limit.",
39
+ ErrorCode.INTERNAL_ERROR: "The worker failed unexpectedly while processing the task.",
40
+ ErrorCode.UPSTREAM_UNAVAILABLE: "A service the task depends on is unavailable.",
41
+ ErrorCode.TASK_ABANDONED_AFTER_REDELIVERIES: "The task was abandoned after several delivery attempts.",
42
+ ErrorCode.DOCUMENT_NOT_FOUND: "The document was not found.",
43
+ ErrorCode.FILE_TOO_LARGE: "The file exceeds the maximum accepted size.",
44
+ ErrorCode.LLM_OUTPUT_INVALID: "The language model returned an unusable answer.",
45
+ ErrorCode.LLM_OUTPUT_TRUNCATED: "The language model answer was truncated.",
46
+ ErrorCode.LLM_OUTPUT_FILTERED: "The language model answer was blocked by a content filter.",
47
+ ErrorCode.LLM_REQUEST_REJECTED: "The language model rejected the request.",
48
+ },
49
+ )
50
+
51
+ # Owned by the broker side (the rebut listener concludes tasks that expired in the queue with it): the SDK never
52
+ # emits it, and a handler must not claim it for a task that did run.
53
+ TASK_EXPIRED_IN_QUEUE: Final[str] = "TASK_EXPIRED_IN_QUEUE"
54
+
55
+ # Codes only the runtime or the broker side may emit: a handler cannot raise a ``WorkerError`` carrying one of them.
56
+ RESERVED_CODES: Final[frozenset[str]] = frozenset(
57
+ {
58
+ ErrorCode.INPUT_INVALID,
59
+ ErrorCode.OUTPUT_INVALID,
60
+ ErrorCode.TASK_TIMEOUT,
61
+ ErrorCode.INTERNAL_ERROR,
62
+ ErrorCode.TASK_ABANDONED_AFTER_REDELIVERIES,
63
+ TASK_EXPIRED_IN_QUEUE,
64
+ },
65
+ )
66
+
67
+
68
+ def validate_public_message(message: str) -> None:
69
+ """Raise ``ValueError`` unless ``message`` is safe to show to a client: one non-empty line, bounded length."""
70
+ if not _MIN_MESSAGE_LENGTH <= len(message) <= MAX_MESSAGE_LENGTH or "\n" in message or "\r" in message:
71
+ msg = f"Error message must be a single line of {_MIN_MESSAGE_LENGTH}..{MAX_MESSAGE_LENGTH} characters"
72
+ raise ValueError(msg)
73
+
74
+
75
+ class WorkerError(Exception):
76
+ """A failure whose code and message are safe to show to the client.
77
+
78
+ Only these two values ever reach the wire: never build the message from ``str(cause)``.
79
+ """
80
+
81
+ def __init__(self, code: str, message: str) -> None:
82
+ if not _CODE_PATTERN.fullmatch(code):
83
+ msg = f"Invalid error code {code!r}: expected {_CODE_PATTERN.pattern}"
84
+ raise ValueError(msg)
85
+ if code in RESERVED_CODES:
86
+ msg = f"Error code {code!r} is reserved for the SDK runtime"
87
+ raise ValueError(msg)
88
+ validate_public_message(message)
89
+ super().__init__(code, message)
90
+ self.code: str = code
91
+ self.public_message: str = message
92
+
93
+ @property
94
+ def wire_text(self) -> str:
95
+ """The ``"<CODE>: <message>"`` text form, for clients that read the failure as a string."""
96
+ return f"{self.code}: {self.public_message}"
97
+
98
+
99
+ class FatalError(WorkerError):
100
+ """Not worth retrying: the failure is published and the delivery acked."""
101
+
102
+
103
+ class RetryableError(WorkerError):
104
+ """Transient: backoff and requeue until the broker's last delivery attempt."""
105
+
106
+
107
+ class ProbeError(Exception):
108
+ """Raised by a readiness probe to signal "not ready", optionally with a safe detail."""
109
+
110
+
111
+ class WorkerConfigurationError(Exception):
112
+ """Invalid settings or budget: the worker refuses to start."""
113
+
114
+
115
+ class WorkerDefinitionError(Exception):
116
+ """Invalid decorator usage, raised at import time."""
@@ -0,0 +1 @@
1
+ """Probes, metrics and logs of a running worker."""