mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
Counts failed gateway requests by HTTP status at the ASGI edge (LiteLLM_DailyGatewayFailedRequests) and rolls failures up per virtual key, team, user and model group with the status the logging callbacks recorded (LiteLLM_DailyRequestErrors, fed from the spend writer, flushed like the gateway counters, directly or through Redis with a pod lease, and on shutdown). GET /gateway/daily/activity gains by_status_code; the new admin-only GET /gateway/errors/activity returns per-day totals with per-status counts, failures by status code, and keys, teams, users and model groups ranked by failures with their top status. Dashboard schema.d.ts regenerated. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
833 lines
31 KiB
Python
833 lines
31 KiB
Python
"""
|
|
Tests for the gateway request (SGR) fold and its commit to
|
|
LiteLLM_DailyGatewayRequests.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from collections.abc import Awaitable, Callable
|
|
from datetime import datetime, timezone
|
|
from itertools import chain
|
|
from typing import Final
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from litellm.constants import (
|
|
GATEWAY_REQUESTS_MAX_ROWS_PER_UPSERT,
|
|
MAX_REDIS_BUFFER_DEQUEUE_COUNT,
|
|
REDIS_GATEWAY_REQUESTS_BUFFER_KEY,
|
|
REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2,
|
|
)
|
|
from litellm.proxy.db.gateway_request_tracking import (
|
|
GATEWAY_REQUESTS_JOB_NAME,
|
|
GatewayRequestAccumulator,
|
|
GatewayRequestRedisBuffer,
|
|
commit_gateway_requests_to_db,
|
|
flush_gateway_requests,
|
|
)
|
|
from litellm.proxy.db.log_db_metrics import record_db_io
|
|
from litellm.proxy.middleware.billable_request_metrics_middleware import BillableCategory
|
|
from litellm.types.proxy.gateway_requests import GatewayRequestCounts, GatewayRequestKey
|
|
|
|
_NOW: Final = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
|
|
_DAY: Final = _NOW.strftime("%Y-%m-%d")
|
|
|
|
|
|
def _accumulator() -> GatewayRequestAccumulator:
|
|
return GatewayRequestAccumulator(clock=lambda: _NOW)
|
|
|
|
|
|
def _record(accumulator: GatewayRequestAccumulator, status_code: int, **overrides) -> None:
|
|
accumulator.record(
|
|
category=overrides.get("category", BillableCategory.LLM),
|
|
route=overrides.get("route", "/chat/completions"),
|
|
status_code=status_code,
|
|
)
|
|
|
|
|
|
# ── fold ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_folds_repeated_requests_into_one_key():
|
|
acc = _accumulator()
|
|
for _ in range(3):
|
|
_record(acc, 200)
|
|
_record(acc, 500)
|
|
|
|
snapshot = acc.drain()
|
|
assert snapshot == {
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=200): (
|
|
GatewayRequestCounts(successful_requests=3, failed_requests=0)
|
|
),
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=500): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=1)
|
|
),
|
|
}
|
|
|
|
|
|
def test_folds_failed_status_codes_into_distinct_keys_for_the_same_route():
|
|
acc: Final = _accumulator()
|
|
_record(acc, 429)
|
|
_record(acc, 500)
|
|
|
|
assert acc.drain() == {
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=429): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=1)
|
|
),
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=500): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=1)
|
|
),
|
|
}
|
|
|
|
|
|
def test_records_each_request_under_the_date_of_its_clock_read():
|
|
accumulator: Final = GatewayRequestAccumulator(
|
|
clock=iter(
|
|
(
|
|
datetime(2026, 1, 31, 23, 59, 59, 999999, tzinfo=timezone.utc),
|
|
datetime(2026, 2, 1, 0, 0, 0, 1, tzinfo=timezone.utc),
|
|
)
|
|
).__next__
|
|
)
|
|
_record(accumulator, 200)
|
|
_record(accumulator, 200)
|
|
|
|
assert accumulator.drain() == {
|
|
GatewayRequestKey(
|
|
date="2026-01-31", category="llm", route="/chat/completions", status_code=200
|
|
): GatewayRequestCounts(successful_requests=1, failed_requests=0),
|
|
GatewayRequestKey(
|
|
date="2026-02-01", category="llm", route="/chat/completions", status_code=200
|
|
): GatewayRequestCounts(successful_requests=1, failed_requests=0),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"status_code, expected_successful, expected_failed",
|
|
[(200, 1, 0), (201, 1, 0), (204, 1, 0), (299, 1, 0), (300, 0, 1), (400, 0, 1), (500, 0, 1)],
|
|
)
|
|
def test_success_boundary_is_2xx(status_code: int, expected_successful: int, expected_failed: int):
|
|
acc = _accumulator()
|
|
_record(acc, status_code)
|
|
counts = next(iter(acc.drain().values()))
|
|
assert (counts.successful_requests, counts.failed_requests) == (expected_successful, expected_failed)
|
|
|
|
|
|
def test_distinct_dimensions_do_not_merge():
|
|
acc = _accumulator()
|
|
_record(acc, 200, route="/chat/completions")
|
|
_record(acc, 200, route="/embeddings")
|
|
_record(acc, 200, category=BillableCategory.MCP, route="/mcp")
|
|
assert len(acc.drain()) == 3
|
|
|
|
|
|
def test_drain_empties_the_fold():
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
assert len(acc.drain()) == 1
|
|
assert acc.drain() == {}
|
|
|
|
|
|
def test_drain_snapshot_is_not_mutated_by_later_records():
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
snapshot = acc.drain()
|
|
_record(acc, 200)
|
|
assert next(iter(snapshot.values())).successful_requests == 1
|
|
|
|
|
|
# ── commit ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class FakeDB:
|
|
def __init__(self) -> None:
|
|
self.statements: list[tuple[str, tuple[object, ...]]] = []
|
|
|
|
async def execute_raw(self, query: str, *args: object) -> int:
|
|
record_db_io()
|
|
self.statements.append((query, args))
|
|
return len(args) // 5
|
|
|
|
|
|
class FakePrismaClient:
|
|
def __init__(self) -> None:
|
|
self.db = FakeDB()
|
|
|
|
|
|
def _rows_written(client: FakePrismaClient) -> list[tuple[object, ...]]:
|
|
"""Every (date, category, route, successful, failed) tuple the database received, in statement order."""
|
|
return list(chain.from_iterable(_main_rows_for_statement(query, params) for query, params in client.db.statements))
|
|
|
|
|
|
def _main_param_offset(query: str) -> int:
|
|
main_query: Final[str] = query[query.index('INSERT INTO "LiteLLM_DailyGatewayRequests"') :]
|
|
first_placeholder: Final[int] = main_query.index("$")
|
|
placeholder_type: Final[int] = main_query.index("::", first_placeholder)
|
|
offset: Final[int] = int(main_query[first_placeholder + 1 : placeholder_type]) - 1
|
|
return offset
|
|
|
|
|
|
def _main_rows_for_statement(query: str, params: tuple[object, ...]) -> list[tuple[object, ...]]:
|
|
offset: Final[int] = _main_param_offset(query)
|
|
return [params[offset + i : offset + i + 5] for i in range(0, len(params) - offset, 5)]
|
|
|
|
|
|
def _failure_rows_written(client: FakePrismaClient) -> list[tuple[object, ...]]:
|
|
return list(
|
|
chain.from_iterable(_failure_rows_for_statement(query, params) for query, params in client.db.statements)
|
|
)
|
|
|
|
|
|
def _failure_rows_for_statement(query: str, params: tuple[object, ...]) -> list[tuple[object, ...]]:
|
|
if 'INSERT INTO "LiteLLM_DailyGatewayFailedRequests"' not in query:
|
|
return []
|
|
return [params[i : i + 5] for i in range(0, _main_param_offset(query), 5)]
|
|
|
|
|
|
def test_commit_increments_with_a_single_statement_for_the_whole_snapshot():
|
|
"""One statement per flush is the whole point: the previous per-key upsert cost
|
|
the primary (workers x routes) statements per interval."""
|
|
client = FakePrismaClient()
|
|
snapshot = {
|
|
GatewayRequestKey(date="2026-08-01", category="llm", route=route): (
|
|
GatewayRequestCounts(successful_requests=7, failed_requests=2)
|
|
)
|
|
for route in ("/chat/completions", "/embeddings", "/responses", "/v1/messages", "/mcp")
|
|
}
|
|
|
|
asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot))
|
|
|
|
assert len(client.db.statements) == 1
|
|
sql, params = client.db.statements[0]
|
|
assert sql.count("ON CONFLICT") == 1
|
|
assert sql.count("(NOW() AT TIME ZONE 'UTC'))") == 5
|
|
assert len(params) == 25
|
|
|
|
|
|
def test_commit_sql_adds_to_the_existing_row_instead_of_replacing_it():
|
|
"""A worker only knows its own share; the SQL must add EXCLUDED onto the stored count."""
|
|
client = FakePrismaClient()
|
|
snapshot = {
|
|
GatewayRequestKey(date="2026-08-01", category="llm", route="/chat/completions"): (
|
|
GatewayRequestCounts(successful_requests=7, failed_requests=2)
|
|
)
|
|
}
|
|
|
|
asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot))
|
|
|
|
sql, params = client.db.statements[0]
|
|
assert 'INSERT INTO "LiteLLM_DailyGatewayRequests"' in sql
|
|
assert 'ON CONFLICT ("date", "category", "route") DO UPDATE SET' in sql
|
|
assert (
|
|
'"successful_requests" = "LiteLLM_DailyGatewayRequests"."successful_requests" + EXCLUDED."successful_requests"'
|
|
in sql
|
|
)
|
|
assert '"failed_requests" = "LiteLLM_DailyGatewayRequests"."failed_requests" + EXCLUDED."failed_requests"' in sql
|
|
assert params == ("2026-08-01", "llm", "/chat/completions", 7, 2)
|
|
|
|
|
|
def test_commit_placeholders_line_up_with_params():
|
|
"""$n positions are generated per row; a drift here silently swaps a route for a count."""
|
|
client = FakePrismaClient()
|
|
snapshot = {
|
|
GatewayRequestKey(date="2026-08-01", category="llm", route="/chat/completions"): (
|
|
GatewayRequestCounts(successful_requests=1, failed_requests=0)
|
|
),
|
|
GatewayRequestKey(date="2026-08-01", category="mcp", route="/mcp"): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=3)
|
|
),
|
|
}
|
|
|
|
asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot))
|
|
|
|
sql, params = client.db.statements[0]
|
|
assert "($1::text, $2::text, $3::text, $4::bigint, $5::bigint," in sql
|
|
assert "($6::text, $7::text, $8::text, $9::bigint, $10::bigint," in sql
|
|
assert "$11" not in sql
|
|
assert params == ("2026-08-01", "llm", "/chat/completions", 1, 0, "2026-08-01", "mcp", "/mcp", 0, 3)
|
|
|
|
|
|
def test_commit_upserts_per_status_and_sums_main_table_counts_in_one_statement():
|
|
client: Final = FakePrismaClient()
|
|
accumulator: Final = _accumulator()
|
|
_record(accumulator, 200)
|
|
_record(accumulator, 201)
|
|
_record(accumulator, 429)
|
|
_record(accumulator, 500)
|
|
|
|
asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=accumulator.drain()))
|
|
|
|
assert len(client.db.statements) == 1
|
|
sql, params = client.db.statements[0]
|
|
assert sql.startswith('WITH failed AS (INSERT INTO "LiteLLM_DailyGatewayFailedRequests"')
|
|
assert sql.index('INSERT INTO "LiteLLM_DailyGatewayFailedRequests"') < sql.index(
|
|
'INSERT INTO "LiteLLM_DailyGatewayRequests"'
|
|
)
|
|
assert "$1::text, $2::text, $3::text, $4::integer, $5::bigint" in sql
|
|
assert "$6::text, $7::text, $8::text, $9::integer, $10::bigint" in sql
|
|
assert "$11::text, $12::text, $13::text, $14::bigint, $15::bigint" in sql
|
|
assert "$16" not in sql
|
|
assert params == (
|
|
_DAY,
|
|
"llm",
|
|
"/chat/completions",
|
|
429,
|
|
1,
|
|
_DAY,
|
|
"llm",
|
|
"/chat/completions",
|
|
500,
|
|
1,
|
|
_DAY,
|
|
"llm",
|
|
"/chat/completions",
|
|
2,
|
|
2,
|
|
)
|
|
assert _failure_rows_written(client) == [
|
|
(_DAY, "llm", "/chat/completions", 429, 1),
|
|
(_DAY, "llm", "/chat/completions", 500, 1),
|
|
]
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", 2, 2)]
|
|
|
|
|
|
def test_success_only_snapshot_uses_only_the_existing_table_upsert():
|
|
client: Final = FakePrismaClient()
|
|
accumulator: Final = _accumulator()
|
|
_record(accumulator, 200)
|
|
snapshot: Final = accumulator.drain()
|
|
|
|
assert snapshot == {
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=200): (
|
|
GatewayRequestCounts(successful_requests=1, failed_requests=0)
|
|
)
|
|
}
|
|
|
|
asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot))
|
|
|
|
sql, _ = client.db.statements[0]
|
|
assert sql.startswith('INSERT INTO "LiteLLM_DailyGatewayRequests"')
|
|
assert "LiteLLM_DailyGatewayFailedRequests" not in sql
|
|
assert _failure_rows_written(client) == []
|
|
|
|
|
|
def test_commit_is_deterministically_ordered():
|
|
"""Concurrent writers must touch rows in the same order or they deadlock."""
|
|
client = FakePrismaClient()
|
|
keys = [
|
|
GatewayRequestKey(date="2026-08-02", category="llm", route="/embeddings"),
|
|
GatewayRequestKey(date="2026-08-01", category="mcp", route="/mcp"),
|
|
GatewayRequestKey(date="2026-08-01", category="llm", route="/chat/completions"),
|
|
]
|
|
snapshot = {key: GatewayRequestCounts(successful_requests=1, failed_requests=0) for key in keys}
|
|
|
|
asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot))
|
|
|
|
written_order = [(row[0], row[1]) for row in _rows_written(client)]
|
|
assert written_order == [("2026-08-01", "llm"), ("2026-08-01", "mcp"), ("2026-08-02", "llm")]
|
|
|
|
|
|
def _backlog(size: int) -> dict[GatewayRequestKey, GatewayRequestCounts]:
|
|
return {
|
|
GatewayRequestKey(date=_DAY, category="llm", route=f"/route-{index:05d}", status_code=200): (
|
|
GatewayRequestCounts(successful_requests=1, failed_requests=0)
|
|
)
|
|
for index in range(size)
|
|
}
|
|
|
|
|
|
def test_commit_splits_a_backlog_into_bounded_statements():
|
|
client: Final = MagicMock()
|
|
client.db.execute_raw = AsyncMock()
|
|
|
|
uncommitted: Final = asyncio.run(
|
|
commit_gateway_requests_to_db(prisma_client=client, snapshot=_backlog(GATEWAY_REQUESTS_MAX_ROWS_PER_UPSERT + 1))
|
|
)
|
|
|
|
assert uncommitted == {}
|
|
assert client.db.execute_raw.await_count == 2
|
|
assert len(client.db.execute_raw.await_args_list[0].args) - 1 == GATEWAY_REQUESTS_MAX_ROWS_PER_UPSERT * 5
|
|
|
|
|
|
def test_commit_keeps_a_route_total_and_its_status_rows_in_one_statement():
|
|
client: Final = MagicMock()
|
|
client.db.execute_raw = AsyncMock()
|
|
snapshot: Final = {
|
|
**_backlog(GATEWAY_REQUESTS_MAX_ROWS_PER_UPSERT - 1),
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/last", status_code=429): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=1)
|
|
),
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/last", status_code=500): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=1)
|
|
),
|
|
}
|
|
|
|
asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot))
|
|
|
|
assert client.db.execute_raw.await_count == 2
|
|
with_last: Final = [
|
|
awaited.args for awaited in client.db.execute_raw.await_args_list if "/last" in awaited.args[1:]
|
|
]
|
|
assert len(with_last) == 1
|
|
assert with_last[0][1:].count("/last") == 3
|
|
|
|
|
|
def test_commit_hands_back_only_the_chunks_that_did_not_land():
|
|
client: Final = MagicMock()
|
|
client.db.execute_raw = AsyncMock(side_effect=(None, RuntimeError("db down")))
|
|
snapshot: Final = _backlog(GATEWAY_REQUESTS_MAX_ROWS_PER_UPSERT + 1)
|
|
|
|
uncommitted: Final = asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot))
|
|
|
|
last_key: Final = GatewayRequestKey(
|
|
date=_DAY, category="llm", route=f"/route-{GATEWAY_REQUESTS_MAX_ROWS_PER_UPSERT:05d}", status_code=200
|
|
)
|
|
assert uncommitted == {last_key: snapshot[last_key]}
|
|
assert client.db.execute_raw.await_count == 2
|
|
|
|
|
|
def test_commit_skips_the_database_entirely_when_nothing_accumulated():
|
|
client = FakePrismaClient()
|
|
asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot={}))
|
|
assert client.db.statements == []
|
|
|
|
|
|
# ── flush ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_flush_drains_and_commits():
|
|
client = FakePrismaClient()
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
|
|
asyncio.run(flush_gateway_requests(client, acc))
|
|
|
|
assert len(client.db.statements) == 1
|
|
assert acc.drain() == {}
|
|
|
|
|
|
class ExplodingDB:
|
|
async def execute_raw(self, query: str, *args: object) -> int:
|
|
raise RuntimeError("db gone")
|
|
|
|
|
|
class ExplodingClient:
|
|
db = ExplodingDB()
|
|
|
|
|
|
def test_flush_swallows_commit_failure_so_the_scheduler_survives():
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
|
|
asyncio.run(flush_gateway_requests(ExplodingClient(), acc))
|
|
|
|
|
|
def test_failed_flush_keeps_counts_for_the_next_attempt():
|
|
"""A dropped flush would silently undercount the SGR source of truth."""
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
_record(acc, 500)
|
|
|
|
asyncio.run(flush_gateway_requests(ExplodingClient(), acc))
|
|
|
|
client = FakePrismaClient()
|
|
asyncio.run(flush_gateway_requests(client, acc))
|
|
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", 1, 1)]
|
|
|
|
|
|
def test_restored_counts_merge_with_requests_recorded_meanwhile():
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
asyncio.run(flush_gateway_requests(ExplodingClient(), acc))
|
|
|
|
_record(acc, 200)
|
|
client = FakePrismaClient()
|
|
asyncio.run(flush_gateway_requests(client, acc))
|
|
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", 2, 0)]
|
|
|
|
|
|
class ExplodingDBWithInFlightRequest:
|
|
"""Fails the write after a request has been recorded while it was in flight."""
|
|
|
|
def __init__(self, accumulator: GatewayRequestAccumulator) -> None:
|
|
self.accumulator = accumulator
|
|
|
|
async def execute_raw(self, query: str, *args: object) -> int:
|
|
_record(self.accumulator, 500)
|
|
raise RuntimeError("db gone")
|
|
|
|
|
|
class ExplodingClientWithInFlightRequest:
|
|
def __init__(self, accumulator: GatewayRequestAccumulator) -> None:
|
|
self.db = ExplodingDBWithInFlightRequest(accumulator)
|
|
|
|
|
|
def test_restore_keeps_requests_recorded_while_the_failed_write_was_in_flight():
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
asyncio.run(flush_gateway_requests(ExplodingClientWithInFlightRequest(acc), acc))
|
|
|
|
client = FakePrismaClient()
|
|
asyncio.run(flush_gateway_requests(client, acc))
|
|
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", 1, 1)]
|
|
|
|
|
|
# ── redis buffer ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class FakeRedis:
|
|
def __init__(self) -> None:
|
|
self.lists: dict[str, list[str]] = {}
|
|
|
|
async def async_rpush(self, key: str, values: list[str]) -> int:
|
|
self.lists.setdefault(key, []).extend(values)
|
|
return len(self.lists[key])
|
|
|
|
async def async_lpop(self, key: str, count: int) -> list[str] | None:
|
|
queue = self.lists.get(key, [])
|
|
if not queue:
|
|
return None
|
|
popped, self.lists[key] = queue[:count], queue[count:]
|
|
return popped
|
|
|
|
|
|
class FakePodLock:
|
|
def __init__(self, *, leader: bool) -> None:
|
|
self.leader = leader
|
|
self.held: list[str] = []
|
|
self.released: list[str] = []
|
|
|
|
async def acquire_lock(self, cronjob_id: str) -> bool:
|
|
self.held.append(cronjob_id)
|
|
return self.leader
|
|
|
|
async def release_lock(self, cronjob_id: str) -> None:
|
|
self.released.append(cronjob_id)
|
|
|
|
|
|
class FakeLease:
|
|
"""Redis-side view of the job lock: SET NX by pod id, re-entrant for the holder, freed only by release or TTL."""
|
|
|
|
def __init__(self) -> None:
|
|
self.holder: str | None = None
|
|
|
|
|
|
class FakeLeasePodLock:
|
|
def __init__(self, lease: FakeLease, pod_id: str) -> None:
|
|
self.lease = lease
|
|
self.pod_id = pod_id
|
|
|
|
async def acquire_lock(self, cronjob_id: str) -> bool:
|
|
if self.lease.holder is None:
|
|
self.lease.holder = self.pod_id
|
|
return self.lease.holder == self.pod_id
|
|
|
|
async def release_lock(self, cronjob_id: str) -> None:
|
|
if self.lease.holder == self.pod_id:
|
|
self.lease.holder = None
|
|
|
|
|
|
def _buffer(redis: FakeRedis, *, leader: bool) -> tuple[GatewayRequestRedisBuffer, FakePodLock]:
|
|
lock = FakePodLock(leader=leader)
|
|
return GatewayRequestRedisBuffer(redis_cache=redis, pod_lock_manager=lock), lock # pyright: ignore[reportArgumentType] # duck-typed fakes
|
|
|
|
|
|
def test_non_leader_workers_push_to_redis_and_never_touch_the_database():
|
|
redis = FakeRedis()
|
|
client = FakePrismaClient()
|
|
for _ in range(3):
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
buffer, _ = _buffer(redis, leader=False)
|
|
asyncio.run(flush_gateway_requests(client, acc, buffer))
|
|
|
|
assert client.db.statements == []
|
|
assert len(redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2]) == 3
|
|
assert redis.lists.get(REDIS_GATEWAY_REQUESTS_BUFFER_KEY, []) == []
|
|
|
|
|
|
def test_push_writes_six_field_rows_to_the_v2_key():
|
|
redis: Final = FakeRedis()
|
|
accumulator: Final = _accumulator()
|
|
_record(accumulator, 429)
|
|
buffer, _ = _buffer(redis, leader=False)
|
|
|
|
asyncio.run(buffer.push(accumulator.drain()))
|
|
|
|
assert json.loads(redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2][0]) == [
|
|
[_DAY, "llm", "/chat/completions", 429, 0, 1]
|
|
]
|
|
assert redis.lists.get(REDIS_GATEWAY_REQUESTS_BUFFER_KEY, []) == []
|
|
|
|
|
|
def test_leader_combines_legacy_and_v2_buffer_rows():
|
|
redis: Final = FakeRedis()
|
|
redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY] = [json.dumps(((_DAY, "llm", "/chat/completions", 2, 1),))]
|
|
redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2] = [json.dumps(((_DAY, "llm", "/chat/completions", 429, 0, 2),))]
|
|
client: Final = FakePrismaClient()
|
|
leader, _ = _buffer(redis, leader=True)
|
|
|
|
asyncio.run(flush_gateway_requests(client, _accumulator(), leader))
|
|
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", 2, 3)]
|
|
assert _failure_rows_written(client) == [(_DAY, "llm", "/chat/completions", 429, 2)]
|
|
assert redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY] == []
|
|
assert redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2] == []
|
|
|
|
|
|
def test_leader_folds_every_workers_snapshot_into_one_statement():
|
|
"""Fifty workers each flushing the same routes must cost the primary one statement, not fifty."""
|
|
redis = FakeRedis()
|
|
client = FakePrismaClient()
|
|
for _ in range(50):
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
_record(acc, 500, route="/responses")
|
|
buffer, _ = _buffer(redis, leader=False)
|
|
asyncio.run(flush_gateway_requests(client, acc, buffer))
|
|
|
|
leader_acc = _accumulator()
|
|
_record(leader_acc, 200)
|
|
leader, lock = _buffer(redis, leader=True)
|
|
asyncio.run(flush_gateway_requests(client, leader_acc, leader))
|
|
|
|
assert len(client.db.statements) == 1
|
|
assert _rows_written(client) == [
|
|
(_DAY, "llm", "/chat/completions", 51, 0),
|
|
(_DAY, "llm", "/responses", 0, 50),
|
|
]
|
|
assert redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2] == []
|
|
assert lock.held == [GATEWAY_REQUESTS_JOB_NAME]
|
|
assert lock.released == []
|
|
|
|
|
|
def test_leader_keeps_the_lease_so_staggered_pods_cost_one_statement_per_interval():
|
|
"""Pods flush on their own clocks; without the lease each one would win the lock in turn and commit alone."""
|
|
redis = FakeRedis()
|
|
client = FakePrismaClient()
|
|
lease = FakeLease()
|
|
pods = tuple(
|
|
GatewayRequestRedisBuffer(redis_cache=redis, pod_lock_manager=FakeLeasePodLock(lease, f"pod-{i}")) # pyright: ignore[reportArgumentType] # duck-typed fakes
|
|
for i in range(4)
|
|
)
|
|
|
|
for _interval in range(3):
|
|
for pod in pods:
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
asyncio.run(flush_gateway_requests(client, acc, pod))
|
|
|
|
assert lease.holder == "pod-0"
|
|
assert len(client.db.statements) == 3
|
|
assert [row[3] for row in _rows_written(client)] == [1, 4, 4]
|
|
assert len(redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2]) == 3
|
|
|
|
|
|
def test_leader_drains_a_backlog_deeper_than_one_capped_pop():
|
|
"""More workers than MAX_REDIS_BUFFER_DEQUEUE_COUNT must not leave a growing tail queued behind the cap."""
|
|
redis = FakeRedis()
|
|
client = FakePrismaClient()
|
|
workers = MAX_REDIS_BUFFER_DEQUEUE_COUNT * 2 + 1
|
|
for _ in range(workers):
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
buffer, _ = _buffer(redis, leader=False)
|
|
asyncio.run(flush_gateway_requests(client, acc, buffer))
|
|
|
|
leader, _ = _buffer(redis, leader=True)
|
|
asyncio.run(flush_gateway_requests(client, _accumulator(), leader))
|
|
|
|
assert len(client.db.statements) == 1
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", workers, 0)]
|
|
assert redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2] == []
|
|
|
|
|
|
def test_leader_with_nothing_buffered_writes_nothing():
|
|
redis = FakeRedis()
|
|
client = FakePrismaClient()
|
|
leader, lock = _buffer(redis, leader=True)
|
|
|
|
asyncio.run(flush_gateway_requests(client, _accumulator(), leader))
|
|
|
|
assert client.db.statements == []
|
|
assert lock.released == []
|
|
|
|
|
|
def test_leader_requeues_to_redis_when_the_database_commit_fails():
|
|
"""Counts popped from Redis are gone from every worker; a failed commit must put them back."""
|
|
redis = FakeRedis()
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
_record(acc, 200)
|
|
leader, lock = _buffer(redis, leader=True)
|
|
|
|
asyncio.run(flush_gateway_requests(ExplodingClient(), acc, leader))
|
|
|
|
assert len(redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2]) == 1
|
|
assert lock.released == []
|
|
assert acc.drain() == {}
|
|
|
|
client = FakePrismaClient()
|
|
retry, _ = _buffer(redis, leader=True)
|
|
asyncio.run(flush_gateway_requests(client, _accumulator(), retry))
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", 2, 0)]
|
|
|
|
|
|
class ExplodingRedis(FakeRedis):
|
|
async def async_rpush(self, key: str, values: list[str]) -> int:
|
|
raise RuntimeError("redis gone")
|
|
|
|
|
|
class UnreadableRedis(FakeRedis):
|
|
async def async_lpop(self, key: str, count: int) -> list[str] | None:
|
|
raise RuntimeError("redis gone mid-flush")
|
|
|
|
|
|
class LegacyKeyReadFailureRedis(FakeRedis):
|
|
async def async_lpop(self, key: str, count: int) -> list[str] | None:
|
|
if key == REDIS_GATEWAY_REQUESTS_BUFFER_KEY:
|
|
raise RuntimeError("legacy redis key unreadable")
|
|
return await super().async_lpop(key, count)
|
|
|
|
|
|
class FailureAfterFullV2BatchRedis(FakeRedis):
|
|
async def async_lpop(self, key: str, count: int) -> list[str] | None:
|
|
if key == REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2 and not self.lists.get(key):
|
|
raise RuntimeError("redis read failed after full batch")
|
|
return await super().async_lpop(key, count)
|
|
|
|
|
|
class UnwritableRedis(FakeRedis):
|
|
"""Pops succeed, pushes fail: a Redis that went read-only between the leader's pop and its re-queue."""
|
|
|
|
async def async_rpush(self, key: str, values: list[str]) -> int:
|
|
raise RuntimeError("redis read-only")
|
|
|
|
|
|
def test_leader_keeps_popped_counts_in_memory_when_both_the_database_and_the_requeue_fail():
|
|
"""The pop removed the only copy; if Redis will not take it back the leader itself must carry it."""
|
|
redis = FakeRedis()
|
|
worker_acc = _accumulator()
|
|
_record(worker_acc, 200)
|
|
_record(worker_acc, 200)
|
|
worker, _ = _buffer(redis, leader=False)
|
|
asyncio.run(flush_gateway_requests(FakePrismaClient(), worker_acc, worker))
|
|
|
|
degraded = UnwritableRedis()
|
|
degraded.lists = redis.lists
|
|
leader_acc = _accumulator()
|
|
leader, _ = _buffer(degraded, leader=True)
|
|
asyncio.run(flush_gateway_requests(ExplodingClient(), leader_acc, leader))
|
|
assert degraded.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2] == []
|
|
|
|
client = FakePrismaClient()
|
|
retry, _ = _buffer(redis, leader=True)
|
|
asyncio.run(flush_gateway_requests(client, leader_acc, retry))
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", 2, 0)]
|
|
|
|
|
|
def test_leader_whose_redis_read_fails_leaves_the_pushed_rows_for_the_next_flush():
|
|
"""The scheduler job must not raise, and nothing is popped so nothing needs restoring anywhere."""
|
|
redis = UnreadableRedis()
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
client = FakePrismaClient()
|
|
leader, _ = _buffer(redis, leader=True)
|
|
|
|
asyncio.run(flush_gateway_requests(client, acc, leader))
|
|
|
|
assert client.db.statements == []
|
|
assert acc.drain() == {}
|
|
assert len(redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2]) == 1
|
|
|
|
|
|
def test_pop_keeps_v2_counts_when_legacy_key_read_fails():
|
|
redis: Final = LegacyKeyReadFailureRedis()
|
|
redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2] = [
|
|
json.dumps(((_DAY, "llm", "/chat/completions", 500, 0, 2),)),
|
|
json.dumps(((_DAY, "llm", "/embeddings", 429, 0, 3),)),
|
|
]
|
|
buffer: Final = _buffer(redis, leader=True)[0]
|
|
|
|
snapshot: Final = asyncio.run(buffer.pop())
|
|
|
|
assert snapshot == {
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=500): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=2)
|
|
),
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/embeddings", status_code=429): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=3)
|
|
),
|
|
}
|
|
|
|
|
|
def test_commit_if_leader_writes_v2_counts_when_legacy_key_read_fails():
|
|
redis: Final = LegacyKeyReadFailureRedis()
|
|
redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2] = [json.dumps(((_DAY, "llm", "/chat/completions", 500, 0, 2),))]
|
|
buffer: Final = _buffer(redis, leader=True)[0]
|
|
client: Final = FakePrismaClient()
|
|
|
|
asyncio.run(buffer.commit_if_leader(client))
|
|
|
|
assert _rows_written(client) == [(_DAY, "llm", "/chat/completions", 0, 2)]
|
|
assert _failure_rows_written(client) == [(_DAY, "llm", "/chat/completions", 500, 2)]
|
|
|
|
|
|
def test_pop_keeps_v2_first_batch_when_a_later_batch_read_fails(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setattr("litellm.proxy.db.gateway_request_tracking.MAX_REDIS_BUFFER_DEQUEUE_COUNT", 2)
|
|
redis: Final = FailureAfterFullV2BatchRedis()
|
|
redis.lists[REDIS_GATEWAY_REQUESTS_BUFFER_KEY_V2] = [
|
|
json.dumps(((_DAY, "llm", "/chat/completions", 500, 0, 2),)),
|
|
json.dumps(((_DAY, "llm", "/chat/completions", 429, 0, 3),)),
|
|
]
|
|
buffer: Final = _buffer(redis, leader=True)[0]
|
|
|
|
snapshot: Final = asyncio.run(buffer.pop())
|
|
|
|
assert snapshot == {
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=500): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=2)
|
|
),
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=429): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=3)
|
|
),
|
|
}
|
|
|
|
|
|
def test_failed_redis_push_keeps_counts_locally_for_the_next_flush():
|
|
acc = _accumulator()
|
|
_record(acc, 200)
|
|
_record(acc, 500)
|
|
buffer, lock = _buffer(ExplodingRedis(), leader=True)
|
|
|
|
asyncio.run(flush_gateway_requests(FakePrismaClient(), acc, buffer))
|
|
|
|
assert lock.held == []
|
|
assert acc.drain() == {
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=200): (
|
|
GatewayRequestCounts(successful_requests=1, failed_requests=0)
|
|
),
|
|
GatewayRequestKey(date=_DAY, category="llm", route="/chat/completions", status_code=500): (
|
|
GatewayRequestCounts(successful_requests=0, failed_requests=1)
|
|
),
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_gateway_request_flush_renders_a_postgres_upsert_span(
|
|
postgres_span_names: Callable[[], Awaitable[tuple[str, ...]]],
|
|
) -> None:
|
|
prisma = FakePrismaClient()
|
|
snapshot = {
|
|
GatewayRequestKey(date="2026-08-01", category="llm", route="/chat/completions"): (
|
|
GatewayRequestCounts(successful_requests=7, failed_requests=2)
|
|
)
|
|
}
|
|
|
|
await commit_gateway_requests_to_db(prisma_client=prisma, snapshot=snapshot)
|
|
|
|
assert await postgres_span_names() == ("postgres.upsert LiteLLM_DailyGatewayRequests",)
|