This commit is contained in:
yucheng-berri 2026-11-06 05:20:59 +00:00 • committed by GitHub
commit 2edfc192ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2560 additions and 39 deletions

View file

@ -16,6 +16,8 @@ import inspect
import itertools
import json
import logging
import math
import re
import threading
import time
import weakref
@ -24,6 +26,7 @@ from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import timedelta
from decimal import Decimal
from types import FrameType, MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar, cast
@ -140,10 +143,34 @@ _INCREMENT_IF_PRESENT_LUA: Final = (
)
_LUA_COUNT: Final = TypeAdapter(int)
_PLAIN_DECIMAL: Final = re.compile(r"-?[0-9]+(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?")
_PIPELINE_COMMAND_NUMBER: Final = re.compile(r"Command # ([0-9]+) \(")
_LUA_OPTIONAL_TOTAL: Final[TypeAdapter[float | None]] = TypeAdapter(float | None)
_OPTIONAL_COUNTS: Final = TypeAdapter(tuple[int | None, ...])
def _as_incrbyfloat_writes(value: float) -> str:
"""``value`` as INCRBYFLOAT writes it into a missing key: 17 decimal places with the trailing zeros cut."""
text: Final = format(Decimal(repr(value)), ".17f").rstrip("0").rstrip(".")
return "0" if text == "-0" else text
def _failed_after_the_first_command(error: Exception) -> bool:
"""Whether redis-py names a pipeline command after the first as the one that failed, so the first one applied."""
message: Final = error.args[0] if error.args else None
number: Final = _PIPELINE_COMMAND_NUMBER.match(message) if isinstance(message, str) else None
return number is not None and int(number.group(1)) > 1
def _plain_float(reply: object) -> float | None:
"""The finite float a stored value spells in plain decimal or exponent form, or None for anything else."""
text: Final = reply.decode(errors="replace") if isinstance(reply, bytes) else reply
if not isinstance(text, str) or _PLAIN_DECIMAL.fullmatch(text) is None:
return None
number: Final = float(text)
return number if math.isfinite(number) else None
def _decoded_counts(values: Sequence[bytes | str | None]) -> tuple[int | None, ...]:
return _OPTIONAL_COUNTS.validate_python(
tuple(value.decode("utf-8") if isinstance(value, bytes) else value for value in values)
@ -723,6 +750,11 @@ class RedisCache(BaseCache):
def _admit_async_call(self, call: Callable[[], Awaitable[_RedisCallResult]]) -> Awaitable[_RedisCallResult]:
return call()
@property
def circuit_breaker_closed(self) -> bool:
"""Whether calls reach Redis without a recovery probe. Reading it, unlike making a call, never starts one."""
return not self._circuit_breaker.enabled or self._circuit_breaker.state == RedisCircuitBreaker.CLOSED
def _setup_health_pings(self):
"""Setup async and sync health pings for Redis."""
# ASYNC HEALTH PING
@ -1482,15 +1514,69 @@ class RedisCache(BaseCache):
await _redis_client.expire(key, ttl)
return float(result)
async def _set_if_missing_and_get(self, key: str, value: float, ttl: int | None) -> tuple[bool, float] | None:
"""SET NX GET and its TTL command in one round trip: whether the key was created and the value it holds, or
None when the server refused SET NX GET rather than failed to answer, or the key holds a value this does not
read as INCRBYFLOAT would. SET NX GET is one command, so a refused one wrote nothing. A refused TTL command
raises, as it does in main's add."""
from redis.exceptions import ClusterError, RedisError
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
if not math.isfinite(value):
return None
stored: Final = _as_incrbyfloat_writes(value)
try:
async with self._async_commands().pipeline(transaction=False) as pipe:
pipe.set(name=key, value=stored, nx=True, get=True)
if ttl is not None:
pipe.expire(key, ttl)
replies: Final[Sequence[object]] = await pipe.execute() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # redis generics
except RedisError as e:
failed_to_answer: Final = isinstance(e, (RedisConnectionError, RedisTimeoutError, ClusterError))
if failed_to_answer or _failed_after_the_first_command(e):
raise
return None
if replies[0] is None:
return True, float(stored)
held: Final = _plain_float(replies[0]) # pyright: ignore[reportUnknownArgumentType] # redis generics
return None if held is None else (False, held)
async def _set_if_missing_with_ttl(
self,
_redis_client: "Redis[bytes]",
key: str,
value: float,
ttl: int | None,
refresh_ttl: bool,
on_added: Callable[[], None] | None,
) -> float:
"""The value the key holds after setting it if it was missing. A server that refuses the commands this sends,
such as a user denied SET or a Redis before 7.0, and a key holding a value INCRBYFLOAT might read differently,
get a plain add instead. ``on_added`` runs when this call added the value."""
written: Final = await self._set_if_missing_and_get(key=key, value=value, ttl=ttl)
current: Final = (
await self._incrbyfloat_with_ttl(_redis_client, key=key, value=value, ttl=ttl, refresh_ttl=refresh_ttl)
if written is None
else written[1]
)
if on_added is not None and (written is None or written[0]):
on_added()
return current
@_redis_circuit_breaker_guard
async def async_increment(
self,
key,
key: str,
value: float,
ttl: int | None = None,
parent_otel_span: Span | None = None,
refresh_ttl: bool = False,
only_if_missing: bool = False,
on_added: Callable[[], None] | None = None,
) -> float:
"""Add ``value`` to the key and return its new value. With ``only_if_missing``, a key that exists keeps its
value, which is returned, and only the TTL is refreshed, and ``on_added`` runs if this call added the value."""
from redis.asyncio import Redis
_redis_client: Final[Redis] = self.init_async_client()
@ -1498,8 +1584,19 @@ class RedisCache(BaseCache):
_used_ttl: Final = self.get_ttl(ttl=ttl)
key = self.check_and_fix_namespace(key=key)
try:
result: Final = await self._incrbyfloat_with_ttl(
_redis_client, key=key, value=value, ttl=_used_ttl, refresh_ttl=refresh_ttl
result: Final = await (
self._set_if_missing_with_ttl(
_redis_client, # pyright: ignore[reportUnknownArgumentType] # redis generics
key=key,
value=value,
ttl=_used_ttl,
refresh_ttl=refresh_ttl,
on_added=on_added,
)
if only_if_missing
else self._incrbyfloat_with_ttl(
_redis_client, key=key, value=value, ttl=_used_ttl, refresh_ttl=refresh_ttl
)
)
## LOGGING ##

View file

@ -1942,6 +1942,8 @@ SPEND_LOG_QUEUE_POLL_INTERVAL: Final = get_env_float("SPEND_LOG_QUEUE_POLL_INTER
RESPONSES_SESSION_LOOKUP_MAX_ATTEMPTS: Final = max(1, get_env_int("RESPONSES_SESSION_LOOKUP_MAX_ATTEMPTS", 3))
RESPONSES_SESSION_LOOKUP_RETRY_INTERVAL: Final = get_env_float("RESPONSES_SESSION_LOOKUP_RETRY_INTERVAL", 0.2)
SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE: Final = get_env_int("SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE", 10000)
SPEND_COUNTER_COLD_SEED_JOIN_SECONDS: Final = 0.05
SPEND_COUNTER_INCRBYFLOAT_ROUNDING: Final = 1e-16
PROXY_DB_LOOKUP_MAX_CONCURRENCY: Final = max(1, get_env_int("PROXY_DB_LOOKUP_MAX_CONCURRENCY", 25))
PROXY_DB_LOOKUP_DEADLINE_SECONDS: Final = max(0.1, get_env_float("PROXY_DB_LOOKUP_DEADLINE_SECONDS", 10.0))
PROXY_DB_LOOKUP_STALL_WINDOW_SECONDS: Final = max(0.0, get_env_float("PROXY_DB_LOOKUP_STALL_WINDOW_SECONDS", 30.0))

View file

@ -13,22 +13,31 @@ memory in long-lived deployments.
"""
import asyncio
import functools
from collections import OrderedDict
from collections.abc import Mapping
from collections.abc import Awaitable, Callable, Coroutine, Mapping
from contextvars import ContextVar, copy_context
from dataclasses import dataclass
from datetime import datetime, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, ClassVar, Final, Optional
from litellm._internal_context import with_service_target
from litellm._logging import verbose_proxy_logger
from litellm.constants import SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE
from litellm.constants import (
SPEND_COUNTER_COLD_SEED_JOIN_SECONDS,
SPEND_COUNTER_INCRBYFLOAT_ROUNDING,
SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE,
)
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy._types import Litellm_EntityType
from litellm.proxy.db.db_lookup_gate import bounded_db_lookup, db_lookup_gate
from litellm.proxy.spend_tracking.spend_counter_batch import (
SPEND_COUNTERS_TARGET,
SpendCounterRead,
read_batched_spend_counter,
record_spend_counter_value,
spend_counter_tick,
)
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.project_repository import ProjectRepository
@ -68,6 +77,93 @@ _WINDOW_SPEND_LOG_FIELDS: Final[Mapping[str, str]] = MappingProxyType(
)
@dataclass(slots=True)
class _CachedSpendSeed:
"""This process adding its cached spend to one cold counter: the add in flight, the tick it started at, the tick
the last write of its spend landed at, and that spend."""
started_at: int = 0
landed_at: int = 0
landed_spend: float = 0.0
in_flight: asyncio.Future[float | None] | None = None
def on_this_loop(self) -> bool:
return self.in_flight is None or self.in_flight.get_loop() is asyncio.get_running_loop()
def run(self, add: Callable[[], Awaitable[float | None]]) -> Coroutine[object, object, float | None]:
"""Make ``add`` the add in flight from this call on, and return the coroutine that runs it and publishes its
result to the requests waiting on ``in_flight``, which ends cancelled when ``add`` fails or is cancelled."""
in_flight: Final[asyncio.Future[float | None]] = asyncio.get_running_loop().create_future()
self.started_at = spend_counter_tick()
self.in_flight = in_flight
async def add_and_publish() -> float | None:
try:
added: Final = await add()
in_flight.set_result(added)
return added
finally:
if self.in_flight is in_flight:
self.in_flight = None
in_flight.cancel()
return add_and_publish()
@functools.lru_cache(maxsize=SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE)
def _cached_spend_seed(spend_counter_cache: "DualCache", counter_key: str) -> _CachedSpendSeed:
return _CachedSpendSeed()
_cold_seed_waited: Final[ContextVar[float]] = ContextVar("cold_spend_seed_waited", default=0.0)
async def _wait_for_cold_seed(in_flight: asyncio.Future[float | None]) -> None:
"""Wait on ``in_flight`` for what this context has left of ``SPEND_COUNTER_COLD_SEED_JOIN_SECONDS``."""
loop: Final = asyncio.get_running_loop()
waited: Final = _cold_seed_waited.get()
started: Final = loop.time()
await asyncio.wait((in_flight,), timeout=max(0.0, SPEND_COUNTER_COLD_SEED_JOIN_SECONDS - waited))
_cold_seed_waited.set(waited + loop.time() - started)
_late_writes: Final[set[asyncio.Task[float | None]]] = set() # mutable-ok: asyncio only weak-refs pending tasks
_late_write_abandoned: Final[ContextVar[asyncio.Event | None]] = ContextVar("late_spend_write_abandoned", default=None)
def late_write_abandoned() -> bool:
"""Whether this runs in a late write whose request was cancelled, which main's add would not have outlived."""
abandoned: Final = _late_write_abandoned.get()
return abandoned is not None and abandoned.is_set()
def _late_write_finished(task: asyncio.Task[float | None]) -> None:
_late_writes.discard(task)
if not task.cancelled():
task.exception()
async def _landing_even_if_cancelled(write: Coroutine[object, object, float | None]) -> float | None:
"""Await ``write`` in a task of its own, so cancelling the caller leaves it to land as a command already sent does,
and marks it abandoned from then on, so ``late_write_abandoned`` holds inside it."""
abandoned: Final = asyncio.Event()
context: Final = copy_context()
context.run(_late_write_abandoned.set, abandoned)
task: Final = context.run(asyncio.ensure_future, write)
_late_writes.add(task)
task.add_done_callback(_late_write_finished)
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
abandoned.set()
raise
def _redis_breaker_closed(spend_counter_cache: "DualCache") -> bool:
redis_cache: Final = spend_counter_cache.redis_cache
return redis_cache is None or redis_cache.circuit_breaker_closed
def _as_utc(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
@ -112,15 +208,97 @@ class SpendCounterReseed:
SpendCounterReseed._locks.popitem(last=False)
return lock
@staticmethod
async def add_cached_spend_once(
spend_counter_cache: "DualCache",
counter_key: str,
read: SpendCounterRead,
spend: float,
add: Callable[[], Awaitable[float | None]],
set_if_missing: Callable[[Callable[[], None]], Awaitable[float | None]],
) -> None:
"""Run ``add``, this process adding ``spend`` to a cold counter, once for all of this process's requests that
read the counter cold before that add landed. A request joins an add that started after its read came back and
gets its result, and starts or joins another add when that one fails, is cancelled or wrote nothing after its
read went out. A request whose read went out before an add landed, and one still waiting on an add after
``SPEND_COUNTER_COLD_SEED_JOIN_SECONDS`` across its counters, runs ``set_if_missing``, which calls its argument
if it added the spend, in place of ``add``, then runs ``add`` too if it did not and the counter holds less than
the spend it should already hold, beyond what INCRBYFLOAT's 17 decimal places can round away.
Those two steps finish even if the request is cancelled, and stay the add in flight until they do, but once it is
cancelled a failure in them leaves the counter as main's cancelled add does, without the DEL. A request on
another event loop than the add in flight, one that finds the Redis circuit breaker not closed, and each other
process, still run ``add``."""
seed: Final = _cached_spend_seed(spend_counter_cache, counter_key)
def spend_landed() -> None:
seed.landed_at = spend_counter_tick()
seed.landed_spend = spend
async def add_spend() -> float | None:
added: Final = await add()
spend_landed()
return added
async def write_if_short() -> float | None:
least: Final = min(spend, seed.landed_spend) if seed.landed_at >= read.sent_at else spend
set_added: Final = asyncio.Event()
def on_added() -> None:
set_added.set()
spend_landed()
current: Final = await set_if_missing(on_added)
if current is None or set_added.is_set():
return current
return await add_spend() if least - current > SPEND_COUNTER_INCRBYFLOAT_ROUNDING else current
async def add_if_short() -> float | None:
return await _landing_even_if_cancelled(seed.run(write_if_short))
async def add_unless_covered() -> float | None:
in_flight: Final = seed.in_flight
if not seed.on_this_loop() or not _redis_breaker_closed(spend_counter_cache):
return await add()
if in_flight is None:
return await (seed.run(add_spend) if seed.landed_at < read.sent_at else add_if_short())
covers: Final = seed.started_at > read.returned_at
await _wait_for_cold_seed(in_flight)
if not in_flight.done():
return await add_if_short()
if covers and not in_flight.cancelled() and seed.landed_at >= read.sent_at:
return in_flight.result()
return await add_unless_covered()
added: Final = await add_unless_covered()
if added is not None:
record_spend_counter_value(counter_key, added)
@staticmethod
@with_service_target(SPEND_COUNTERS_TARGET)
async def increment_in_memory(spend_counter_cache: "DualCache", counter_key: str, increment: float) -> float | None:
"""Apply local deltas after an in-flight reseed establishes the spend balance."""
async def increment_in_memory(
spend_counter_cache: "DualCache",
counter_key: str,
increment: float,
only_if_missing: bool = False,
on_added: Callable[[], None] | None = None,
) -> float | None:
"""Apply local deltas after an in-flight reseed establishes the spend balance. With ``only_if_missing``, a
counter that exists keeps its value, which is returned, and ``on_added`` runs if this call added to it."""
lock: Final = await SpendCounterReseed._get_lock(counter_key)
async with lock:
return await spend_counter_cache.async_increment_cache(
current: Final[float | None] = ( # pyright: ignore[reportUnknownVariableType] # untyped in-memory cache
spend_counter_cache.in_memory_cache.get_cache(key=counter_key) # pyright: ignore[reportUnknownMemberType] # untyped in-memory cache
if only_if_missing
else None
)
if current is not None:
return float(current)
added: Final = await spend_counter_cache.async_increment_cache(
key=counter_key, value=increment, local_only=True, refresh_ttl=True
)
if on_added is not None:
on_added()
return added
@staticmethod
async def from_db(prisma_client: Optional["PrismaClient"], counter_key: str) -> float | None:

View file

@ -682,7 +682,7 @@ from litellm.proxy.db.request_error_tracking import (
flush_request_errors,
request_error_accumulator,
)
from litellm.proxy.db.spend_counter_reseed import END_USER_COUNTER_PREFIX, SpendCounterReseed
from litellm.proxy.db.spend_counter_reseed import END_USER_COUNTER_PREFIX, SpendCounterReseed, late_write_abandoned
from litellm.proxy.discovery_endpoints import (
agent_skills_discovery_router,
ui_discovery_endpoints_router,
@ -981,6 +981,7 @@ from litellm.proxy.spend_tracking.spend_counter_batch import (
read_batched_spend_counter,
record_spend_counter_value,
spend_counter_batch_scope,
spend_counter_read,
)
from litellm.proxy.spend_tracking.spend_management_endpoints import (
router as spend_management_router,
@ -4299,6 +4300,7 @@ async def ensure_spend_counter_initialized(
) -> None:
is_warm: Final = await _is_spend_counter_cache_warm(counter_key=counter_key)
if is_warm is False:
read: Final = spend_counter_read(counter_key)
# Shares the per-counter lock with get_current_spend.
db_spend: Final = await SpendCounterReseed.coalesced(
prisma_client=prisma_client,
@ -4310,7 +4312,16 @@ async def ensure_spend_counter_initialized(
# DB unavailable - fall back to in-process cache (may be stale).
base_spend: Final = await _get_source_cache_base_spend(source_cache_key=source_cache_key)
if base_spend > 0:
await increment_spend_counter_cache(counter_key=counter_key, increment=base_spend)
await SpendCounterReseed.add_cached_spend_once(
spend_counter_cache=spend_counter_cache,
counter_key=counter_key,
read=read,
spend=base_spend,
add=lambda: increment_spend_counter_cache(counter_key=counter_key, increment=base_spend),
set_if_missing=lambda on_added: increment_spend_counter_cache(
counter_key=counter_key, increment=base_spend, only_if_missing=True, on_added=on_added
),
)
_ensure_spend_counter_initialized: Final = ensure_spend_counter_initialized
@ -4413,16 +4424,32 @@ async def refresh_spend_counter_ttl(counter_key: str) -> bool:
@with_service_target(SPEND_COUNTERS_TARGET)
async def increment_spend_counter_cache(counter_key: str, increment: float) -> float | None:
async def increment_spend_counter_cache(
counter_key: str,
increment: float,
only_if_missing: bool = False,
on_added: Callable[[], None] | None = None,
) -> float | None:
if spend_counter_cache.redis_cache is not None:
try:
current_value: Final = await spend_counter_cache.redis_cache.async_increment(
key=counter_key,
value=increment,
refresh_ttl=True,
current_value: Final = await (
spend_counter_cache.redis_cache.async_increment(
key=counter_key,
value=increment,
refresh_ttl=True,
only_if_missing=True,
on_added=on_added,
)
if only_if_missing
else spend_counter_cache.redis_cache.async_increment(
key=counter_key,
value=increment,
refresh_ttl=True,
)
)
except Exception:
await invalidate_spend_counter(counter_key=counter_key)
if not late_write_abandoned():
await invalidate_spend_counter(counter_key=counter_key)
raise
spend_counter_cache.in_memory_cache.set_cache(
key=counter_key,
@ -4432,7 +4459,11 @@ async def increment_spend_counter_cache(counter_key: str, increment: float) -> f
return current_value
return await SpendCounterReseed.increment_in_memory(
spend_counter_cache=spend_counter_cache, counter_key=counter_key, increment=increment
spend_counter_cache=spend_counter_cache,
counter_key=counter_key,
increment=increment,
only_if_missing=only_if_missing,
on_added=on_added,
)

View file

@ -1,11 +1,12 @@
"""One Redis MGET per phase (admission, reservation, post-call) for the spend counters it reads, not one GET each."""
import asyncio
import itertools
from collections.abc import Iterator, Mapping, Sequence
from contextvars import ContextVar, Token
from dataclasses import dataclass
from types import MappingProxyType, TracebackType
from typing import Final
from typing import Final, TypeAlias
from pydantic import TypeAdapter
@ -22,6 +23,24 @@ from litellm.proxy.common_utils.user_api_key_cache import (
_CounterValues: Final = TypeAdapter(dict[str, float | None])
_NO_VALUES: Final[Mapping[str, float | None]] = MappingProxyType({})
SPEND_COUNTERS_TARGET: Final = "spend_counters"
_ticks: Final = itertools.count(1)
def spend_counter_tick() -> int:
"""The next number in a per-process sequence that orders spend counter reads and cold seeds."""
return next(_ticks)
@dataclass(frozen=True, slots=True)
class SpendCounterRead:
"""When a read of a spend counter went out and when its reply came back, as spend counter ticks."""
sent_at: int
returned_at: int
_NO_READS: Final[Mapping[str, SpendCounterRead]] = MappingProxyType({})
_PendingMGet: TypeAlias = tuple[BatchResult[Mapping[str, object]], int]
@dataclass(frozen=True, slots=True)
@ -39,7 +58,17 @@ class SpendCounterBatch:
hook declares whatever is bound but unread, so whoever flushes first (the auth object prefetch, usually)
carries the spend counters in the same round trip."""
__slots__ = ("_fetched", "_inflight", "_keys", "_loaded", "_lock", "_open", "_redis_cache", "_request_batch")
__slots__ = (
"_fetched",
"_inflight",
"_keys",
"_loaded",
"_lock",
"_open",
"_reads",
"_redis_cache",
"_request_batch",
)
def __init__(self, redis_cache: RedisCache) -> None:
self._redis_cache: Final = redis_cache
@ -48,7 +77,8 @@ class SpendCounterBatch:
self._keys: frozenset[str] = frozenset()
self._fetched: frozenset[str] = frozenset()
self._loaded: Mapping[str, float | None] = _NO_VALUES
self._inflight: Final[list[BatchResult[Mapping[str, object]]]] = [] # mutable-ok: drained by _load
self._reads: Mapping[str, SpendCounterRead] = _NO_READS
self._inflight: Final[list[_PendingMGet]] = [] # mutable-ok: drained by _load
self._request_batch: Final[RedisBatch | None] = active_request_redis_batch(redis_cache)
if self._request_batch is not None:
self._request_batch.add_flush_hook(self._declare_pending)
@ -78,6 +108,10 @@ class SpendCounterBatch:
return None
return loaded[counter_key], True
def counter_read(self, counter_key: str) -> SpendCounterRead | None:
"""When the MGET behind this batch's value for the counter went out and came back, None without that value."""
return self._reads.get(counter_key) if self._open and counter_key in self._loaded else None
def record(self, counter_key: str, value: float) -> None:
"""A write returned the counter's new value; later reads in this scope see it instead of the MGET value."""
if not self._open:
@ -93,6 +127,7 @@ class SpendCounterBatch:
self._keys = self._keys - key
self._fetched = self._fetched - key
self._loaded = MappingProxyType({k: v for k, v in self._loaded.items() if k != counter_key})
self._reads = MappingProxyType({k: v for k, v in self._reads.items() if k != counter_key})
async def _load(self) -> Mapping[str, float | None]:
async with self._lock:
@ -103,10 +138,15 @@ class SpendCounterBatch:
pending: Final = self._keys - self._fetched
if pending:
self._fetched = self._fetched | pending
sent_at: Final = spend_counter_tick()
fetched: Final = await self._fetch(pending)
self._loaded = MappingProxyType({**fetched, **self._loaded})
self._merge(fetched, SpendCounterRead(sent_at=sent_at, returned_at=spend_counter_tick()))
return self._loaded
def _merge(self, fetched: Mapping[str, float | None], read: SpendCounterRead) -> None:
self._loaded = MappingProxyType({**fetched, **self._loaded})
self._reads = MappingProxyType({**dict.fromkeys(fetched, read), **self._reads})
def _declare_pending(self) -> None:
"""Flush hook: put every bound-but-unread counter on the request pipeline that is about to go out."""
if self._request_batch is None or not self._open:
@ -114,19 +154,21 @@ class SpendCounterBatch:
pending: Final = self._keys - self._fetched
if pending:
self._fetched = self._fetched | pending
sent_at: Final = spend_counter_tick()
with service_target(SPEND_COUNTERS_TARGET):
self._inflight.append(self._request_batch.mget(sorted(pending)))
read: Final = self._request_batch.mget(sorted(pending))
self._inflight.append((read, sent_at))
async def _collect_inflight(self) -> None:
results: Final = tuple(self._inflight)
reads: Final = tuple(self._inflight)
self._inflight.clear()
for result in results:
for read, sent_at in reads:
try:
fetched: Mapping[str, float | None] = _CounterValues.validate_python(await result)
fetched: Mapping[str, float | None] = _CounterValues.validate_python(await read)
except Exception as e: # noqa: BLE001 # per-key reads take over and apply their own Redis fallback
verbose_proxy_logger.debug("spend counter batch read failed, falling back to per-key reads: %s", e)
continue
self._loaded = MappingProxyType({**fetched, **self._loaded})
self._merge(fetched, SpendCounterRead(sent_at=sent_at, returned_at=spend_counter_tick()))
async def _fetch(self, keys: frozenset[str]) -> Mapping[str, float | None]:
try:
@ -145,6 +187,17 @@ def active_spend_counter_batch() -> SpendCounterBatch | None:
return _active_batch.get()
def spend_counter_read(counter_key: str) -> SpendCounterRead:
"""When this context's read of the counter went out and came back: the open batch's MGET for it, else now, since
``SpendCounterReseed.coalesced`` reads the counter again after a read outside the batch."""
batch: Final = _active_batch.get()
batched: Final = batch.counter_read(counter_key) if batch is not None else None
if batched is not None:
return batched
now: Final = spend_counter_tick()
return SpendCounterRead(sent_at=now, returned_at=now)
class spend_counter_batch_scope:
"""Reads inside the scope share one MGET for the keys bound here or by ``bind_*`` calls inside it.
Opened inside a scope whose batch is still open, it binds into that batch so both phases share the MGET."""

View file

@ -1,17 +1,20 @@
import json
import os
import re
import uuid
from collections.abc import Callable, Mapping
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, Scenario, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.database import read_rows, write_rows
from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import JsonResponse
from pydantic import BaseModel, JsonValue
from redis import Redis
PROVIDER_AUTHORIZATION: Final = "Bearer integration-provider-key"
CANNED_CONTENT: Final = "Hello! This is a mock response from the fake OpenAI endpoint."
@ -649,3 +652,141 @@ def test_streamed_call_is_refused_as_json_once_over_budget(gateway: Gateway) ->
assert "data:" not in denied.text, denied.text
_assert_budget_refusal(denied, end_user, 0.05)
assert _observed_requests(upstream) == []
_COLD_BURST: Final = 40
_COLD_SEED_SPEND: Final = 6.0
_COLD_BUDGET: Final = 10.0
_CHAT_COST: Final = 20 * 0.001 + 20 * 0.002
def _serves_model(gateway: Gateway, model: str) -> bool:
response: Final = gateway.request("GET", "/model/info")
return response.status_code == 200 and any(
object_value(entry)["model_name"] == model for entry in object_value(response.json())["data"]
)
def _counter_cache() -> Redis:
return Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]))
def _counter(cache: Redis, end_user: str) -> float | None:
raw: Final = cache.get(f"spend:end_user:{end_user}")
return None if raw is None else float(raw)
def _lose_counter(cache: Redis, end_user: str) -> None:
cache.delete(f"spend:end_user:{end_user}")
def _set_end_user_row_spend(end_user: str, spend: float) -> None:
write_rows('UPDATE "LiteLLM_EndUserTable" SET spend = %s WHERE user_id = %s', (str(spend), end_user))
def _await_row_spend(end_user: str, spend: float) -> None:
eventually(
lambda: _end_user_row(end_user),
lambda rows: len(rows) == 1 and float(str(rows[0]["spend"])) == pytest.approx(spend),
seconds=70,
)
def _refused_spend(denied: httpx.Response, end_user: str) -> float:
error: Final = _denial_error(denied)
assert error["type"] == "budget_exceeded", denied.text
matched: Final = _REFUSAL_MESSAGE.match(string_value(error["message"]))
assert matched is not None, denied.text
assert matched.group("user") == end_user
return float(matched.group("spend"))
@pytest.mark.parametrize("spelling", ("user", "responses_user", "messages_metadata_user_id"))
def test_overlapping_cold_starts_count_the_end_user_spend_once(gateway: Gateway, spelling: str) -> None:
chosen: Final = SPELLINGS[spelling]
with (
gateway.scenario() as scenario,
httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream,
_counter_cache() as cache,
):
deployment: Final = _deployment(scenario, chosen)
key: Final = scenario.key(models=[deployment.model])
end_user: Final = f"integration-end-user-{uuid.uuid4().hex}"
_new_customer(gateway, end_user, max_budget=_COLD_BUDGET, spend=_COLD_SEED_SPEND)
scenario.cleanups.callback(gateway.request, "POST", "/customer/delete", {"user_ids": [end_user]})
assert _customer_info(gateway, end_user).spend == _COLD_SEED_SPEND
assert _counter(cache, end_user) is None
upstream.delete("/__observations").raise_for_status()
with ThreadPoolExecutor(max_workers=_COLD_BURST) as pool:
burst: Final = tuple(
pool.map(
lambda index: _send(gateway, chosen, deployment, key, end_user, f"cold {index}"),
range(_COLD_BURST),
)
)
refused: Final = [response.text for response in burst if response.status_code != 200]
assert refused == [], refused
follow_up: Final = _send(gateway, chosen, deployment, key, end_user, "cold follow-up")
_assert_served(chosen, follow_up)
assert len(_observed_requests(upstream)) == _COLD_BURST + 1
expected_spend: Final = _COLD_SEED_SPEND + (_COLD_BURST + 1) * _CHAT_COST
_await_row_spend(end_user, expected_spend)
assert _customer_info(gateway, end_user).spend == pytest.approx(expected_spend)
counted: Final = eventually(
lambda: _counter(cache, end_user),
lambda value: value is not None and value == pytest.approx(expected_spend),
seconds=30,
return_last_on_timeout=True,
)
assert counted == pytest.approx(expected_spend)
def test_a_lost_counter_keeps_spend_this_proxy_has_not_written_yet(gateway: Gateway) -> None:
with gateway.scenario() as scenario, _counter_cache() as cache:
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.25)
key: Final = scenario.key(models=[model])
end_user: Final = f"integration-end-user-{uuid.uuid4().hex}"
call_cost: Final = 20 * 0.001 + 20 * 0.25
_new_customer(gateway, end_user, max_budget=_COLD_BUDGET, spend=_COLD_SEED_SPEND)
scenario.cleanups.callback(gateway.request, "POST", "/customer/delete", {"user_ids": [end_user]})
served: Final = _chat(gateway, model, key, end_user)
assert served.status_code == 200, served.text
_await_row_spend(end_user, _COLD_SEED_SPEND + call_cost)
_set_end_user_row_spend(end_user, _COLD_SEED_SPEND)
_lose_counter(cache, end_user)
denied: Final = _chat(gateway, model, key, end_user)
assert _refused_spend(denied, end_user) == pytest.approx(_COLD_SEED_SPEND + call_cost)
def test_a_lost_counter_keeps_spend_another_proxy_has_not_written_yet(gateway: Gateway, peer: Gateway) -> None:
with gateway.scenario() as scenario, _counter_cache() as cache:
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.149)
eventually(lambda: _serves_model(peer, model), bool, seconds=30)
key: Final = scenario.key(models=[model])
end_user: Final = f"integration-end-user-{uuid.uuid4().hex}"
call_cost: Final = 20 * 0.001 + 20 * 0.149
_new_customer(gateway, end_user, max_budget=_COLD_BUDGET, spend=_COLD_SEED_SPEND)
scenario.cleanups.callback(gateway.request, "POST", "/customer/delete", {"user_ids": [end_user]})
first: Final = _chat(peer, model, key, end_user)
assert first.status_code == 200, first.text
_await_row_spend(end_user, _COLD_SEED_SPEND + call_cost)
_set_end_user_row_spend(end_user, _COLD_SEED_SPEND)
_lose_counter(cache, end_user)
second: Final = _chat(peer, model, key, end_user)
assert second.status_code == 200, second.text
expected_spend: Final = _COLD_SEED_SPEND + 2 * call_cost
counted: Final = eventually(
lambda: _counter(cache, end_user),
lambda value: value is not None and value == pytest.approx(expected_spend),
seconds=30,
return_last_on_timeout=True,
)
denied: Final = _chat(gateway, model, key, end_user)
assert counted == pytest.approx(expected_spend)
assert _denial_error(denied)["type"] == "budget_exceeded", denied.text

View file

@ -8,6 +8,10 @@ from typing import Final, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fakeredis import FakeAsyncRedis, FakeServer
from redis.exceptions import ClusterDownError, NoPermissionError, RedisError, ResponseError
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
import litellm
from litellm._service_logger import ServiceLogging
@ -1033,8 +1037,9 @@ def test_prometheus_services_logger_starts_from_the_breakers_already_built(monke
monkeypatch.setattr(litellm, "service_callback", ["prometheus_system"])
gc.collect()
ServiceLogging()
assert REGISTRY.get_sample_value("litellm_redis_circuit_breaker_state", {"state": "closed"}) == (
redis_breaker_state_counts()["closed"]
assert (
REGISTRY.get_sample_value("litellm_redis_circuit_breaker_state", {"state": "closed"})
== (redis_breaker_state_counts()["closed"])
)
@ -1474,6 +1479,411 @@ async def test_async_increment_pipelines_the_ttl_command(
assert client.round_trips == expected_round_trips
@pytest.mark.asyncio
@pytest.mark.parametrize(
("existing", "expected"),
[
pytest.param(None, 1.5, id="missing key: set to the value"),
pytest.param(0.25, 0.25, id="key below the value: kept"),
pytest.param(4.0, 4.0, id="key above the value: kept"),
],
)
async def test_async_increment_only_if_missing_sets_a_missing_key_and_refreshes_the_ttl(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object, existing: float | None, expected: float
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
client: Final = FakeAsyncRedis(server=FakeServer())
if existing is not None:
await client.set("ns:spend:key:k", existing, ex=5)
with patch.object(redis_cache, "init_async_client", return_value=client):
current: Final = await redis_cache.async_increment(
key="spend:key:k", value=1.5, ttl=60, refresh_ttl=True, only_if_missing=True
)
after_add: Final = await redis_cache.async_increment(key="spend:key:k", value=2.0, ttl=60, refresh_ttl=True)
assert current == expected
assert after_add == expected + 2.0
assert await client.ttl("ns:spend:key:k") == 60
@pytest.mark.asyncio
@pytest.mark.parametrize(
("value", "stored"),
[(6.0, b"6"), (1e-05, b"0.00001"), (6.000000002, b"6.000000002"), (0.1, b"0.1"), (1e-20, b"0"), (-0.0, b"0")],
)
async def test_async_increment_only_if_missing_stores_a_missing_key_the_way_an_add_stores_it(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object, value: float, stored: bytes
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
client: Final = FakeAsyncRedis(server=FakeServer())
with patch.object(redis_cache, "init_async_client", return_value=client):
current: Final = await redis_cache.async_increment(key="spend:key:k", value=value, ttl=60, only_if_missing=True)
assert await client.get("ns:spend:key:k") == stored
assert current == float(stored)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("existing", "reported"), [pytest.param(None, ["created"], id="created"), pytest.param(7.0, [], id="kept")]
)
async def test_async_increment_only_if_missing_reports_only_a_key_it_created(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object, existing: float | None, reported: list[str]
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
client: Final = FakeAsyncRedis(server=FakeServer())
if existing is not None:
await client.set("ns:spend:key:k", existing)
created: Final[list[str]] = []
with patch.object(redis_cache, "init_async_client", return_value=client):
await redis_cache.async_increment(
key="spend:key:k", value=1.5, ttl=60, only_if_missing=True, on_added=lambda: created.append("created")
)
assert created == reported
class _RaisingPipeline:
"""A pipeline whose execute raises the first error in its replies, as redis-py's does by default."""
def __init__(self, replies: list[object]) -> None:
self.replies = replies
self.commands: list[tuple[object, ...]] = []
async def __aenter__(self) -> "_RaisingPipeline":
return self
async def __aexit__(self, *exc_info: object) -> None:
return None
def set(self, name: str, value: str, nx: bool = False, get: bool = False) -> None:
self.commands.append(("SET", name, value, nx, get))
def incrbyfloat(self, name: str, amount: float) -> None:
self.commands.append(("INCRBYFLOAT", name, amount))
def expire(self, name: str, ttl: int) -> None:
self.commands.append(("EXPIRE", name, ttl))
async def execute(self) -> list[object]:
first_error: Final = next((reply for reply in self.replies if isinstance(reply, Exception)), None)
if first_error is not None:
raise first_error
return self.replies
class _ClusterPipelineAfterADroppedConnection(_RaisingPipeline):
"""The first attempt reads a dropped connection. As in redis-py 5.3.1's ClusterPipeline.execute, raising is what
drives the retry, and ``raise_on_error=False`` hands the error back as a reply instead."""
async def execute(self, raise_on_error: bool = True) -> list[object]:
if not raise_on_error:
return [RedisConnectionError("Connection closed by server."), self.replies[1]]
return self.replies
@pytest.mark.asyncio
async def test_async_increment_only_if_missing_keeps_the_cluster_retry_of_a_dropped_connection(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
client: Final = MagicMock()
client.pipeline.return_value = _ClusterPipelineAfterADroppedConnection([None, True])
with patch.object(redis_cache, "init_async_client", return_value=client):
current: Final = await redis_cache.async_increment(
key="spend:key:k", value=1.5, ttl=60, refresh_ttl=True, only_if_missing=True
)
assert current == 1.5
@pytest.mark.asyncio
@pytest.mark.parametrize(
"refusal",
[
pytest.param(NoPermissionError("no permissions to run the 'set' command"), id="denied"),
pytest.param(ResponseError("syntax error"), id="redis-before-7-has-no-set-nx-get"),
pytest.param(ResponseError("unknown command 'SET'"), id="renamed"),
pytest.param(RedisError("SET command doesn't exist in Redis commands"), id="renamed-on-a-cluster-client"),
],
)
async def test_async_increment_only_if_missing_adds_the_value_when_the_server_refuses_the_set(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object, refusal: Exception
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
add: Final = _RaisingPipeline([7.5, True])
client: Final = MagicMock()
client.pipeline.side_effect = [_RaisingPipeline([refusal, True]), add]
failure_hook: Final = AsyncMock()
added: Final = MagicMock()
with (
patch.object(redis_cache, "init_async_client", return_value=client),
patch.object(redis_cache.service_logger_obj, "async_service_failure_hook", failure_hook),
):
current: Final = await redis_cache.async_increment(
key="spend:key:k", value=1.5, ttl=60, refresh_ttl=True, only_if_missing=True, on_added=added
)
await asyncio.sleep(0)
assert current == 7.5
added.assert_called_once_with()
assert add.commands == [("INCRBYFLOAT", "ns:spend:key:k", 1.5), ("EXPIRE", "ns:spend:key:k", 60)]
failure_hook.assert_not_awaited()
assert redis_cache._circuit_breaker._hard_failure_count == 0
@pytest.mark.asyncio
async def test_async_increment_only_if_missing_reports_a_write_redis_refuses_with_the_add_and_its_error_text(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
add_error: Final = ResponseError("Command # 1 (INCRBYFLOAT ns:spend:key:k 1.5) of pipeline caused error")
client: Final = MagicMock()
client.pipeline.side_effect = [
_RaisingPipeline([ResponseError("Command # 1 (SET) of pipeline caused error"), True]),
_RaisingPipeline([add_error, True]),
]
failure_hook: Final = AsyncMock()
added: Final = MagicMock()
with (
patch.object(redis_cache, "init_async_client", return_value=client),
patch.object(redis_cache.service_logger_obj, "async_service_failure_hook", failure_hook),
pytest.raises(ResponseError) as raised,
):
await redis_cache.async_increment(
key="spend:key:k", value=1.5, ttl=60, refresh_ttl=True, only_if_missing=True, on_added=added
)
await asyncio.sleep(0)
assert raised.value is add_error
added.assert_not_called()
failure_hook.assert_awaited_once()
assert failure_hook.await_args.kwargs["error"] is add_error
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failure",
[
pytest.param(RedisConnectionError("Connection closed by server."), id="dropped-connection"),
pytest.param(RedisTimeoutError("Timeout reading from socket"), id="stalled-node"),
pytest.param(ClusterDownError("CLUSTERDOWN The cluster is down"), id="cluster-down"),
],
)
async def test_async_increment_only_if_missing_sends_no_add_after_a_failure_the_client_already_retried(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object, failure: Exception
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
client: Final = MagicMock()
client.pipeline.return_value = _RaisingPipeline([failure, True])
with patch.object(redis_cache, "init_async_client", return_value=client), pytest.raises(type(failure)):
await redis_cache.async_increment(key="spend:key:k", value=1.5, ttl=60, refresh_ttl=True, only_if_missing=True)
assert client.pipeline.call_count == 1
class _Keyspace:
"""String keys as Redis holds them, for pipelines that run each command in order and raise the first error after
running the rest, as a non-transaction pipeline does. Commands named in ``refused`` are refused, a pipeline that
has run ``writes_before_full`` write commands has the rest refused as Redis does past ``maxmemory``, and pipelines
after the first ``reached`` never reach Redis, as when the connection drops."""
def __init__(
self, refused: frozenset[str] = frozenset(), writes_before_full: int | None = None, reached: int | None = None
) -> None:
self.values: Final[dict[str, str]] = {} # mutable-ok: stands in for the Redis keyspace
self.refused = refused
self.writes_before_full = writes_before_full
self.reached = reached
self.pipelines = 0
def pipeline(self, **_: object) -> "_KeyspacePipeline":
self.pipelines += 1
return _KeyspacePipeline(self, lost=self.reached is not None and self.pipelines > self.reached)
class _KeyspacePipeline(_RaisingPipeline):
def __init__(self, keyspace: _Keyspace, lost: bool) -> None:
super().__init__([])
self.keyspace = keyspace
self.lost = lost
self.writes_left = keyspace.writes_before_full
def setnx(self, name: str, value: float) -> None:
self.commands.append(("SETNX", name, value))
def _write(self, command: str) -> None:
if self.writes_left is not None:
if self.writes_left == 0:
raise ResponseError("OOM command not allowed when used memory > 'maxmemory'.")
self.writes_left -= 1
def _run(self, command: tuple[object, ...]) -> object:
name: Final = str(command[1])
held: Final = self.keyspace.values.get(name)
if command[0] in self.keyspace.refused:
raise NoPermissionError(f"this user has no permissions to run the '{str(command[0]).lower()}' command")
if command[0] == "EXPIRE":
return held is not None
self._write(str(command[0]))
if command[0] == "SET":
if held is None:
self.keyspace.values[name] = str(command[2])
return None if held is None else held.encode()
if command[0] == "SETNX":
if held is None:
self.keyspace.values[name] = repr(command[2])
return held is None
total: Final = float(held or 0) + float(cast(float, command[2]))
self.keyspace.values[name] = repr(total)
return total
async def execute(self) -> list[object]:
if self.lost:
raise RedisConnectionError("Connection closed by server.")
replies: Final[list[object]] = []
for number, command in enumerate(self.commands, start=1):
try:
replies.append(self._run(command))
except RedisError as e:
words: str = " ".join(str(part) for part in command)
e.args = (f"Command # {number} ({words}) of pipeline caused error: {e.args}",)
replies.append(e)
first_error: Final = next((reply for reply in replies if isinstance(reply, Exception)), None)
if first_error is not None:
raise first_error
return replies
@pytest.mark.asyncio
async def test_async_increment_only_if_missing_leaves_a_missing_key_missing_when_the_set_is_refused_and_the_add_lost(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
keyspace: Final = _Keyspace(refused=frozenset(("SET", "SETNX")), reached=1)
with patch.object(redis_cache, "init_async_client", return_value=keyspace), pytest.raises(RedisConnectionError):
await redis_cache.async_increment(key="spend:key:k", value=6.0, ttl=60, refresh_ttl=True, only_if_missing=True)
assert keyspace.values == {}
@pytest.mark.asyncio
async def test_async_increment_only_if_missing_sets_a_missing_key_once_when_redis_runs_out_of_memory_after_the_set(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
keyspace: Final = _Keyspace(writes_before_full=1)
added: Final = MagicMock()
with patch.object(redis_cache, "init_async_client", return_value=keyspace):
current: Final = await redis_cache.async_increment(
key="spend:key:k", value=6.0, ttl=60, refresh_ttl=True, only_if_missing=True, on_added=added
)
assert current == 6.0
assert float(keyspace.values["ns:spend:key:k"]) == 6.0
assert keyspace.pipelines == 1
added.assert_called_once_with()
@pytest.mark.asyncio
async def test_async_increment_only_if_missing_raises_a_refused_ttl_command_as_mains_add_does_without_adding_again(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
keyspace: Final = _Keyspace(refused=frozenset(("EXPIRE",)))
added: Final = MagicMock()
with patch.object(redis_cache, "init_async_client", return_value=keyspace), pytest.raises(NoPermissionError):
await redis_cache.async_increment(
key="spend:key:k", value=6.0, ttl=60, refresh_ttl=True, only_if_missing=True, on_added=added
)
assert keyspace.values == {"ns:spend:key:k": "6"}
assert keyspace.pipelines == 1
added.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("held", ["not-a-number", "0x10", "6_0", " 6", "inf", "nan", "1e999"])
async def test_async_increment_only_if_missing_leaves_a_value_it_cannot_read_like_incrbyfloat_to_mains_add(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object, held: str
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
add: Final = _RaisingPipeline([7.5, True])
client: Final = MagicMock()
client.pipeline.side_effect = [_RaisingPipeline([held.encode(), True]), add]
with patch.object(redis_cache, "init_async_client", return_value=client):
current: Final = await redis_cache.async_increment(
key="spend:key:k", value=1.5, ttl=60, refresh_ttl=True, only_if_missing=True
)
assert current == 7.5
assert add.commands == [("INCRBYFLOAT", "ns:spend:key:k", 1.5), ("EXPIRE", "ns:spend:key:k", 60)]
@pytest.mark.asyncio
@pytest.mark.parametrize("value", [float("inf"), float("nan")])
async def test_async_increment_only_if_missing_sends_a_value_incrbyfloat_refuses_as_mains_add(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object, value: float
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
add: Final = _RaisingPipeline([ResponseError("increment would produce NaN or Infinity"), True])
client: Final = MagicMock()
client.pipeline.return_value = add
with patch.object(redis_cache, "init_async_client", return_value=client), pytest.raises(ResponseError):
await redis_cache.async_increment(
key="spend:key:k", value=value, ttl=60, refresh_ttl=True, only_if_missing=True
)
assert client.pipeline.call_count == 1
assert add.commands[0][0] == "INCRBYFLOAT"
@pytest.mark.asyncio
async def test_async_increment_only_if_missing_reports_a_redis_failure_like_an_increment(
monkeypatch: pytest.MonkeyPatch, redis_no_ping: object
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache: Final = RedisCache(namespace="ns")
client: Final = MagicMock()
client.pipeline.side_effect = ConnectionError("connection reset")
failure_hook: Final = AsyncMock()
with (
patch.object(redis_cache, "init_async_client", return_value=client),
patch.object(redis_cache.service_logger_obj, "async_service_failure_hook", failure_hook),
pytest.raises(ConnectionError),
):
await redis_cache.async_increment(key="spend:key:k", value=1.5, ttl=60, refresh_ttl=True, only_if_missing=True)
await asyncio.sleep(0)
assert failure_hook.await_args.kwargs["call_type"] == "async_increment"
assert redis_cache._circuit_breaker._hard_failure_count == 1
class _SetRecordingPipeline:
def __init__(self) -> None:
self.sets: list[tuple[str, str, timedelta | None]] = []

File diff suppressed because it is too large Load diff