async-task-api-worker 0.1.0rc2__tar.gz → 0.1.0rc3__tar.gz

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 (41) hide show
  1. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/CHANGELOG.md +9 -0
  2. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/PKG-INFO +6 -1
  3. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/README.md +5 -0
  4. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/pyproject.toml +1 -1
  5. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/amqp_transport.py +8 -0
  6. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/lifecycle.py +22 -2
  7. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/cli.py +16 -1
  8. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/errors.py +19 -0
  9. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/settings.py +9 -3
  10. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/.gitignore +0 -0
  11. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/LICENSE +0 -0
  12. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/__init__.py +0 -0
  13. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/__main__.py +0 -0
  14. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/__init__.py +0 -0
  15. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/declare.py +0 -0
  16. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/engine.py +0 -0
  17. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/inflight.py +0 -0
  18. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/log_context.py +0 -0
  19. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/observer.py +0 -0
  20. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/progress.py +0 -0
  21. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/retry.py +0 -0
  22. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/sync_bridge.py +0 -0
  23. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/transport.py +0 -0
  24. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_version.py +0 -0
  25. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/context.py +0 -0
  26. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/__init__.py +0 -0
  27. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/health.py +0 -0
  28. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/logging.py +0 -0
  29. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/loop_lag.py +0 -0
  30. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/metrics.py +0 -0
  31. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/__init__.py +0 -0
  32. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/envelope.py +0 -0
  33. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/json_types.py +0 -0
  34. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/messages.py +0 -0
  35. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/version.py +0 -0
  36. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/py.typed +0 -0
  37. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/testing/__init__.py +0 -0
  38. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/testing/_context.py +0 -0
  39. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/testing/_harness.py +0 -0
  40. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/testing/_memory_broker.py +0 -0
  41. {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/worker.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.0-rc.3](https://github.com/IA-Generative/async-api/compare/async-task-api-worker-v0.1.0-rc.2...async-task-api-worker-v0.1.0-rc.3) (2026-10-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **sdk:** close an already closed channel quietly and tie the close budgets together ([51e0281](https://github.com/IA-Generative/async-api/commit/51e0281dfe7ea22686f0967d4c8ce55efa470d0e))
9
+ * **sdk:** name the invalid settings instead of quoting pydantic's text about them ([df6c747](https://github.com/IA-Generative/async-api/commit/df6c7475464600f31f767997269d3f86b1cf432e))
10
+ * **sdk:** treat a pydantic validation error in a startup hook as a configuration error ([5e98af1](https://github.com/IA-Generative/async-api/commit/5e98af109a027fc2903a3f98d5d0028f936efbba))
11
+
3
12
  ## [0.1.0-rc.2](https://github.com/IA-Generative/async-api/compare/async-task-api-worker-v0.1.0-rc.1...async-task-api-worker-v0.1.0-rc.2) (2026-10-23)
4
13
 
5
14
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: async-task-api-worker
3
- Version: 0.1.0-rc.2
3
+ Version: 0.1.0-rc.3
4
4
  Summary: SDK to write workers for the AsyncTaskAPI platform: protocol models, runtime and conformance kit.
5
5
  Project-URL: Repository, https://github.com/IA-Generative/async-api
6
6
  License: MIT
@@ -77,6 +77,11 @@ process is stopped and `await worker.serve()` serves from an existing event loop
77
77
  - The settings are read and validated first: an invalid configuration, or a task timeout that does not fit under the
78
78
  broker's consumer timeout (`task timeout + WORKER_RETRY_BACKOFF_MAX_S + 60 s < WORKER_CONSUMER_TIMEOUT_S`), exits with
79
79
  code 2 before any connection. Exit codes: 0 clean stop, 1 failure, 2 configuration or usage error.
80
+ - A pydantic `ValidationError` raised by a startup hook (a settings class of your own, say) is a configuration error too:
81
+ one line naming the invalid fields, never their values (and `<Model> (a model-level check failed)` for a check that
82
+ belongs to no field), exit code 2. In job mode that fails the Job without a retry. So a startup hook must only validate
83
+ **configuration**: a hook that validates external data (a downloaded document, a response) must catch its own errors and
84
+ raise a `FatalError` or `RetryableError` from the task, where the task-level error model applies.
80
85
  - The queues are declared durable and with no arguments (the broker decides their type and policies), and consumed with
81
86
  manual acknowledgements: a task is acked only after its result is published.
82
87
  - SIGTERM or SIGINT stops consuming, lets the in-flight tasks finish within `WORKER_SHUTDOWN_GRACE_S` (default 25 s),
@@ -56,6 +56,11 @@ process is stopped and `await worker.serve()` serves from an existing event loop
56
56
  - The settings are read and validated first: an invalid configuration, or a task timeout that does not fit under the
57
57
  broker's consumer timeout (`task timeout + WORKER_RETRY_BACKOFF_MAX_S + 60 s < WORKER_CONSUMER_TIMEOUT_S`), exits with
58
58
  code 2 before any connection. Exit codes: 0 clean stop, 1 failure, 2 configuration or usage error.
59
+ - A pydantic `ValidationError` raised by a startup hook (a settings class of your own, say) is a configuration error too:
60
+ one line naming the invalid fields, never their values (and `<Model> (a model-level check failed)` for a check that
61
+ belongs to no field), exit code 2. In job mode that fails the Job without a retry. So a startup hook must only validate
62
+ **configuration**: a hook that validates external data (a downloaded document, a response) must catch its own errors and
63
+ raise a `FatalError` or `RetryableError` from the task, where the task-level error model applies.
59
64
  - The queues are declared durable and with no arguments (the broker decides their type and policies), and consumed with
60
65
  manual acknowledgements: a task is acked only after its result is published.
61
66
  - SIGTERM or SIGINT stops consuming, lets the in-flight tasks finish within `WORKER_SHUTDOWN_GRACE_S` (default 25 s),
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "async-task-api-worker"
7
- version = "0.1.0-rc.2"
7
+ version = "0.1.0-rc.3"
8
8
  description = "SDK to write workers for the AsyncTaskAPI platform: protocol models, runtime and conformance kit."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -217,6 +217,8 @@ class AmqpTransport:
217
217
  self._queue = None
218
218
  if connection is None:
219
219
  return
220
+ # ``close_timeout_s`` (the channels' share plus the connection's) is the whole budget of this method, and it
221
+ # must stay within the lifecycle's own bound on ``close()``: both default to ``CLOSE_TIMEOUT_S``.
220
222
  channels_timeout_s = self._close_timeout_s * _CHANNELS_SHARE
221
223
  try:
222
224
  async with asyncio.timeout(channels_timeout_s):
@@ -231,8 +233,14 @@ class AmqpTransport:
231
233
 
232
234
  @staticmethod
233
235
  async def _close_channel(channel: AbstractChannel) -> None:
236
+ if channel.is_closed:
237
+ # A robust channel reports itself closed while the connection reconnects: nothing to close or wait for.
238
+ _LOGGER.debug("A channel was already closed (an outage, most likely).")
239
+ return
234
240
  try:
235
241
  await channel.close()
242
+ except ChannelInvalidStateError:
243
+ _LOGGER.debug("A channel became invalid while closing (an outage, most likely).")
236
244
  except Exception:
237
245
  _LOGGER.warning("Closing a channel failed: closing the connection anyway.", exc_info=True)
238
246
 
@@ -28,9 +28,15 @@ from concurrent.futures import ThreadPoolExecutor
28
28
  from enum import Enum
29
29
  from typing import TYPE_CHECKING, Final, NoReturn
30
30
 
31
+ from pydantic import ValidationError
32
+
31
33
  from async_task_api_worker._runtime.engine import Engine
32
34
  from async_task_api_worker._runtime.transport import CLOSE_TIMEOUT_S, Delivery, Transport, TransportUnavailableError
33
- from async_task_api_worker.errors import WorkerConfigurationError, WorkerDefinitionError
35
+ from async_task_api_worker.errors import (
36
+ WorkerConfigurationError,
37
+ WorkerDefinitionError,
38
+ configuration_error_from_validation,
39
+ )
34
40
  from async_task_api_worker.observability.health import HealthServer, RuntimeState
35
41
  from async_task_api_worker.observability.logging import configure_logging
36
42
  from async_task_api_worker.observability.loop_lag import EventLoopLagProbe
@@ -137,7 +143,7 @@ class Lifecycle:
137
143
  await health.start()
138
144
  loop_lag.start()
139
145
  for hook in self._worker._startup_hooks:
140
- if not await self._until_stopped(hook()):
146
+ if not await self._run_startup_hook(hook):
141
147
  break # stopped during startup: nothing was consumed, drain and exit
142
148
  started_hooks += 1
143
149
  if started_hooks == len(self._worker._startup_hooks) and await self._connect(settings):
@@ -226,6 +232,20 @@ class Lifecycle:
226
232
  return True
227
233
  return False
228
234
 
235
+ async def _run_startup_hook(self, hook: Callable[[], Awaitable[None]]) -> bool:
236
+ """Run a startup hook. False when a stop came first.
237
+
238
+ A pydantic ``ValidationError`` from a hook (a settings class of its own, say) is a configuration error: one
239
+ line, exit code 2, and no value of the input in the message nor chained to it.
240
+ """
241
+ configuration_error: WorkerConfigurationError | None = None
242
+ try:
243
+ return await self._until_stopped(hook())
244
+ except ValidationError as error:
245
+ configuration_error = configuration_error_from_validation(error)
246
+ assert configuration_error is not None
247
+ raise configuration_error
248
+
229
249
  async def _until_stopped(self, work: Awaitable[object]) -> bool:
230
250
  """Run ``work``, cancelling it if a stop is requested first. False when it was cancelled by the stop.
231
251
 
@@ -8,8 +8,14 @@ from collections.abc import Sequence
8
8
  from pathlib import Path
9
9
  from typing import Final
10
10
 
11
+ from pydantic import ValidationError
12
+
11
13
  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
14
+ from async_task_api_worker.errors import (
15
+ WorkerConfigurationError,
16
+ WorkerDefinitionError,
17
+ configuration_error_from_validation,
18
+ )
13
19
  from async_task_api_worker.worker import Worker
14
20
 
15
21
  __all__ = ["EXIT_CONFIGURATION", "EXIT_FAILURE", "EXIT_OK", "load_worker", "main"]
@@ -31,6 +37,11 @@ def _working_directory_first() -> None:
31
37
  sys.path.insert(0, working_directory)
32
38
 
33
39
 
40
+ def _configuration_target_error(where: str, error: ValidationError) -> TargetError:
41
+ """A pydantic ``ValidationError`` while loading is a configuration error: field names, never their values."""
42
+ return TargetError(f"Invalid worker configuration in {where}: {configuration_error_from_validation(error)}")
43
+
44
+
34
45
  def load_worker(target: str) -> Worker:
35
46
  """Import ``module:attr``: ``attr`` is a ``Worker`` or a zero-argument factory function returning one."""
36
47
  module_name, separator, attribute = target.partition(_TARGET_SEPARATOR)
@@ -46,6 +57,8 @@ def load_worker(target: str) -> Worker:
46
57
  except _DEFINITION_ERRORS as error:
47
58
  msg = f"Invalid worker definition in module {module_name!r}: {error}"
48
59
  raise TargetError(msg) from error
60
+ except ValidationError as error:
61
+ raise _configuration_target_error(f"module {module_name!r}", error) from None
49
62
  try:
50
63
  candidate = getattr(module, attribute)
51
64
  except AttributeError as error:
@@ -57,6 +70,8 @@ def load_worker(target: str) -> Worker:
57
70
  except _DEFINITION_ERRORS as error:
58
71
  msg = f"Invalid worker definition in {target!r}: {error}"
59
72
  raise TargetError(msg) from error
73
+ except ValidationError as error:
74
+ raise _configuration_target_error(repr(target), error) from None
60
75
  except TypeError as error:
61
76
  msg = f"{target!r} cannot be called without arguments: {error}"
62
77
  raise TargetError(msg) from error
@@ -6,6 +6,8 @@ from enum import StrEnum
6
6
  from types import MappingProxyType
7
7
  from typing import Final
8
8
 
9
+ from pydantic import ValidationError
10
+
9
11
  _CODE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$")
10
12
  _MIN_MESSAGE_LENGTH: Final[int] = 1
11
13
  MAX_MESSAGE_LENGTH: Final[int] = 500
@@ -114,3 +116,20 @@ class WorkerConfigurationError(Exception):
114
116
 
115
117
  class WorkerDefinitionError(Exception):
116
118
  """Invalid decorator usage, raised at import time."""
119
+
120
+
121
+ def configuration_error_from_validation(error: ValidationError) -> WorkerConfigurationError:
122
+ """A configuration error for a pydantic ``ValidationError`` raised while setting the worker up (a hook, say).
123
+
124
+ The message names the invalid fields (``loc[0]``) and nothing else, never a value and never pydantic's ``msg``:
125
+ ``input`` can hold a whole settings dict, secrets included, and a validator written by the user can format the very
126
+ value it rejects. An issue with no location (a model-level check) is reported as a fixed text with the name of the
127
+ model (``error.title``). The caller adds the prefix. Build the error outside an ``except`` block and raise it
128
+ ``from None``, so that the original one, with its ``input``, is not chained to it.
129
+ """
130
+ issues = error.errors(include_input=False, include_url=False, include_context=False)
131
+ names = list(dict.fromkeys(str(issue["loc"][0]) for issue in issues if issue["loc"]))
132
+ parts = [", ".join(names)] if names else []
133
+ if any(not issue["loc"] for issue in issues):
134
+ parts.append(f"{error.title} (a model-level check failed)")
135
+ return WorkerConfigurationError("; ".join(parts))
@@ -8,6 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
8
8
 
9
9
  from async_task_api_worker.errors import WorkerConfigurationError
10
10
 
11
+ _MISSING_TYPE: Final[str] = "missing"
11
12
  DEFAULT_WORKER_CONCURRENCY: Final[int] = 1
12
13
  DEFAULT_HEALTH_CHECK_HOST: Final[str] = "0.0.0.0" # noqa: S104 - the probe must be reachable from the pod network
13
14
  DEFAULT_HEALTH_CHECK_PORT: Final[int] = 8083
@@ -116,12 +117,17 @@ class WorkerSettings(BaseSettings):
116
117
  try:
117
118
  return handler(data)
118
119
  except ValidationError as error:
119
- # Never echo ``input`` or the raw error: BROKER_URL carries credentials.
120
+ # THE RULE: the message is built from field names, never from a value or from pydantic's text about one. A
121
+ # field-level ``msg`` can quote the input (a validator that formats it, BROKER_URL above all, which carries
122
+ # credentials), so for those only the setting name and whether it is missing are said. A model-level ``msg``
123
+ # (no location) is the text of one of OUR validators, written without a value. That is deliberate: the
124
+ # user's validators, handled by ``configuration_error_from_validation``, can format theirs, so that one
125
+ # never uses a ``msg``.
120
126
  problems = "; ".join(
121
- f"{location}: {issue['msg']}"
127
+ f"{location} is {'missing' if issue['type'] == _MISSING_TYPE else 'invalid'}"
122
128
  if (location := ".".join(map(str, issue["loc"])).upper())
123
129
  else issue["msg"]
124
- for issue in error.errors()
130
+ for issue in error.errors(include_input=False, include_url=False, include_context=False)
125
131
  )
126
132
  msg = f"Invalid worker settings: {problems}"
127
133
  raise WorkerConfigurationError(msg) from None