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.
- taskiq_llm_queue/__init__.py +138 -0
- taskiq_llm_queue/_logging.py +40 -0
- taskiq_llm_queue/_version.py +5 -0
- taskiq_llm_queue/broker/__init__.py +86 -0
- taskiq_llm_queue/broker/acker.py +239 -0
- taskiq_llm_queue/broker/batching.py +188 -0
- taskiq_llm_queue/broker/broker.py +937 -0
- taskiq_llm_queue/broker/delivery.py +50 -0
- taskiq_llm_queue/broker/errors.py +178 -0
- taskiq_llm_queue/broker/events.py +51 -0
- taskiq_llm_queue/broker/filters.py +32 -0
- taskiq_llm_queue/broker/heartbeat.py +157 -0
- taskiq_llm_queue/broker/queue.py +141 -0
- taskiq_llm_queue/broker/s3_offload.py +346 -0
- taskiq_llm_queue/broker/scheduler.py +113 -0
- taskiq_llm_queue/broker/serializer.py +32 -0
- taskiq_llm_queue/cli.py +488 -0
- taskiq_llm_queue/client.py +1409 -0
- taskiq_llm_queue/config.py +592 -0
- taskiq_llm_queue/context.py +62 -0
- taskiq_llm_queue/core.py +832 -0
- taskiq_llm_queue/envelope.py +136 -0
- taskiq_llm_queue/errors.py +71 -0
- taskiq_llm_queue/failover.py +286 -0
- taskiq_llm_queue/http_transport.py +220 -0
- taskiq_llm_queue/keys.py +87 -0
- taskiq_llm_queue/langchain/__init__.py +64 -0
- taskiq_llm_queue/langchain/_client.py +142 -0
- taskiq_llm_queue/langchain/bedrock.py +291 -0
- taskiq_llm_queue/langchain/openai.py +103 -0
- taskiq_llm_queue/lifecycle.py +512 -0
- taskiq_llm_queue/metrics/__init__.py +45 -0
- taskiq_llm_queue/metrics/emf.py +100 -0
- taskiq_llm_queue/metrics/prometheus.py +66 -0
- taskiq_llm_queue/metrics_names.py +19 -0
- taskiq_llm_queue/protocols.py +140 -0
- taskiq_llm_queue/providers/__init__.py +62 -0
- taskiq_llm_queue/providers/bedrock.py +188 -0
- taskiq_llm_queue/providers/classify.py +251 -0
- taskiq_llm_queue/providers/embeddings.py +87 -0
- taskiq_llm_queue/providers/hosts.py +34 -0
- taskiq_llm_queue/providers/openai_compat.py +142 -0
- taskiq_llm_queue/providers/registry.py +173 -0
- taskiq_llm_queue/py.typed +0 -0
- taskiq_llm_queue/reliability/__init__.py +59 -0
- taskiq_llm_queue/reliability/_lua.py +461 -0
- taskiq_llm_queue/reliability/breaker.py +86 -0
- taskiq_llm_queue/reliability/claims.py +226 -0
- taskiq_llm_queue/reliability/config_store.py +148 -0
- taskiq_llm_queue/reliability/fallback.py +111 -0
- taskiq_llm_queue/reliability/limiter.py +482 -0
- taskiq_llm_queue/reliability/local_limiter.py +153 -0
- taskiq_llm_queue/results/__init__.py +25 -0
- taskiq_llm_queue/results/backend.py +226 -0
- taskiq_llm_queue/results/dispatcher.py +366 -0
- taskiq_llm_queue/results/s3_backend.py +105 -0
- taskiq_llm_queue/routing.py +173 -0
- taskiq_llm_queue/routing_hooks.py +90 -0
- taskiq_llm_queue/tasks.py +453 -0
- taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
- taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
- taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
- taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
taskiq_llm_queue/core.py
ADDED
|
@@ -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
|
+
)
|