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,832 @@
1
+ """Execution of one LLM call: claim, limiter, breaker, provider, retry, fallback, result.
2
+
3
+ `execute_job` is what a worker runs for every received envelope and what the `direct` path runs
4
+ in-process (without the claim and the stored result). Provider errors come back as
5
+ `LLMReply(ok=False, error=...)` (§8, §9.2); a Valkey failure raises `InfraError` so the caller
6
+ releases the message instead of acking it, and a failed result write raises `QueueUnavailable`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import itertools
13
+ import random
14
+ import time
15
+ from collections import OrderedDict
16
+ from collections.abc import Awaitable, Callable, Mapping
17
+ from dataclasses import dataclass, field
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import orjson
21
+ from redis.asyncio.cluster import RedisCluster
22
+ from redis.exceptions import RedisError
23
+
24
+ from taskiq_llm_queue._logging import get_logger
25
+ from taskiq_llm_queue.config import Lane
26
+ from taskiq_llm_queue.envelope import CallMeta, Envelope, LLMReply
27
+ from taskiq_llm_queue.errors import ConfigError, ErrorInfo, ErrorKind, QueueUnavailable
28
+ from taskiq_llm_queue.protocols import ClaimDecision, ClaimState
29
+ from taskiq_llm_queue.providers.classify import is_target_error
30
+ from taskiq_llm_queue.reliability import _lua
31
+ from taskiq_llm_queue.reliability.claims import RedisClaimStore
32
+ from taskiq_llm_queue.reliability.fallback import FallbackConfig, resolve_fallback
33
+ from taskiq_llm_queue.reliability.limiter import AdaptiveLimiter
34
+ from taskiq_llm_queue.results.backend import LLMQResultBackend
35
+ from taskiq_llm_queue.routing_hooks import QueueHooks, safe_hook
36
+
37
+ if TYPE_CHECKING:
38
+ from redis.asyncio import Redis
39
+
40
+ from taskiq_llm_queue.config import QueueConfig
41
+ from taskiq_llm_queue.envelope import Op
42
+ from taskiq_llm_queue.keys import KeySpace
43
+ from taskiq_llm_queue.protocols import Lease, Limiter, Provider, ResultStore
44
+ from taskiq_llm_queue.reliability.breaker import CircuitBreaker
45
+ from taskiq_llm_queue.reliability.fallback import FallbackStep
46
+
47
+ logger = get_logger(__name__)
48
+
49
+ # DS13: a live (rt) call that can't get a slot within 2 s moves to the next fallback step.
50
+ RT_ACQUIRE_SECS = 2.0
51
+ # S35: one 2 s wait failed an rt call with no fallback during a burst; rt walks the chain this
52
+ # many times (each step waiting 2 s) before giving up with `no_capacity`.
53
+ RT_CAPACITY_PASSES = 3
54
+ # Caps one backoff sleep; the deadline caps the total (DS10).
55
+ MAX_BACKOFF_SECS = 20.0
56
+ # DS4/DS10: a queued bg/bulk call waits this long per step for a slot, then the message is
57
+ # released with a delay instead of holding a worker slot until its deadline.
58
+ QUEUED_ACQUIRE_SECS = 15.0
59
+ # Longest a result write is retried before the message is released (DS8).
60
+ STORE_RETRY_SECS = 30.0
61
+ _RESULT_RETRY_SECS = 0.5
62
+ # Pause between passes when every fallback step is skipped by an open breaker.
63
+ _IDLE_RETRY_SECS = 1.0
64
+ _MIN_REQUEUE_SECS = 10
65
+ _MAX_REQUEUE_SECS = 900
66
+ _CACHE_ENTRIES = 1024
67
+ # S48: a model this worker's config lacks is released this long (+ up to 50 % jitter) for a pod
68
+ # that knows it, until the delivery budget (below `maxReceiveCount` 10, DS5) or deadline is spent.
69
+ UNKNOWN_MODEL_RELEASE_SECS = 10
70
+ UNKNOWN_MODEL_MAX_DELIVERIES = 8
71
+
72
+
73
+ class InfraError(QueueUnavailable):
74
+ """Valkey failed while running a call; the message must be released, never acked.
75
+
76
+ `attempted` is True when a provider attempt had already started, so a caller must not
77
+ simply run the call again elsewhere (the cause is the `RedisError`).
78
+ """
79
+
80
+ def __init__(self, request_id: str, *, attempted: bool) -> None:
81
+ """Keep the request id and whether a provider attempt had started."""
82
+ super().__init__(f"Valkey failed while running {request_id}")
83
+ self.request_id = request_id
84
+ self.attempted = attempted
85
+
86
+
87
+ class _Requeue(Exception):
88
+ """Internal: no capacity for a queued bg/bulk call; release the message with a delay."""
89
+
90
+ def __init__(self, secs: int) -> None:
91
+ super().__init__(secs)
92
+ self.secs = secs
93
+
94
+
95
+ @dataclass(slots=True)
96
+ class _Progress:
97
+ """Provider attempts started so far for one call (tells `InfraError` what happened)."""
98
+
99
+ attempts: int = 0
100
+
101
+
102
+ class ReplyCache:
103
+ """Bounded per-pod cache of replies whose result write failed (DS8 "cached response").
104
+
105
+ A redelivery of the same request to this pod stores the cached reply instead of calling
106
+ the provider again. Other pods cannot see it, so a redelivery there calls once more.
107
+ """
108
+
109
+ def __init__(self, max_entries: int = _CACHE_ENTRIES) -> None:
110
+ """Hold at most `max_entries` replies."""
111
+ self._max = max_entries
112
+ self._items: OrderedDict[str, tuple[float, LLMReply]] = OrderedDict()
113
+
114
+ def __len__(self) -> int:
115
+ return len(self._items)
116
+
117
+ def put(self, reply: LLMReply, *, expires_at: float) -> None:
118
+ """Keep `reply` until `expires_at` (the call's deadline); evicts the oldest when full."""
119
+ self._items[reply.request_id] = (expires_at, reply)
120
+ self._items.move_to_end(reply.request_id)
121
+ while len(self._items) > self._max:
122
+ self._items.popitem(last=False)
123
+
124
+ def pop(self, request_id: str, now: float) -> LLMReply | None:
125
+ """Take the cached reply for `request_id` if it has not expired."""
126
+ item = self._items.pop(request_id, None)
127
+ if item is None or item[0] <= now:
128
+ return None
129
+ return item[1]
130
+
131
+
132
+ class _Stopped(Exception):
133
+ """Internal: the call was cancelled (DS14) or ran past its deadline."""
134
+
135
+ def __init__(self, state: ClaimState) -> None:
136
+ super().__init__(state.value)
137
+ self.state = state
138
+
139
+
140
+ @dataclass(slots=True)
141
+ class CoreDeps:
142
+ """Everything `execute_job` needs, injected so tests can pass fakes and clocks.
143
+
144
+ `providers(alias)` returns the provider for a model alias; `fallback()` returns the live
145
+ fallback config without I/O. `claims`, `breaker`, `results` and `redis` may be None on the
146
+ `direct` path or while Valkey is down. `leases` holds this pod's in-flight leases for the
147
+ pod heartbeat to renew (DS11); every path on a pod shares one set.
148
+ """
149
+
150
+ config: QueueConfig
151
+ keys: KeySpace
152
+ providers: Callable[[str], Provider]
153
+ limiter: Limiter
154
+ claims: RedisClaimStore | None = None
155
+ breaker: CircuitBreaker | None = None
156
+ results: ResultStore | None = None
157
+ redis: Redis | None = None
158
+ fallback: Callable[[], FallbackConfig] = FallbackConfig
159
+ worker: str = ""
160
+ hooks: QueueHooks = field(default_factory=QueueHooks)
161
+ clock: Callable[[], float] = time.time
162
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
163
+ rng: random.Random = field(default_factory=random.Random)
164
+ leases: set[Lease] = field(default_factory=set)
165
+ acquire_wait_secs: float = QUEUED_ACQUIRE_SECS
166
+ store_retry_secs: float = STORE_RETRY_SECS
167
+ reply_cache: ReplyCache = field(default_factory=ReplyCache)
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class JobResult:
172
+ """Outcome of `execute_job`: the claim decision and the reply (None unless it ran).
173
+
174
+ `decision` other than RUN means the worker must not call the provider: DEFER releases the
175
+ message with the heartbeat window, the ACK_* decisions ack it. `requeue_secs` set means no
176
+ capacity (DS4) or a model this pod lacks (S48): release the message with that delay; no
177
+ claim is held.
178
+ """
179
+
180
+ decision: ClaimDecision
181
+ reply: LLMReply | None = None
182
+ requeue_secs: int | None = None
183
+
184
+
185
+ def error_reply(
186
+ envelope: Envelope,
187
+ code: str,
188
+ message: str,
189
+ *,
190
+ retryable: bool,
191
+ kind: str = "",
192
+ retry_after_secs: float | None = None,
193
+ meta: CallMeta | None = None,
194
+ cancelled: bool = False,
195
+ ) -> LLMReply:
196
+ """An `ok=False` reply in the §9.2 shape (`type`, `message`, `retryable`, ...)."""
197
+ return LLMReply(
198
+ request_id=envelope.request_id,
199
+ ok=False,
200
+ cancelled=cancelled,
201
+ meta=meta or CallMeta(),
202
+ error={
203
+ "type": code,
204
+ "message": message,
205
+ "retryable": retryable,
206
+ "kind": kind,
207
+ "retry_after_secs": retry_after_secs,
208
+ },
209
+ )
210
+
211
+
212
+ def backoff_secs(attempt: int, base_secs: float, rng: random.Random, hint: float | None) -> float:
213
+ """Sleep before retry `attempt` (0-based): `base * 2^attempt` + full jitter, or `Retry-After`.
214
+
215
+ A provider's `Retry-After` hint wins when larger; the result is capped at 20 s.
216
+ """
217
+ delay = base_secs * float(2**attempt) + rng.uniform(0, base_secs)
218
+ if hint is not None:
219
+ delay = max(delay, hint)
220
+ return min(delay, MAX_BACKOFF_SECS)
221
+
222
+
223
+ def params_for_step(op: Op, params: Mapping[str, Any], model_id: str) -> dict[str, Any]:
224
+ """Provider params with the step's model id set (`modelId` for Bedrock ops, else `model`).
225
+
226
+ Each fallback step may switch model, so the id always comes from that step's `ModelConfig`.
227
+ """
228
+ key = "modelId" if op in ("converse", "invoke_model") else "model"
229
+ return {**params, key: model_id}
230
+
231
+
232
+ async def load_params(envelope: Envelope, deps: CoreDeps) -> dict[str, Any] | None:
233
+ """Inline params, or the JSON stored at `params_ref` (None if it expired or Redis is off)."""
234
+ if envelope.params is not None:
235
+ return envelope.params
236
+ if deps.redis is None or envelope.params_ref is None:
237
+ return None
238
+ raw = await deps.redis.get(envelope.params_ref)
239
+ if raw is None:
240
+ return None
241
+ value = orjson.loads(raw)
242
+ return value if isinstance(value, dict) else None
243
+
244
+
245
+ async def execute_job(
246
+ envelope: Envelope,
247
+ deps: CoreDeps,
248
+ *,
249
+ claim: bool = True,
250
+ store: bool = True,
251
+ on_run: Callable[[], None] | None = None,
252
+ deliveries: int = 1,
253
+ ) -> JobResult:
254
+ """Run one envelope end to end (§6.1, §8): claim → call with retry/fallback → result.
255
+
256
+ With `claim`, the Lua claim decides first (DS1-DS3) and the record is completed (best
257
+ effort) after the result is stored (DS8). `on_run` is called once the claim says RUN. A
258
+ Valkey failure raises `InfraError`; a `CancelledError` propagates with nothing written.
259
+ A model this config lacks is handed back unclaimed (`requeue_secs`) while `deliveries`
260
+ (the SQS receive count) and the deadline allow, then answered `model_not_configured`.
261
+ """
262
+ claims = deps.claims if claim else None
263
+ known = _model_known(envelope, deps)
264
+ if not known and claims is not None and store:
265
+ delay = _unknown_model_delay(envelope, deps, deliveries)
266
+ logger.warning(
267
+ "model_not_configured",
268
+ request_id=envelope.request_id,
269
+ model=envelope.model,
270
+ worker=deps.worker,
271
+ deliveries=deliveries,
272
+ released_secs=delay,
273
+ )
274
+ if delay is not None: # unclaimed: a pod configured for the model takes it
275
+ return JobResult(ClaimDecision.RUN, requeue_secs=delay)
276
+ if claims is not None:
277
+ try:
278
+ decision = await claims.claim(
279
+ envelope.request_id, deadline_at=envelope.deadline_at, worker=deps.worker
280
+ )
281
+ except RedisError as exc:
282
+ raise InfraError(envelope.request_id, attempted=False) from exc
283
+ if decision is not ClaimDecision.RUN:
284
+ if decision is ClaimDecision.ACK_CANCELLED and store:
285
+ reply = error_reply(envelope, "cancelled", "cancelled", retryable=False)
286
+ await _store(reply.model_copy(update={"cancelled": True}), envelope, deps)
287
+ return JobResult(decision)
288
+ if on_run is not None:
289
+ on_run()
290
+ state = ClaimState.DONE
291
+ progress = _Progress()
292
+ cached = deps.reply_cache.pop(envelope.request_id, deps.clock()) if store else None
293
+ try:
294
+ if cached is not None: # DS8: an earlier delivery ran it but could not store the reply
295
+ logger.info("cached_reply_reused", request_id=envelope.request_id)
296
+ reply = cached
297
+ elif not known:
298
+ message = f"model {envelope.model!r} is not configured on worker {deps.worker!r}"
299
+ reply = error_reply(envelope, "model_not_configured", message, retryable=False)
300
+ elif (params := await load_params(envelope, deps)) is None:
301
+ reply = error_reply(envelope, "params_missing", "params_ref not found", retryable=False)
302
+ else:
303
+ reply = await run_call(
304
+ envelope,
305
+ params,
306
+ deps,
307
+ check_cancel=claims is not None,
308
+ requeue=claims is not None and store,
309
+ progress=progress,
310
+ # the claim script just checked the cancel flag (DS14): skip one EXISTS
311
+ fresh_claim=isinstance(claims, RedisClaimStore),
312
+ )
313
+ except _Stopped as stop:
314
+ state = stop.state
315
+ cancelled = stop.state is ClaimState.CANCELLED
316
+ code = "cancelled" if cancelled else "deadline_exceeded"
317
+ reply = error_reply(envelope, code, code, retryable=False, cancelled=cancelled)
318
+ except _Requeue as requeue:
319
+ if claims is not None:
320
+ await _quietly(
321
+ claims.release(envelope.request_id, worker=deps.worker),
322
+ "claim_release_failed",
323
+ envelope,
324
+ )
325
+ return JobResult(ClaimDecision.RUN, requeue_secs=requeue.secs)
326
+ except asyncio.CancelledError:
327
+ raise
328
+ except RedisError as exc: # infrastructure, not the call: release for redelivery
329
+ raise InfraError(envelope.request_id, attempted=progress.attempts > 0) from exc
330
+ except Exception as exc: # a bug must still release the waiter with a value
331
+ logger.exception(
332
+ "call_failed_unexpectedly",
333
+ request_id=envelope.request_id,
334
+ model=envelope.model,
335
+ lane=envelope.lane.value,
336
+ )
337
+ reply = error_reply(envelope, "internal_error", type(exc).__name__, retryable=False)
338
+ if store:
339
+ try:
340
+ await _finish(reply, envelope, deps, claims, state)
341
+ except QueueUnavailable:
342
+ deps.reply_cache.put(reply, expires_at=envelope.deadline_at)
343
+ raise
344
+ elif claims is not None:
345
+ await _quietly(
346
+ claims.complete(envelope.request_id, state, worker=deps.worker),
347
+ "claim_complete_failed",
348
+ envelope,
349
+ )
350
+ return JobResult(ClaimDecision.RUN, reply)
351
+
352
+
353
+ def _model_known(envelope: Envelope, deps: CoreDeps) -> bool:
354
+ """True when this worker's config has the envelope's `alias[@target]`."""
355
+ try:
356
+ deps.config.resolve(envelope.model)
357
+ except ConfigError:
358
+ return False
359
+ return True
360
+
361
+
362
+ def _unknown_model_delay(envelope: Envelope, deps: CoreDeps, deliveries: int) -> int | None:
363
+ """Release delay for a model this pod lacks; None once the budget or deadline is spent."""
364
+ remaining = envelope.deadline_at - deps.clock()
365
+ if deliveries >= UNKNOWN_MODEL_MAX_DELIVERIES or remaining <= 2 * UNKNOWN_MODEL_RELEASE_SECS:
366
+ return None
367
+ # Jitter: a fixed delay in step with this pod's receive loop let it win every redelivery.
368
+ return UNKNOWN_MODEL_RELEASE_SECS + deps.rng.randint(0, UNKNOWN_MODEL_RELEASE_SECS // 2)
369
+
370
+
371
+ async def _finish(
372
+ reply: LLMReply,
373
+ envelope: Envelope,
374
+ deps: CoreDeps,
375
+ claims: RedisClaimStore | None,
376
+ state: ClaimState,
377
+ ) -> None:
378
+ """DS8 tail: store the result, then complete the claim, then wake-up and params cleanup.
379
+
380
+ One pipelined round trip when our stores share one non-cluster client (4 before); else
381
+ the same steps one by one. Raises `QueueUnavailable` when the result cannot be stored.
382
+ """
383
+ results, redis = deps.results, deps.redis
384
+ if (
385
+ redis is not None
386
+ and not isinstance(redis, RedisCluster) # cluster nodes would run out of order
387
+ and isinstance(results, LLMQResultBackend)
388
+ and results.uses(redis)
389
+ and (claims is None or (isinstance(claims, RedisClaimStore) and claims.client is redis))
390
+ and await _finish_pipelined(reply, envelope, deps, results, claims, state)
391
+ ):
392
+ return
393
+ await _store(reply, envelope, deps)
394
+ if claims is not None: # the reply is out; a failed CAS only lets a duplicate re-ack
395
+ await _quietly(
396
+ claims.complete(envelope.request_id, state, worker=deps.worker),
397
+ "claim_complete_failed",
398
+ envelope,
399
+ )
400
+ if deps.redis is not None:
401
+ await _after_store(envelope, deps)
402
+
403
+
404
+ async def _finish_pipelined(
405
+ reply: LLMReply,
406
+ envelope: Envelope,
407
+ deps: CoreDeps,
408
+ results: LLMQResultBackend,
409
+ claims: RedisClaimStore | None,
410
+ state: ClaimState,
411
+ ) -> bool:
412
+ """SET result, claim CAS, PUBLISH, DEL params in one non-transactional pipeline (DS8).
413
+
414
+ Valkey runs one connection's commands in order, so `done` is never written before the
415
+ result, and a write it refuses (OOM, READONLY) is refused to the claim script too. A failed
416
+ round trip is retried whole (each step is idempotent) until `_store`'s give-up time.
417
+ Returns False when the `SET` alone was refused; the caller then goes step by step.
418
+ """
419
+ assert deps.redis is not None
420
+ rid = envelope.request_id
421
+ complete = claims.complete_call(rid, state, worker=deps.worker) if claims else None
422
+ give_up_at = min(envelope.deadline_at, deps.clock() + deps.store_retry_secs)
423
+ while True:
424
+ try:
425
+ async with deps.redis.pipeline(transaction=False) as pipe:
426
+ results.queue_put(pipe, reply, ttl_secs=deps.config.result_ttl_secs)
427
+ if complete is not None:
428
+ _lua.queue_script(pipe, *complete)
429
+ if envelope.reply_to:
430
+ pipe.publish(deps.keys.done_channel(envelope.reply_to), rid)
431
+ if envelope.params_ref:
432
+ pipe.delete(envelope.params_ref)
433
+ replies = await pipe.execute(raise_on_error=False)
434
+ break
435
+ except Exception as exc:
436
+ if deps.clock() + _RESULT_RETRY_SECS >= give_up_at:
437
+ raise QueueUnavailable(f"cannot store result {rid}") from exc
438
+ logger.warning("result_write_failed", request_id=rid, error_type=type(exc).__name__)
439
+ await deps.sleep(_RESULT_RETRY_SECS)
440
+ if isinstance(replies[0], Exception):
441
+ logger.warning("result_write_failed", request_id=rid, error_type=type(replies[0]).__name__)
442
+ return False
443
+ if claims is not None and complete is not None:
444
+ try:
445
+ outcome = await _lua.settle(replies[1], *complete)
446
+ claims.complete_outcome(outcome, rid, worker=deps.worker)
447
+ except Exception as exc: # the reply is out; a failed CAS only lets a duplicate re-ack
448
+ logger.warning("claim_complete_failed", request_id=rid, error_type=type(exc).__name__)
449
+ rest = replies[2:] if complete is not None else replies[1:]
450
+ if failed := next((r for r in rest if isinstance(r, Exception)), None):
451
+ # a lost wake-up only delays the result until the next poll
452
+ logger.warning("result_cleanup_failed", request_id=rid, error_type=type(failed).__name__)
453
+ return True
454
+
455
+
456
+ async def _quietly(call: Awaitable[Any], event: str, envelope: Envelope) -> None:
457
+ """Await a best-effort claim update; a Valkey error is logged, never raised."""
458
+ try:
459
+ await call
460
+ except Exception as exc:
461
+ logger.warning(event, request_id=envelope.request_id, error_type=type(exc).__name__)
462
+
463
+
464
+ async def _store(reply: LLMReply, envelope: Envelope, deps: CoreDeps) -> None:
465
+ """Write the reply, retrying until the deadline or `store_retry_secs` (DS8).
466
+
467
+ Then raises `QueueUnavailable`; `execute_job` keeps the reply in the pod's `ReplyCache`.
468
+ """
469
+ if deps.results is None:
470
+ return
471
+ ttl = deps.config.result_ttl_secs
472
+ give_up_at = min(envelope.deadline_at, deps.clock() + deps.store_retry_secs)
473
+ while True:
474
+ try:
475
+ await deps.results.put(reply, ttl_secs=ttl)
476
+ return
477
+ except Exception as exc:
478
+ if deps.clock() + _RESULT_RETRY_SECS >= give_up_at:
479
+ raise QueueUnavailable(f"cannot store result {envelope.request_id}") from exc
480
+ logger.warning(
481
+ "result_write_failed",
482
+ request_id=envelope.request_id,
483
+ error_type=type(exc).__name__,
484
+ )
485
+ await deps.sleep(_RESULT_RETRY_SECS)
486
+
487
+
488
+ async def _after_store(envelope: Envelope, deps: CoreDeps) -> None:
489
+ """Best effort: wake the sender's dispatcher and drop large params stored by reference."""
490
+ assert deps.redis is not None
491
+ try:
492
+ if envelope.reply_to:
493
+ await deps.redis.publish(deps.keys.done_channel(envelope.reply_to), envelope.request_id)
494
+ if envelope.params_ref:
495
+ await deps.redis.delete(envelope.params_ref)
496
+ except Exception as exc: # a lost wake-up only delays the result until the next poll
497
+ logger.warning(
498
+ "result_cleanup_failed",
499
+ request_id=envelope.request_id,
500
+ error_type=type(exc).__name__,
501
+ )
502
+
503
+
504
+ async def _check_live(envelope: Envelope, deps: CoreDeps, check_cancel: bool) -> None:
505
+ """Raise `_Stopped` when the call is cancelled (DS14) or past its deadline."""
506
+ if deps.clock() >= envelope.deadline_at:
507
+ raise _Stopped(ClaimState.EXPIRED)
508
+ if (
509
+ check_cancel
510
+ and deps.claims is not None
511
+ and await deps.claims.is_cancelled(envelope.request_id)
512
+ ):
513
+ raise _Stopped(ClaimState.CANCELLED)
514
+
515
+
516
+ async def _step_usable(step: FallbackStep, lane: Lane, deps: CoreDeps) -> bool:
517
+ """Breaker allows the target and, for `require_headroom` steps, the limiter has room."""
518
+ try:
519
+ if step.require_headroom and isinstance(deps.limiter, AdaptiveLimiter):
520
+ state = await deps.limiter.state(step.alias, step.target)
521
+ if state.headroom(deps.limiter.settings.share_for(lane)) <= 0:
522
+ return False
523
+ if deps.breaker is not None:
524
+ return await deps.breaker.allow(step.alias, step.target)
525
+ except Exception as exc: # Valkey blip: try the step rather than skip everything
526
+ logger.warning(
527
+ "step_check_failed",
528
+ model=step.alias,
529
+ target=step.target,
530
+ lane=lane.value,
531
+ error_type=type(exc).__name__,
532
+ )
533
+ return True
534
+
535
+
536
+ def _classify(provider: Provider, exc: BaseException) -> ErrorInfo:
537
+ """Provider classification; our own per-attempt timeout counts as retryable."""
538
+ if isinstance(exc, TimeoutError):
539
+ return ErrorInfo(ErrorKind.RETRYABLE, "timeout")
540
+ try:
541
+ return provider.classify(exc)
542
+ except Exception: # a broken classifier must not turn into an unhandled error
543
+ return ErrorInfo(ErrorKind.RETRYABLE, type(exc).__name__)
544
+
545
+
546
+ def _attempt_message(exc: BaseException, info: ErrorInfo) -> str:
547
+ """`ExcType: code` for an error reply, or just the code when it is the type's name."""
548
+ code = info.code or info.kind.value
549
+ name = type(exc).__name__
550
+ return code if code == name else f"{name}: {code}"
551
+
552
+
553
+ def _acquire_wait(envelope: Envelope, deps: CoreDeps, *, bounded: bool) -> float:
554
+ """Slot wait for one attempt: rt 2 s (DS13); queued bg/bulk bounded (DS4); else the deadline."""
555
+ remaining = envelope.deadline_at - deps.clock()
556
+ if envelope.lane is Lane.RT:
557
+ return min(RT_ACQUIRE_SECS, remaining)
558
+ if bounded and remaining > 2 * deps.acquire_wait_secs:
559
+ return deps.acquire_wait_secs
560
+ return remaining
561
+
562
+
563
+ def _requeue_secs(envelope: Envelope, deps: CoreDeps) -> int | None:
564
+ """Delay before a queued bg/bulk call with no capacity is redelivered; None near deadline.
565
+
566
+ A quarter of the remaining time (10-900 s) keeps receives per message low (DS5).
567
+ """
568
+ remaining = envelope.deadline_at - deps.clock()
569
+ if envelope.lane is Lane.RT or remaining <= 2 * deps.acquire_wait_secs:
570
+ return None
571
+ return int(min(_MAX_REQUEUE_SECS, max(_MIN_REQUEUE_SECS, remaining / 4)))
572
+
573
+
574
+ async def _take_slot(
575
+ envelope: Envelope,
576
+ step: FallbackStep,
577
+ deps: CoreDeps,
578
+ *,
579
+ check_cancel: bool,
580
+ bounded: bool,
581
+ fresh_claim: bool = False,
582
+ ) -> Lease | None:
583
+ """Acquire one slot; cancel and deadline are checked before and right after (DS14).
584
+
585
+ `fresh_claim`: the claim script checked the cancel flag just now, so skip the first check.
586
+ """
587
+ await _check_live(envelope, deps, check_cancel and not fresh_claim)
588
+ wait = _acquire_wait(envelope, deps, bounded=bounded)
589
+ abort = _cancel_probe(envelope, deps) if check_cancel else None
590
+ lease = await deps.limiter.acquire(
591
+ step.alias, step.target, envelope.lane, wait, should_abort=abort
592
+ )
593
+ if lease is None:
594
+ return None
595
+ try:
596
+ await _check_live(envelope, deps, check_cancel)
597
+ except BaseException:
598
+ await _release(deps, lease, throttled=False)
599
+ raise
600
+ return lease
601
+
602
+
603
+ def _cancel_probe(envelope: Envelope, deps: CoreDeps) -> Callable[[], Awaitable[bool]] | None:
604
+ """`should_abort` for a slot wait: the cancel flag (DS14); a Valkey blip reads as no."""
605
+ claims = deps.claims
606
+ if claims is None:
607
+ return None
608
+
609
+ async def cancelled() -> bool:
610
+ try:
611
+ return await claims.is_cancelled(envelope.request_id)
612
+ except RedisError:
613
+ return False
614
+
615
+ return cancelled
616
+
617
+
618
+ async def run_call(
619
+ envelope: Envelope,
620
+ params: Mapping[str, Any],
621
+ deps: CoreDeps,
622
+ *,
623
+ check_cancel: bool = False,
624
+ requeue: bool = False,
625
+ progress: _Progress | None = None,
626
+ fresh_claim: bool = False,
627
+ ) -> LLMReply:
628
+ """Walk the fallback steps; each step gets `retry_attempts` attempts with backoff (§8).
629
+
630
+ A step the provider throttled gets `throttle_retry_attempts` instead, when that is more.
631
+ A limiter slot covers one attempt and is released in `finally` before any sleep (DS10).
632
+ When no step could run: `requeue` raises `_Requeue` (bg/bulk), an all-open breaker waits
633
+ and tries again within the deadline. Raises `_Stopped`; a Valkey error propagates.
634
+ `fresh_claim` skips the cancel check before the first slot (the claim just made it).
635
+ An attempt is capped at `attempt_timeout_secs`; a hung target moves on to the next step,
636
+ as does a fatal error about the target itself (`is_target_error`, e.g. model not in region).
637
+ """
638
+ config = deps.config
639
+ steps = resolve_fallback(
640
+ config, envelope.model, policy=envelope.fallback, op=envelope.op, live=deps.fallback()
641
+ )
642
+ started = deps.clock()
643
+ queue_wait_ms = max(0, round((started - envelope.enqueued_at) * 1000))
644
+ safe_hook(deps.hooks.on_queue_wait_ms, envelope.model, envelope.lane.value, queue_wait_ms)
645
+ progress = progress or _Progress()
646
+ last = ErrorInfo(ErrorKind.RETRYABLE, "no_capacity")
647
+ last_message = "no fallback step had capacity"
648
+ passes = 0
649
+ while True:
650
+ passes += 1
651
+ attempts_before = progress.attempts
652
+ circuit_only = True
653
+ for index, step in enumerate(steps):
654
+ if not await _step_usable(step, envelope.lane, deps):
655
+ last, last_message = ErrorInfo(ErrorKind.RETRYABLE, "circuit_open"), "circuit open"
656
+ continue
657
+ circuit_only = False
658
+ _, model_cfg, _ = config.resolve(f"{step.alias}@{step.target}")
659
+ target = model_cfg.targets[step.target]
660
+ provider = deps.providers(step.alias)
661
+ call_params = params_for_step(envelope.op, params, model_cfg.model_id)
662
+ budget = config.retry_attempts
663
+ for attempt in itertools.count():
664
+ if attempt >= budget:
665
+ break
666
+ lease = await _take_slot(
667
+ envelope,
668
+ step,
669
+ deps,
670
+ check_cancel=check_cancel,
671
+ bounded=requeue,
672
+ fresh_claim=fresh_claim,
673
+ )
674
+ fresh_claim = False
675
+ if lease is None:
676
+ last, last_message = (
677
+ ErrorInfo(ErrorKind.RETRYABLE, "no_capacity"),
678
+ "no capacity",
679
+ )
680
+ break # DS13: next step
681
+ progress.attempts += 1
682
+ deps.leases.add(lease)
683
+ info: ErrorInfo | None = None
684
+ target_error = False
685
+ # One cap for the attempt timeout and the deadline: no extra task, unlike
686
+ # wait_for, and `expired()` tells it apart from a provider's own timeout.
687
+ remaining = envelope.deadline_at - deps.clock()
688
+ by_deadline = config.attempt_timeout_secs >= remaining
689
+ cap = asyncio.timeout(max(0.1, min(config.attempt_timeout_secs, remaining)))
690
+ t0 = time.monotonic()
691
+ try:
692
+ async with cap:
693
+ response = await provider.call(envelope.op, call_params, target)
694
+ except asyncio.CancelledError:
695
+ raise
696
+ except Exception as exc:
697
+ if cap.expired() and not by_deadline: # S39: a hung target, not the call
698
+ info = ErrorInfo(ErrorKind.RETRYABLE, "attempt_timeout")
699
+ last_message = f"attempt timed out after {config.attempt_timeout_secs}s"
700
+ else:
701
+ info = _classify(provider, exc)
702
+ target_error = is_target_error(exc, info)
703
+ last_message = _attempt_message(exc, info)
704
+ last = info
705
+ finally:
706
+ deps.leases.discard(lease)
707
+ await _release(
708
+ deps, lease, throttled=info is not None and info.kind is ErrorKind.THROTTLE
709
+ )
710
+ secs = time.monotonic() - t0
711
+ kind = info.kind if info else "ok"
712
+ safe_hook(deps.hooks.on_attempt, step.alias, step.target, kind, secs)
713
+ if info is None:
714
+ await _record(deps, step, ok=True)
715
+ if index:
716
+ safe_hook(
717
+ deps.hooks.on_fallback, envelope.model, f"{step.alias}@{step.target}"
718
+ )
719
+ meta = CallMeta(
720
+ served_model=step.alias,
721
+ served_target=step.target,
722
+ fallback_used=index > 0,
723
+ queue_wait_ms=queue_wait_ms,
724
+ provider_ms=round(secs * 1000),
725
+ attempts=progress.attempts,
726
+ worker=deps.worker,
727
+ )
728
+ return LLMReply(
729
+ request_id=envelope.request_id, ok=True, response=response, meta=meta
730
+ )
731
+ if target_error: # S38: model/profile not served here; the next step may be
732
+ logger.warning(
733
+ "target_unavailable",
734
+ request_id=envelope.request_id,
735
+ model=step.alias,
736
+ target=step.target,
737
+ error=last_message,
738
+ )
739
+ await _record(deps, step, ok=None)
740
+ break
741
+ if info.kind is ErrorKind.FATAL:
742
+ return error_reply(
743
+ envelope,
744
+ info.code or "fatal",
745
+ last_message,
746
+ retryable=False,
747
+ kind=info.kind.value,
748
+ meta=CallMeta(
749
+ served_model=step.alias,
750
+ served_target=step.target,
751
+ attempts=progress.attempts,
752
+ worker=deps.worker,
753
+ ),
754
+ )
755
+ if info.kind is ErrorKind.RETRYABLE:
756
+ await _record(deps, step, ok=False)
757
+ else: # a throttle belongs to the limiter: frees a half-open probe, no count
758
+ await _record(deps, step, ok=None)
759
+ # S35b: a random 30 % throttle storm failed calls after 3 tries (2.7 %);
760
+ # a throttled step gets more tries, each after a backoff without a slot.
761
+ budget = max(budget, config.throttle_retry_attempts)
762
+ if cap.expired():
763
+ if by_deadline: # the call's deadline cut it off: expired, not a provider error
764
+ raise _Stopped(ClaimState.EXPIRED)
765
+ if index + 1 < len(steps): # a hung target: the next one, not the same again
766
+ break
767
+ if attempt + 1 < budget:
768
+ delay = backoff_secs(
769
+ attempt, config.retry_base_secs, deps.rng, info.retry_after_secs
770
+ )
771
+ if deps.clock() + delay >= envelope.deadline_at:
772
+ break
773
+ await _check_live(envelope, deps, check_cancel)
774
+ await deps.sleep(delay)
775
+ if progress.attempts == attempts_before: # no step could run in this pass
776
+ if requeue and (secs_to_requeue := _requeue_secs(envelope, deps)) is not None:
777
+ raise _Requeue(secs_to_requeue)
778
+ if circuit_only and deps.clock() + _IDLE_RETRY_SECS < envelope.deadline_at:
779
+ await _check_live(envelope, deps, check_cancel)
780
+ await deps.sleep(_IDLE_RETRY_SECS)
781
+ continue
782
+ if (
783
+ envelope.lane is Lane.RT
784
+ and not circuit_only
785
+ and passes < RT_CAPACITY_PASSES
786
+ and deps.clock() + RT_ACQUIRE_SECS < envelope.deadline_at
787
+ ):
788
+ await _check_live(envelope, deps, check_cancel)
789
+ continue # every step waited RT_ACQUIRE_SECS for a slot: walk the chain again
790
+ return error_reply(
791
+ envelope,
792
+ last.code or last.kind.value,
793
+ last_message,
794
+ retryable=last.kind is not ErrorKind.FATAL, # every target refused the model
795
+ kind=last.kind.value,
796
+ retry_after_secs=last.retry_after_secs,
797
+ meta=CallMeta(attempts=progress.attempts, worker=deps.worker),
798
+ )
799
+
800
+
801
+ async def _release(deps: CoreDeps, lease: Lease, *, throttled: bool) -> None:
802
+ """Return a slot; a Valkey error is logged (the lease expires on its own, DS11)."""
803
+ try:
804
+ await deps.limiter.release(lease, throttled=throttled)
805
+ except Exception as exc:
806
+ logger.warning(
807
+ "limiter_release_failed",
808
+ model=lease.model,
809
+ target=lease.target,
810
+ error_type=type(exc).__name__,
811
+ )
812
+
813
+
814
+ async def _record(deps: CoreDeps, step: FallbackStep, *, ok: bool | None) -> None:
815
+ """Feed one outcome into the breaker (best effort); `ok=None` is a neutral throttle.
816
+
817
+ Only 5xx/timeouts count as failures: throttles are the limiter's job (AIMD, pause).
818
+ """
819
+ if deps.breaker is None:
820
+ return
821
+ try:
822
+ if ok is None:
823
+ await deps.breaker.record_neutral(step.alias, step.target)
824
+ else:
825
+ await deps.breaker.record(step.alias, step.target, ok=ok)
826
+ except Exception as exc:
827
+ logger.warning(
828
+ "breaker_record_failed",
829
+ model=step.alias,
830
+ target=step.target,
831
+ error_type=type(exc).__name__,
832
+ )