taskiq-llm-queue 0.1.0a1__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 (65) hide show
  1. taskiq_llm_queue/__init__.py +138 -0
  2. taskiq_llm_queue/_logging.py +40 -0
  3. taskiq_llm_queue/_version.py +5 -0
  4. taskiq_llm_queue/broker/__init__.py +86 -0
  5. taskiq_llm_queue/broker/acker.py +239 -0
  6. taskiq_llm_queue/broker/batching.py +188 -0
  7. taskiq_llm_queue/broker/broker.py +937 -0
  8. taskiq_llm_queue/broker/delivery.py +50 -0
  9. taskiq_llm_queue/broker/errors.py +178 -0
  10. taskiq_llm_queue/broker/events.py +51 -0
  11. taskiq_llm_queue/broker/filters.py +32 -0
  12. taskiq_llm_queue/broker/heartbeat.py +157 -0
  13. taskiq_llm_queue/broker/queue.py +141 -0
  14. taskiq_llm_queue/broker/s3_offload.py +346 -0
  15. taskiq_llm_queue/broker/scheduler.py +113 -0
  16. taskiq_llm_queue/broker/serializer.py +32 -0
  17. taskiq_llm_queue/cli.py +488 -0
  18. taskiq_llm_queue/client.py +1409 -0
  19. taskiq_llm_queue/config.py +592 -0
  20. taskiq_llm_queue/context.py +62 -0
  21. taskiq_llm_queue/core.py +832 -0
  22. taskiq_llm_queue/envelope.py +136 -0
  23. taskiq_llm_queue/errors.py +71 -0
  24. taskiq_llm_queue/failover.py +286 -0
  25. taskiq_llm_queue/http_transport.py +220 -0
  26. taskiq_llm_queue/keys.py +87 -0
  27. taskiq_llm_queue/langchain/__init__.py +64 -0
  28. taskiq_llm_queue/langchain/_client.py +142 -0
  29. taskiq_llm_queue/langchain/bedrock.py +291 -0
  30. taskiq_llm_queue/langchain/openai.py +103 -0
  31. taskiq_llm_queue/lifecycle.py +512 -0
  32. taskiq_llm_queue/metrics/__init__.py +45 -0
  33. taskiq_llm_queue/metrics/emf.py +100 -0
  34. taskiq_llm_queue/metrics/prometheus.py +66 -0
  35. taskiq_llm_queue/metrics_names.py +19 -0
  36. taskiq_llm_queue/protocols.py +140 -0
  37. taskiq_llm_queue/providers/__init__.py +62 -0
  38. taskiq_llm_queue/providers/bedrock.py +188 -0
  39. taskiq_llm_queue/providers/classify.py +251 -0
  40. taskiq_llm_queue/providers/embeddings.py +87 -0
  41. taskiq_llm_queue/providers/hosts.py +34 -0
  42. taskiq_llm_queue/providers/openai_compat.py +142 -0
  43. taskiq_llm_queue/providers/registry.py +173 -0
  44. taskiq_llm_queue/py.typed +0 -0
  45. taskiq_llm_queue/reliability/__init__.py +59 -0
  46. taskiq_llm_queue/reliability/_lua.py +461 -0
  47. taskiq_llm_queue/reliability/breaker.py +86 -0
  48. taskiq_llm_queue/reliability/claims.py +226 -0
  49. taskiq_llm_queue/reliability/config_store.py +148 -0
  50. taskiq_llm_queue/reliability/fallback.py +111 -0
  51. taskiq_llm_queue/reliability/limiter.py +482 -0
  52. taskiq_llm_queue/reliability/local_limiter.py +153 -0
  53. taskiq_llm_queue/results/__init__.py +25 -0
  54. taskiq_llm_queue/results/backend.py +226 -0
  55. taskiq_llm_queue/results/dispatcher.py +366 -0
  56. taskiq_llm_queue/results/s3_backend.py +105 -0
  57. taskiq_llm_queue/routing.py +173 -0
  58. taskiq_llm_queue/routing_hooks.py +90 -0
  59. taskiq_llm_queue/tasks.py +453 -0
  60. taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
  61. taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
  62. taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
  63. taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
  64. taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  65. taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,138 @@
