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,136 @@
1
+ """Wire contract: the task envelope sent through SQS and the result returned through Redis.
2
+
3
+ Both sides of a rolling deploy read these, so the format is versioned (`v`). A worker accepts
4
+ its own version and the previous one; anything newer raises `UnsupportedEnvelopeError` so the
5
+ worker releases the message instead of dropping it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import time
12
+ from typing import Any, Literal
13
+
14
+ import orjson
15
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
16
+
17
+ from taskiq_llm_queue.config import DEFAULT_ID_HASH_SALT, Lane
18
+ from taskiq_llm_queue.errors import UnsupportedEnvelopeError
19
+
20
+ ENVELOPE_VERSION = 1
21
+ TASK_CALL = "taskiq_llm_queue.call"
22
+ TASK_EMBED = "taskiq_llm_queue.embed"
23
+ TASK_JOB = "taskiq_llm_queue.job"
24
+
25
+ Op = Literal["converse", "invoke_model", "chat", "embeddings"]
26
+ FallbackPolicy = Literal["same_model", "chain"]
27
+
28
+
29
+ class Envelope(BaseModel):
30
+ """One LLM call as a task argument; `params` are provider-native and passed through as-is.
31
+
32
+ Large params travel by reference (`params_ref`, a Redis key) instead of inline.
33
+ """
34
+
35
+ model_config = ConfigDict(frozen=True, extra="forbid")
36
+
37
+ v: int = ENVELOPE_VERSION
38
+ request_id: str
39
+ parent_request_id: str | None = None
40
+ session_id: str | None = None
41
+ user_id: str | None = None
42
+ reply_to: str | None = None
43
+ lane: Lane = Lane.BG
44
+ op: Op
45
+ fallback: FallbackPolicy = "same_model"
46
+ model: str
47
+ params: dict[str, Any] | None = None
48
+ params_ref: str | None = None
49
+ est_tokens: int = Field(default=0, ge=0)
50
+ enqueued_at: float = Field(default_factory=time.time)
51
+ deadline_at: float
52
+ trace: dict[str, str] = Field(default_factory=dict)
53
+
54
+ @model_validator(mode="after")
55
+ def _params_xor_ref(self) -> Envelope:
56
+ if (self.params is None) == (self.params_ref is None):
57
+ raise ValueError("exactly one of params / params_ref must be set")
58
+ if self.deadline_at < self.enqueued_at:
59
+ raise ValueError("deadline_at is before enqueued_at")
60
+ return self
61
+
62
+
63
+ class CallMeta(BaseModel):
64
+ """How a call was actually served; surfaced as `response_metadata` / `ConverseResult.meta`."""
65
+
66
+ model_config = ConfigDict(frozen=True)
67
+
68
+ served_model: str = ""
69
+ served_target: str = ""
70
+ fallback_used: bool = False
71
+ queue_wait_ms: int = 0
72
+ provider_ms: int = 0
73
+ attempts: int = 0
74
+ worker: str = ""
75
+
76
+
77
+ class LLMReply(BaseModel):
78
+ """Result stored by the worker: the provider-native response, or a classified error."""
79
+
80
+ model_config = ConfigDict(frozen=True)
81
+
82
+ request_id: str
83
+ ok: bool
84
+ response: dict[str, Any] | None = None
85
+ meta: CallMeta = Field(default_factory=CallMeta)
86
+ error: dict[str, Any] | None = None
87
+ cancelled: bool = False
88
+
89
+
90
+ def parse_envelope(data: bytes | str | dict[str, Any]) -> Envelope:
91
+ """Decode an envelope, enforcing the version rule before field validation.
92
+
93
+ Raises `UnsupportedEnvelopeError` for a newer version and `ValueError` for malformed input.
94
+ """
95
+ raw = orjson.loads(data) if isinstance(data, bytes | str) else data
96
+ if not isinstance(raw, dict):
97
+ raise ValueError("envelope is not a JSON object")
98
+ version = raw.get("v")
99
+ if not isinstance(version, int):
100
+ raise ValueError("envelope has no integer 'v'")
101
+ if version > ENVELOPE_VERSION:
102
+ raise UnsupportedEnvelopeError(f"envelope v{version} is newer than v{ENVELOPE_VERSION}")
103
+ if version < ENVELOPE_VERSION - 1:
104
+ raise ValueError(f"envelope v{version} is no longer supported")
105
+ return Envelope.model_validate(raw)
106
+
107
+
108
+ def keyed_digest(value: str, salt: str | bytes = DEFAULT_ID_HASH_SALT) -> bytes:
109
+ """8-byte keyed BLAKE2b of `value`; equal on every pod that shares `salt` (max 64 bytes).
110
+
111
+ Keyed, so ids cannot be recovered by hashing guesses without the salt.
112
+ """
113
+ key = salt.encode("utf-8") if isinstance(salt, str) else salt
114
+ return hashlib.blake2b(value.encode("utf-8"), digest_size=8, key=key).digest()
115
+
116
+
117
+ def hashed_id(value: str | None, salt: str | bytes = DEFAULT_ID_HASH_SALT) -> str | None:
118
+ """Stable, non-reversible id for labels, logs and metrics (raw ids stay in the body).
119
+
120
+ `salt` is `QueueConfig.id_hash_salt` (`LLMQ_ID_HASH_SALT`); set your own in production.
121
+ """
122
+ return None if value is None else keyed_digest(value, salt).hex()
123
+
124
+
125
+ def task_labels(
126
+ envelope: Envelope, *, hash_ids: bool = True, salt: str | bytes = DEFAULT_ID_HASH_SALT
127
+ ) -> dict[str, str]:
128
+ """Taskiq labels for one envelope: routing and context fields, ids hashed by default."""
129
+ labels = {"lane": envelope.lane.value, "model": envelope.model}
130
+ for key, value in (("session_id", envelope.session_id), ("user_id", envelope.user_id)):
131
+ shown = hashed_id(value, salt) if hash_ids else value
132
+ if shown is not None:
133
+ labels[key] = shown
134
+ if envelope.reply_to:
135
+ labels["reply_to"] = envelope.reply_to
136
+ return labels
@@ -0,0 +1,71 @@
1
+ """Exception tree and provider-error classification shared by every layer.
2
+
3
+ Callers catch one type, `LLMQueueError`; workers turn provider exceptions into `ErrorInfo` so
4
+ the limiter, retry and fallback logic never depend on SDK-specific exception classes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+
12
+
13
+ class LLMQueueError(Exception):
14
+ """Base class for every error this package raises to callers."""
15
+
16
+
17
+ class LLMQueueTimeout(LLMQueueError): # public name is part of the frozen API
18
+ """The caller waited longer than its lane timeout or deadline for a result."""
19
+
20
+
21
+ class LLMQueueCancelled(LLMQueueError):
22
+ """The call was cancelled (`LLMQueue.cancel`, or its caller went away) before it finished."""
23
+
24
+
25
+ class QueueUnavailable(LLMQueueError): # public name is part of the frozen API
26
+ """SQS or Redis/Valkey could not be reached and no fallback path was allowed."""
27
+
28
+
29
+ class ConfigError(LLMQueueError):
30
+ """Invalid configuration, raised at startup (never on a live request path)."""
31
+
32
+
33
+ class UnsupportedEnvelopeError(LLMQueueError):
34
+ """A message carries an envelope version newer than this worker understands.
35
+
36
+ Workers release such messages for another pod instead of acking them, so a rolling deploy
37
+ never drops work.
38
+ """
39
+
40
+
41
+ class ProviderError(LLMQueueError):
42
+ """The provider call failed after the worker's retries and fallbacks were exhausted."""
43
+
44
+ def __init__(
45
+ self, message: str, *, retryable: bool, retry_after_secs: float | None = None
46
+ ) -> None:
47
+ """Keep whether the caller may retry later and any provider-advised wait."""
48
+ super().__init__(message)
49
+ self.retryable = retryable
50
+ self.retry_after_secs = retry_after_secs
51
+
52
+
53
+ class ErrorKind(StrEnum):
54
+ """How the worker reacts to a provider error: shrink limits, retry, or give up."""
55
+
56
+ THROTTLE = "throttle"
57
+ RETRYABLE = "retryable"
58
+ FATAL = "fatal"
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class ErrorInfo:
63
+ """Provider-neutral classification of one failed call, produced by `Provider.classify`.
64
+
65
+ `code` keeps the provider's own spelling for logs only; decisions use `kind`, because
66
+ fakes and real services spell the same failure differently.
67
+ """
68
+
69
+ kind: ErrorKind
70
+ code: str = ""
71
+ retry_after_secs: float | None = None
@@ -0,0 +1,286 @@
1
+ """SQS and Valkey failover state for the sender side (D73, D74, DS17, DS18).
2
+
3
+ A failed SQS send puts that model on `direct` for `sqs_failover_secs`; a Valkey failure puts the
4
+ whole pod on `direct` with a local per-pod cap until a health probe (or a backoff window) says
5
+ Valkey is back. `LLMQueue` calls the `FailoverPolicy` methods; nothing here blocks or raises.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import os
13
+ import threading
14
+ import time
15
+ from collections.abc import Awaitable, Callable, Mapping
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Literal
18
+
19
+ from taskiq_llm_queue._logging import get_logger
20
+ from taskiq_llm_queue.config import env_value
21
+ from taskiq_llm_queue.errors import ConfigError
22
+ from taskiq_llm_queue.metrics_names import FAILOVER_ACTIVE
23
+ from taskiq_llm_queue.reliability.limiter import local_cap
24
+ from taskiq_llm_queue.reliability.local_limiter import LocalLimiter
25
+
26
+ if TYPE_CHECKING:
27
+ from taskiq_llm_queue.metrics import MetricsSink
28
+
29
+ logger = get_logger(__name__)
30
+ Probe = Callable[[], Awaitable[object]]
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class FailoverSettings:
35
+ """Failover timings and the local cap (`LLMQ_SQS_FAILOVER_SECS`, `LLMQ_LOCAL_LIMIT_CAP`).
36
+
37
+ `live_pods` < 1 means unknown: the local cap is then `local_cap_fallback`, never
38
+ `last_known_limit` per pod, so an unknown pod count cannot overshoot the provider limit.
39
+ A running `LLMQueue` replaces it with the pod-registry count (`FailoverState.set_live_pods`).
40
+ """
41
+
42
+ sqs_failover_secs: float = 300.0
43
+ redis_retry_min_secs: float = 1.0
44
+ redis_retry_max_secs: float = 30.0
45
+ local_cap_fallback: int = 20
46
+ live_pods: int = 0
47
+
48
+ def __post_init__(self) -> None:
49
+ """Reject impossible values at construction (boot time)."""
50
+ if self.sqs_failover_secs <= 0 or self.local_cap_fallback < 1:
51
+ raise ConfigError("sqs_failover_secs must be > 0 and local_cap_fallback >= 1")
52
+ if not 0 < self.redis_retry_min_secs <= self.redis_retry_max_secs:
53
+ raise ConfigError("need 0 < redis_retry_min_secs <= redis_retry_max_secs")
54
+
55
+ @classmethod
56
+ def from_env(
57
+ cls, environ: Mapping[str, str] | None = None, *, live_pods: int = 0
58
+ ) -> FailoverSettings:
59
+ """Read `LLMQ_SQS_FAILOVER_SECS` and `LLMQ_LOCAL_LIMIT_CAP`; bad values raise `ConfigError`.
60
+
61
+ The deprecated `LLMQ_SQS_FAILOVER_S` is still read (warned once).
62
+ """
63
+ env = os.environ if environ is None else environ
64
+ values: dict[str, str] = {}
65
+ errors: list[str] = []
66
+ for name, conv in (("SQS_FAILOVER_SECS", float), ("LOCAL_LIMIT_CAP", int)):
67
+ raw = env_value(env, name)
68
+ if raw is None:
69
+ continue
70
+ try:
71
+ conv(raw)
72
+ except ValueError as exc:
73
+ errors.append(f"LLMQ_{name}: {exc}")
74
+ values[name] = raw
75
+ if errors:
76
+ raise ConfigError("; ".join(errors))
77
+ return cls(
78
+ sqs_failover_secs=float(values.get("SQS_FAILOVER_SECS", 300.0)),
79
+ local_cap_fallback=int(values.get("LOCAL_LIMIT_CAP", 20)),
80
+ live_pods=live_pods,
81
+ )
82
+
83
+
84
+ class FailoverState:
85
+ """`FailoverPolicy`: per-model SQS failover windows and pod-wide Valkey-down mode.
86
+
87
+ With a `probe` (e.g. `client.ping`) Valkey-down lasts until a probe succeeds, retried with
88
+ exponential backoff on the running loop. Without one, or from a thread with no loop, the
89
+ mode lapses after the backoff window so the next call tries Valkey again.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ settings: FailoverSettings | None = None,
95
+ *,
96
+ probe: Probe | None = None,
97
+ metrics: MetricsSink | None = None,
98
+ clock: Callable[[], float] = time.monotonic,
99
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
100
+ ) -> None:
101
+ """Keep settings, the optional Valkey probe, metrics sink and injected clock/sleep."""
102
+ self.settings = settings or FailoverSettings()
103
+ self._probe = probe
104
+ self._metrics = metrics
105
+ self._clock = clock
106
+ self._sleep = sleep
107
+ self._lock = threading.Lock()
108
+ self._sqs_until: dict[str, float] = {}
109
+ self._redis_down = False
110
+ self._redis_retry_at = 0.0
111
+ self._redis_backoff = self.settings.redis_retry_min_secs
112
+ self._probe_task: asyncio.Task[None] | None = None
113
+ self._limits: dict[tuple[str, str], int] = {}
114
+ self._local: dict[tuple[str, str], LocalLimiter] = {}
115
+ # Local limiters with calls still in flight when Valkey came back; reused if it fails
116
+ # again before they finish, so a flap never starts a fresh cap on top of busy slots.
117
+ self._busy: dict[tuple[str, str], LocalLimiter] = {}
118
+ self._live_pods: int | None = None
119
+
120
+ # ------------------------------------------------------------------ SQS
121
+
122
+ def path_override(self, model: str) -> Literal["direct"] | None:
123
+ """`direct` while Valkey is down or this model's SQS failover window is open, else None."""
124
+ if self.redis_down() or self.sqs_failover_active(model):
125
+ return "direct"
126
+ return None
127
+
128
+ def report_sqs_failure(self, model: str, exc: BaseException) -> None:
129
+ """Open (or extend) the model's SQS failover window after a failed or ambiguous send."""
130
+ alias = model.partition("@")[0]
131
+ with self._lock:
132
+ entering = alias not in self._sqs_until
133
+ self._sqs_until[alias] = self._clock() + self.settings.sqs_failover_secs
134
+ if entering:
135
+ logger.error(
136
+ "sqs_failover_active",
137
+ model=alias,
138
+ failover_secs=self.settings.sqs_failover_secs,
139
+ error_type=type(exc).__name__,
140
+ )
141
+ self._gauge(1.0, {"model": alias, "kind": "sqs"})
142
+
143
+ def sqs_failover_active(self, model: str) -> bool:
144
+ """True while `model` (alias or `alias@target`) is inside its failover window."""
145
+ alias = model.partition("@")[0]
146
+ with self._lock:
147
+ until = self._sqs_until.get(alias)
148
+ if until is None:
149
+ return False
150
+ if self._clock() < until:
151
+ return True
152
+ del self._sqs_until[alias]
153
+ logger.warning("sqs_failover_ended", model=alias)
154
+ self._gauge(0.0, {"model": alias, "kind": "sqs"})
155
+ return False
156
+
157
+ # ------------------------------------------------------------------ Valkey
158
+
159
+ def report_redis_failure(self, exc: BaseException) -> None:
160
+ """Enter (or stay in) Valkey-down mode and start the health probe if one is configured."""
161
+ with self._lock:
162
+ entering = not self._redis_down
163
+ if entering:
164
+ self._redis_backoff = self.settings.redis_retry_min_secs
165
+ else:
166
+ self._redis_backoff = min(
167
+ self._redis_backoff * 2, self.settings.redis_retry_max_secs
168
+ )
169
+ self._redis_down = True
170
+ self._redis_retry_at = self._clock() + self._redis_backoff
171
+ if entering:
172
+ logger.error("valkey_local_mode_active", error_type=type(exc).__name__)
173
+ self._gauge(1.0, {"kind": "redis"})
174
+ self._start_probe()
175
+
176
+ def report_redis_ok(self) -> None:
177
+ """Leave Valkey-down mode (a probe or a real call succeeded).
178
+
179
+ Idle local limiters are dropped (rebuilt per outage); busy ones are kept aside so the
180
+ in-flight calls stay counted if Valkey fails again before they finish.
181
+ """
182
+ with self._lock:
183
+ if not self._redis_down:
184
+ return
185
+ self._redis_down = False
186
+ self._redis_backoff = self.settings.redis_retry_min_secs
187
+ self._busy = {k: lim for k, lim in self._local.items() if lim.inflight(*k) > 0}
188
+ self._local.clear()
189
+ logger.warning("valkey_local_mode_ended")
190
+ self._gauge(0.0, {"kind": "redis"})
191
+
192
+ def redis_down(self) -> bool:
193
+ """True while Valkey is considered down (probe running, or inside the backoff window)."""
194
+ with self._lock:
195
+ if not self._redis_down:
196
+ return False
197
+ probing = self._probe_task is not None and not self._probe_task.done()
198
+ return probing or self._clock() < self._redis_retry_at
199
+
200
+ async def probe_redis(self) -> bool:
201
+ """Run the probe once; success leaves Valkey-down mode. False when no probe is set."""
202
+ if self._probe is None:
203
+ return False
204
+ try:
205
+ await self._probe()
206
+ except Exception as exc: # any failure of the probe means "still down"
207
+ logger.debug("valkey_probe_failed", error_type=type(exc).__name__)
208
+ return False
209
+ self.report_redis_ok()
210
+ return True
211
+
212
+ async def _probe_loop(self) -> None:
213
+ delay = self.settings.redis_retry_min_secs
214
+ while self._redis_down:
215
+ await self._sleep(delay)
216
+ if await self.probe_redis():
217
+ return
218
+ delay = min(delay * 2, self.settings.redis_retry_max_secs)
219
+
220
+ def _start_probe(self) -> None:
221
+ if self._probe is None:
222
+ return
223
+ try:
224
+ loop = asyncio.get_running_loop()
225
+ except RuntimeError: # sync caller in a thread: the backoff window covers it
226
+ return
227
+ with self._lock:
228
+ if self._probe_task is None or self._probe_task.done():
229
+ self._probe_task = loop.create_task(self._probe_loop(), name="llmq-valkey-probe")
230
+
231
+ # ------------------------------------------------------------------ local cap
232
+
233
+ def note_limit(self, model: str, target: str, limit: int | None) -> None:
234
+ """Remember the last shared limit seen for a model target (feeds the local cap)."""
235
+ if limit is not None:
236
+ with self._lock:
237
+ self._limits[(model, target)] = limit
238
+
239
+ def set_live_pods(self, count: int) -> None:
240
+ """Live pod count from the pod registry; feeds the next local cap (DS18)."""
241
+ if count >= 1:
242
+ with self._lock:
243
+ self._live_pods = count
244
+
245
+ @property
246
+ def live_pods(self) -> int:
247
+ """Pods sharing the provider limit: the registry count, else `settings.live_pods`."""
248
+ with self._lock:
249
+ live = self._live_pods
250
+ return self.settings.live_pods if live is None else live
251
+
252
+ def local_cap(self, model: str, target: str) -> int:
253
+ """Per-pod cap while Valkey is down: last known limit / live pods, else the fallback."""
254
+ with self._lock:
255
+ last = self._limits.get((model, target))
256
+ return local_cap(last, self.live_pods, self.settings.local_cap_fallback)
257
+
258
+ def local_limiter(self, model: str, target: str) -> LocalLimiter:
259
+ """The pod's `LocalLimiter` for a model target, built once per Valkey outage."""
260
+ key = (model, target)
261
+ with self._lock:
262
+ limiter = self._local.get(key)
263
+ if limiter is None and self._redis_down:
264
+ busy = self._busy.pop(key, None)
265
+ if busy is not None and busy.inflight(model, target) > 0:
266
+ limiter = self._local[key] = busy
267
+ if limiter is None:
268
+ limiter = LocalLimiter(self.local_cap(model, target), sleep=self._sleep)
269
+ with self._lock:
270
+ limiter = self._local.setdefault(key, limiter)
271
+ return limiter
272
+
273
+ async def aclose(self) -> None:
274
+ """Stop the probe task; safe to call twice."""
275
+ task, self._probe_task = self._probe_task, None
276
+ if task is not None:
277
+ task.cancel()
278
+ with contextlib.suppress(asyncio.CancelledError):
279
+ await task
280
+
281
+ def _gauge(self, value: float, labels: dict[str, str]) -> None:
282
+ if self._metrics is not None:
283
+ try:
284
+ self._metrics.gauge(FAILOVER_ACTIVE, value, labels)
285
+ except Exception: # metrics must never break failover
286
+ logger.exception("failover_metric_failed")