1
+ """taskiq-llm-queue: an LLM request client with its own durable queue (taskiq + Amazon SQS).
2
+
3
+ This is the public front door: `__all__` lists the client surface (the client and its
4
+ results, config, request context, errors and extension points). Wire and plumbing names live
5
+ in their submodules (`envelope`, `keys`, `protocols`, `errors`, `routing_hooks`). Importing
6
+ the package has no side effects: no clients, threads or network I/O.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib import import_module
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from taskiq_llm_queue._version import __version__
15
+ from taskiq_llm_queue.config import (
16
+ BreakerTuning,
17
+ Lane,
18
+ LimiterTuning,
19
+ ModelConfig,
20
+ QueueConfig,
21
+ Target,
22
+ )
23
+ from taskiq_llm_queue.context import RequestContext, current_context, request_context
24
+ from taskiq_llm_queue.envelope import CallMeta, FallbackPolicy
25
+ from taskiq_llm_queue.errors import (
26
+ ConfigError,
27
+ LLMQueueCancelled,
28
+ LLMQueueError,
29
+ LLMQueueTimeout,
30
+ ProviderError,
31
+ QueueUnavailable,
32
+ UnsupportedEnvelopeError,
33
+ )
34
+ from taskiq_llm_queue.routing_hooks import FailoverPolicy, PathRouter, QueueHooks
35
+
36
+ if TYPE_CHECKING:
37
+ from taskiq_llm_queue.client import (
38
+ ChatResult,
39
+ ConverseResult,
40
+ EmbedResult,
41
+ InvokeResult,
42
+ LLMQueue,
43
+ )
44
+
45
+ # Plumbing kept importable for compatibility (typed for mypy; not in __all__).
46
+ from taskiq_llm_queue.envelope import ENVELOPE_VERSION as ENVELOPE_VERSION
47
+ from taskiq_llm_queue.envelope import TASK_CALL as TASK_CALL
48
+ from taskiq_llm_queue.envelope import TASK_EMBED as TASK_EMBED
49
+ from taskiq_llm_queue.envelope import TASK_JOB as TASK_JOB
50
+ from taskiq_llm_queue.envelope import Envelope as Envelope
51
+ from taskiq_llm_queue.envelope import LLMReply as LLMReply
52
+ from taskiq_llm_queue.envelope import parse_envelope as parse_envelope
53
+ from taskiq_llm_queue.errors import ErrorInfo as ErrorInfo
54
+ from taskiq_llm_queue.errors import ErrorKind as ErrorKind
55
+ from taskiq_llm_queue.keys import KeySpace as KeySpace
56
+ from taskiq_llm_queue.protocols import ClaimDecision as ClaimDecision
57
+ from taskiq_llm_queue.protocols import ClaimState as ClaimState
58
+ from taskiq_llm_queue.protocols import ClaimStore as ClaimStore
59
+ from taskiq_llm_queue.protocols import Lease as Lease
60
+ from taskiq_llm_queue.protocols import Limiter as Limiter
61
+ from taskiq_llm_queue.protocols import Provider as Provider
62
+ from taskiq_llm_queue.protocols import ReceiveGate as ReceiveGate
63
+ from taskiq_llm_queue.protocols import ResultStore as ResultStore
64
+ from taskiq_llm_queue.routing_hooks import CallPath as CallPath
65
+
66
+ # Loaded on first access: the client pulls in taskiq, redis and aiobotocore, and importing the
67
+ # package must stay free of them (library rule; see tests/unit/test_contracts.py).
68
+ _CLIENT = "taskiq_llm_queue.client"
69
+ _LAZY = {
70
+ "LLMQueue": _CLIENT,
71
+ "ConverseResult": _CLIENT,
72
+ "ChatResult": _CLIENT,
73
+ "InvokeResult": _CLIENT,
74
+ "EmbedResult": _CLIENT,
75
+ }
76
+ # Wire / plumbing names that used to be re-exported here: still importable from the package
77
+ # for compatibility, but not public API; import them from their submodules.
78
+ _PLUMBING = {
79
+ "ENVELOPE_VERSION": "taskiq_llm_queue.envelope",
80
+ "TASK_CALL": "taskiq_llm_queue.envelope",
81
+ "TASK_EMBED": "taskiq_llm_queue.envelope",
82
+ "TASK_JOB": "taskiq_llm_queue.envelope",
83
+ "Envelope": "taskiq_llm_queue.envelope",
84
+ "LLMReply": "taskiq_llm_queue.envelope",
85
+ "parse_envelope": "taskiq_llm_queue.envelope",
86
+ "KeySpace": "taskiq_llm_queue.keys",
87
+ "ErrorInfo": "taskiq_llm_queue.errors",
88
+ "ErrorKind": "taskiq_llm_queue.errors",
89
+ "CallPath": "taskiq_llm_queue.routing_hooks",
90
+ "ClaimDecision": "taskiq_llm_queue.protocols",
91
+ "ClaimState": "taskiq_llm_queue.protocols",
92
+ "ClaimStore": "taskiq_llm_queue.protocols",
93
+ "Lease": "taskiq_llm_queue.protocols",
94
+ "Limiter": "taskiq_llm_queue.protocols",
95
+ "Provider": "taskiq_llm_queue.protocols",
96
+ "ReceiveGate": "taskiq_llm_queue.protocols",
97
+ "ResultStore": "taskiq_llm_queue.protocols",
98
+ }
99
+
100
+ __all__ = [
101
+ "BreakerTuning",
102
+ "CallMeta",
103
+ "ChatResult",
104
+ "ConfigError",
105
+ "ConverseResult",
106
+ "EmbedResult",
107
+ "FailoverPolicy",
108
+ "FallbackPolicy",
109
+ "InvokeResult",
110
+ "LLMQueue",
111
+ "LLMQueueCancelled",
112
+ "LLMQueueError",
113
+ "LLMQueueTimeout",
114
+ "Lane",
115
+ "LimiterTuning",
116
+ "ModelConfig",
117
+ "PathRouter",
118
+ "ProviderError",
119
+ "QueueConfig",
120
+ "QueueHooks",
121
+ "QueueUnavailable",
122
+ "RequestContext",
123
+ "Target",
124
+ "UnsupportedEnvelopeError",
125
+ "__version__",
126
+ "current_context",
127
+ "request_context",
128
+ ]
129
+
130
+
131
+ def __getattr__(name: str) -> Any:
132
+ """Import the client on first use of `LLMQueue` / a result type; plumbing names likewise."""
133
+ module = _LAZY.get(name) or _PLUMBING.get(name)
134
+ if module is None:
135
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
136
+ value = getattr(import_module(module), name)
137
+ globals()[name] = value
138
+ return value
@@ -0,0 +1,40 @@
1
+ """Structured loggers that always go through stdlib `logging` (no global structlog config).
2
+
3
+ Unconfigured structlog prints to stdout and ignores the app's levels and handlers, so every
4
+ module logs via `get_logger(__name__)`: structlog's key-value API on top of a stdlib logger.
5
+ Apps keep full control (levels, handlers, loguru's InterceptHandler, structlog's
6
+ ProcessorFormatter); the fields ride on `record.llmq` as a dict.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import Mapping, MutableMapping
13
+ from typing import Any
14
+
15
+ import structlog
16
+
17
+ _PASSTHROUGH = ("exc_info", "stack_info")
18
+
19
+
20
+ def _to_stdlib(
21
+ _logger: Any, _method: str, event_dict: MutableMapping[str, Any]
22
+ ) -> tuple[tuple[str], dict[str, Any]]:
23
+ # Message is readable on its own ("event k=v ..."); the structured copy lives on one
24
+ # `llmq` attribute so field names can never clash with LogRecord's reserved attributes.
25
+ event = str(event_dict.pop("event", ""))
26
+ kwargs: dict[str, Any] = {k: event_dict.pop(k) for k in _PASSTHROUGH if k in event_dict}
27
+ fields: Mapping[str, Any] = dict(event_dict)
28
+ message = " ".join([event, *(f"{k}={v}" for k, v in fields.items())])
29
+ kwargs["extra"] = {"llmq": {"event": event, **fields}}
30
+ kwargs["stacklevel"] = 4 # point %(funcName)s/%(lineno)d at the library call site
31
+ return (message,), kwargs
32
+
33
+
34
+ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
35
+ """Key-value logger bound to `logging.getLogger(name)`; used by every library module."""
36
+ return structlog.wrap_logger( # type: ignore[no-any-return]
37
+ logging.getLogger(name),
38
+ processors=[_to_stdlib],
39
+ wrapper_class=structlog.stdlib.BoundLogger,
40
+ )
@@ -0,0 +1,5 @@
1
+ """Package version, read by hatchling at build time and re-exported as `__version__`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0a1"
@@ -0,0 +1,86 @@
1
+ """SQS broker for taskiq with priority, capacity-aware receive and batching (see docs/DESIGN.md).
2
+
3
+ `HybridSQSBroker` plus its settings (`SQSQueue`, `Batching`, `Heartbeat`), event hooks and
4
+ the SQS error-family classifier. Importing this package creates no client and does no I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from taskiq_llm_queue.broker.acker import VisibilityResult
10
+ from taskiq_llm_queue.broker.broker import (
11
+ DEDUPLICATION_ID_LABEL,
12
+ DELAY_LABEL,
13
+ EXPIRY_ATTRIBUTE,
14
+ EXPIRY_LABEL,
15
+ GROUP_ID_LABEL,
16
+ QUEUE_LABEL,
17
+ SKIP_BATCHING_LABEL,
18
+ SQS_MESSAGE_ID_LABEL,
19
+ SQS_QUEUE_LABEL,
20
+ SQS_RECEIPT_HANDLE_LABEL,
21
+ SQS_RECEIVE_COUNT_LABEL,
22
+ DeliveryFormatter,
23
+ HybridSQSBroker,
24
+ ReceiveBudget,
25
+ )
26
+ from taskiq_llm_queue.broker.delivery import Delivery
27
+ from taskiq_llm_queue.broker.errors import (
28
+ BrokerConfigError,
29
+ ErrorFamily,
30
+ InvalidLabelError,
31
+ MessageTooLargeError,
32
+ SQSSendError,
33
+ backoff_delay,
34
+ classify_code,
35
+ classify_error,
36
+ )
37
+ from taskiq_llm_queue.broker.events import BrokerHooks
38
+ from taskiq_llm_queue.broker.filters import MessageFilter, envelope_supported
39
+ from taskiq_llm_queue.broker.queue import Batching, Heartbeat, SQSQueue
40
+ from taskiq_llm_queue.broker.s3_offload import (
41
+ OFFLOAD_LABEL,
42
+ OffloadedPayloadMissingError,
43
+ S3OffloadError,
44
+ S3OffloadMiddleware,
45
+ )
46
+ from taskiq_llm_queue.broker.scheduler import SlotScheduler
47
+ from taskiq_llm_queue.broker.serializer import WireSerializer
48
+
49
+ __all__ = [
50
+ "DEDUPLICATION_ID_LABEL",
51
+ "DELAY_LABEL",
52
+ "EXPIRY_ATTRIBUTE",
53
+ "EXPIRY_LABEL",
54
+ "GROUP_ID_LABEL",
55
+ "OFFLOAD_LABEL",
56
+ "QUEUE_LABEL",
57
+ "SKIP_BATCHING_LABEL",
58
+ "SQS_MESSAGE_ID_LABEL",
59
+ "SQS_QUEUE_LABEL",
60
+ "SQS_RECEIPT_HANDLE_LABEL",
61
+ "SQS_RECEIVE_COUNT_LABEL",
62
+ "Batching",
63
+ "BrokerConfigError",
64
+ "BrokerHooks",
65
+ "Delivery",
66
+ "DeliveryFormatter",
67
+ "ErrorFamily",
68
+ "Heartbeat",
69
+ "HybridSQSBroker",
70
+ "InvalidLabelError",
71
+ "MessageFilter",
72
+ "MessageTooLargeError",
73
+ "OffloadedPayloadMissingError",
74
+ "ReceiveBudget",
75
+ "S3OffloadError",
76
+ "S3OffloadMiddleware",
77
+ "SQSQueue",
78
+ "SQSSendError",
79
+ "SlotScheduler",
80
+ "VisibilityResult",
81
+ "WireSerializer",
82
+ "backoff_delay",
83
+ "classify_code",
84
+ "classify_error",
85
+ "envelope_supported",
86
+ ]
@@ -0,0 +1,239 @@
1
+ """Batched acks (`DeleteMessageBatch`) and visibility changes (`ChangeMessageVisibilityBatch`).
2
+
3
+ At 10K messages in flight one call per message is too many requests, so acks, heartbeats
4
+ and releases go through per-queue micro-batches (A6). Failed batch entries are retried one
5
+ by one; an invalid receipt handle on delete is fine (the message is already gone or owned
6
+ elsewhere and its claim is `done`, DS7), while on a visibility change it means "lost" (DS6).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Awaitable, Callable, Sequence
13
+ from enum import StrEnum
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from taskiq_llm_queue._logging import get_logger
17
+ from taskiq_llm_queue.broker.batching import MicroBatcher
18
+ from taskiq_llm_queue.broker.errors import (
19
+ ErrorFamily,
20
+ backoff_delay,
21
+ classify_code,
22
+ classify_error,
23
+ error_code,
24
+ )
25
+ from taskiq_llm_queue.broker.events import BrokerHooks, safe_call
26
+ from taskiq_llm_queue.broker.queue import MAX_BATCH_ENTRIES
27
+
28
+ if TYPE_CHECKING:
29
+ from types_aiobotocore_sqs.client import SQSClient
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ class VisibilityResult(StrEnum):
35
+ """Outcome of one visibility change: done, handle invalid (lost), or failed (retry later)."""
36
+
37
+ OK = "ok"
38
+ LOST = "lost"
39
+ FAILED = "failed"
40
+
41
+
42
+ class Acker:
43
+ """Owns the delete and change-visibility batchers for every queue of one broker.
44
+
45
+ `delete` and `change_visibility` return futures immediately; `flush` and `close` push
46
+ out whatever is queued (used on shutdown and drain, A5).
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ client: SQSClient,
52
+ *,
53
+ hooks: BrokerHooks,
54
+ ack_flush_secs: float = 0.5,
55
+ visibility_flush_secs: float = 0.5,
56
+ retry_attempts: int = 3,
57
+ backoff_base_secs: float = 0.2,
58
+ backoff_cap_secs: float = 2.0,
59
+ ) -> None:
60
+ """Create no batchers yet; one pair is made per queue on first use.
61
+
62
+ `retry_attempts` is the only throttle/5xx retry layer: the broker's SQS client makes at
63
+ most one quick botocore retry (standard mode), so one call costs <= 2 x this many.
64
+ """
65
+ self._client = client
66
+ self._hooks = hooks
67
+ self._ack_flush_secs = ack_flush_secs
68
+ self._visibility_flush_secs = visibility_flush_secs
69
+ self._retry_attempts = retry_attempts
70
+ self._backoff_base_secs = backoff_base_secs
71
+ self._backoff_cap_secs = backoff_cap_secs
72
+ self._deleters: dict[str, MicroBatcher[str, bool]] = {}
73
+ self._changers: dict[str, MicroBatcher[tuple[str, int], VisibilityResult]] = {}
74
+
75
+ def delete(self, queue: str, url: str, receipt_handle: str) -> asyncio.Future[bool]:
76
+ """Queue one ack; resolves True when deleted or already gone, False on failure."""
77
+ batcher = self._deleters.get(url)
78
+ if batcher is None:
79
+ batcher = MicroBatcher[str, bool](
80
+ lambda items: self._delete_batch(queue, url, items),
81
+ max_items=MAX_BATCH_ENTRIES,
82
+ max_delay_secs=self._ack_flush_secs,
83
+ name=f"ack:{queue}",
84
+ )
85
+ self._deleters[url] = batcher
86
+ return batcher.submit(receipt_handle)
87
+
88
+ def change_visibility(
89
+ self, queue: str, url: str, receipt_handle: str, visibility_secs: int
90
+ ) -> asyncio.Future[VisibilityResult]:
91
+ """Queue one visibility change (heartbeat extend, or 0 / N for a release)."""
92
+ batcher = self._changers.get(url)
93
+ if batcher is None:
94
+ batcher = MicroBatcher[tuple[str, int], VisibilityResult](
95
+ lambda items: self._visibility_batch(queue, url, items),
96
+ max_items=MAX_BATCH_ENTRIES,
97
+ max_delay_secs=self._visibility_flush_secs,
98
+ name=f"visibility:{queue}",
99
+ )
100
+ self._changers[url] = batcher
101
+ return batcher.submit((receipt_handle, visibility_secs))
102
+
103
+ async def flush(self) -> None:
104
+ """Send every queued ack and visibility change now and wait for the results."""
105
+ batchers: list[MicroBatcher[Any, Any]] = [
106
+ *self._changers.values(),
107
+ *self._deleters.values(),
108
+ ]
109
+ await asyncio.gather(*(b.flush_now() for b in batchers))
110
+
111
+ async def close(self) -> None:
112
+ """Flush and stop all batchers; idempotent."""
113
+ batchers: list[MicroBatcher[Any, Any]] = [
114
+ *self._changers.values(),
115
+ *self._deleters.values(),
116
+ ]
117
+ await asyncio.gather(*(b.close() for b in batchers))
118
+ self._deleters.clear()
119
+ self._changers.clear()
120
+
121
+ def _batchers(self) -> list[MicroBatcher[Any, Any]]:
122
+ return [*self._changers.values(), *self._deleters.values()]
123
+
124
+ async def _delete_batch(self, queue: str, url: str, handles: Sequence[str]) -> list[bool]:
125
+ entries: list[Any] = [{"Id": str(i), "ReceiptHandle": h} for i, h in enumerate(handles)]
126
+ resp = await self.call(
127
+ queue,
128
+ "delete_batch",
129
+ lambda: self._client.delete_message_batch(QueueUrl=url, Entries=entries),
130
+ )
131
+ if resp is None:
132
+ return [False] * len(handles)
133
+ results = [True] * len(handles)
134
+ for failed in resp.get("Failed", []):
135
+ index = int(failed["Id"])
136
+ family = classify_code(failed.get("Code", ""), failed.get("Message", ""))
137
+ if family is ErrorFamily.INVALID_HANDLE:
138
+ logger.debug("sqs_ack_handle_invalid", queue=queue)
139
+ continue
140
+ results[index] = await self._delete_one(queue, url, handles[index])
141
+ acked = sum(results)
142
+ if acked:
143
+ safe_call(self._hooks.on_ack, queue, acked)
144
+ return results
145
+
146
+ async def _delete_one(self, queue: str, url: str, handle: str) -> bool:
147
+ try:
148
+ await self.call(
149
+ queue,
150
+ "delete",
151
+ lambda: self._client.delete_message(QueueUrl=url, ReceiptHandle=handle),
152
+ raise_final=True,
153
+ )
154
+ except Exception as exc:
155
+ if classify_error(exc) is ErrorFamily.INVALID_HANDLE:
156
+ return True
157
+ logger.warning("sqs_ack_failed", queue=queue, error=str(exc))
158
+ return False
159
+ return True
160
+
161
+ async def _visibility_batch(
162
+ self, queue: str, url: str, items: Sequence[tuple[str, int]]
163
+ ) -> list[VisibilityResult]:
164
+ entries: list[Any] = [
165
+ {"Id": str(i), "ReceiptHandle": h, "VisibilityTimeout": secs}
166
+ for i, (h, secs) in enumerate(items)
167
+ ]
168
+ resp = await self.call(
169
+ queue,
170
+ "visibility_batch",
171
+ lambda: self._client.change_message_visibility_batch(QueueUrl=url, Entries=entries),
172
+ )
173
+ if resp is None:
174
+ return [VisibilityResult.FAILED] * len(items)
175
+ results = [VisibilityResult.OK] * len(items)
176
+ for failed in resp.get("Failed", []):
177
+ index = int(failed["Id"])
178
+ family = classify_code(failed.get("Code", ""), failed.get("Message", ""))
179
+ if family is ErrorFamily.INVALID_HANDLE:
180
+ results[index] = VisibilityResult.LOST
181
+ else:
182
+ results[index] = await self._visibility_one(queue, url, *items[index])
183
+ return results
184
+
185
+ async def _visibility_one(
186
+ self, queue: str, url: str, handle: str, secs: int
187
+ ) -> VisibilityResult:
188
+ try:
189
+ await self.call(
190
+ queue,
191
+ "visibility",
192
+ lambda: self._client.change_message_visibility(
193
+ QueueUrl=url, ReceiptHandle=handle, VisibilityTimeout=secs
194
+ ),
195
+ raise_final=True,
196
+ )
197
+ except Exception as exc:
198
+ if classify_error(exc) is ErrorFamily.INVALID_HANDLE:
199
+ return VisibilityResult.LOST
200
+ return VisibilityResult.FAILED
201
+ return VisibilityResult.OK
202
+
203
+ async def call(
204
+ self,
205
+ queue: str,
206
+ operation: str,
207
+ call: Callable[[], Awaitable[Any]],
208
+ *,
209
+ raise_final: bool = False,
210
+ ) -> Any:
211
+ """Run one SQS call, retrying throttles and 5xx with jittered backoff (DS16)."""
212
+ attempt = 0
213
+ while True:
214
+ try:
215
+ return await call()
216
+ except Exception as exc:
217
+ family = classify_error(exc)
218
+ safe_call(self._hooks.on_error, queue, operation, family.value)
219
+ if family.retryable and attempt + 1 < self._retry_attempts:
220
+ await asyncio.sleep(
221
+ backoff_delay(
222
+ attempt,
223
+ base_secs=self._backoff_base_secs,
224
+ cap_secs=self._backoff_cap_secs,
225
+ )
226
+ )
227
+ attempt += 1
228
+ continue
229
+ if raise_final:
230
+ raise
231
+ logger.warning(
232
+ "sqs_call_failed",
233
+ operation=operation,
234
+ queue=queue,
235
+ error_code=error_code(exc),
236
+ attempt=attempt + 1,
237
+ error=str(exc),
238
+ )
239
+ return None