litellm/tests/integration/compatibility/test_status_code_followups_chaos.py
yuneng-jiang 44d99475a5
test(integration): stop GET /__observations from draining the upstream log (#47256)
* test(integration): stop GET /__observations from draining the upstream log

GET /__observations used to empty the queue, so a second read in a test
saw nothing and a "never reached upstream" check could pass whether or not
the request was forwarded. GET now returns every request since the last
reset and DELETE /__observations is the reset. An autouse fixture resets
the worker upstream before each test, which also covers tests that read
without the gateway fixture.

Discarded reads used as resets become DELETEs, helpers that accumulated
drained reads now read one snapshot, and tests with several phases reset
between them. The three vacuous negative checks (rag allowlist, guardrail
opt-out chaos, xAI batch output) now assert the absence on the same read
that shows a request which did reach the upstream.

* style(integration): space the observations unit test like its neighbours
2026-10-18 09:58:05 +00:00

281 lines
11 KiB
Python

"""The provider goes down in the middle of a concurrent burst across /v1/chat/completions, /v1/messages and
/v1/responses, streaming and not, on a two-worker proxy: every failed call reaches the caller as an error status,
another key on another deployment keeps working, and once the provider is back every call serves and lands in the
spend log exactly once."""
from __future__ import annotations
import json
import os
import re
import signal
import uuid
from collections.abc import Iterator, Mapping
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, eventually, gateway_from_environment
from integration._support.database import read_rows
from integration._support.process import UpstreamSlot, owned_proxy_process, owned_upstream
from integration._support.upstream import register_scenario
from integration.compatibility._status_code_audit import (
CHAT,
CHAT_FRAMES,
ENDPOINT_BODIES,
ENDPOINT_PATHS,
MESSAGE,
MESSAGE_FRAMES,
RESPONSE,
RESPONSES_FRAMES,
Upstream,
json_response,
sse_response,
stream_finished,
)
from pydantic import JsonValue
from tests.integration.cost_calculation.cost_tracking_case import StoredResponse
pytestmark: Final = [pytest.mark.timeout(480), pytest.mark.shares_worker_state]
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
_MARKER_HEADER: Final = "x-litellm-spend-logs-metadata"
_KINDS: Final[dict[str, tuple[str, bool, str, str, StoredResponse]]] = {
"chat": ("chat", False, "chaos-chat", "openai/gpt-4o-mini", json_response(CHAT)),
"chatstream": ("chat", True, "chaos-chat-stream", "openai/gpt-4o-mini", sse_response(CHAT_FRAMES)),
"responses": ("responses", False, "chaos-responses", "openai/gpt-4o-mini", json_response(RESPONSE)),
"responsesstream": (
"responses",
True,
"chaos-responses-stream",
"openai/gpt-4o-mini",
sse_response(RESPONSES_FRAMES),
),
"messages": ("messages", False, "chaos-message", "anthropic/claude-haiku-4-5", json_response(MESSAGE)),
"messagesstream": (
"messages",
True,
"chaos-message-stream",
"anthropic/claude-haiku-4-5",
sse_response(MESSAGE_FRAMES),
),
}
_BURST: Final = 30
@dataclass(frozen=True, slots=True)
class _Chaos:
gateway: Gateway
log: Path
slot: UpstreamSlot
identities: Mapping[str, str]
stable_identity: str
burst_key: str
other_key: str
@dataclass(frozen=True, slots=True)
class _Outcome:
marker: str
kind: str
status: int | None
text: str
finished: bool = False
def _register_on_slot(slot: UpstreamSlot, identities: Mapping[str, str]) -> None:
for kind, identity in identities.items():
register_scenario(identity, _KINDS[kind][4], control_url=slot.url)
@pytest.fixture(scope="module")
def chaos(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Chaos]:
directory: Final = tmp_path_factory.mktemp("audit-chaos")
with (
owned_upstream(directory) as slot,
gateway_from_environment() as gateway,
gateway.scenario() as scenario,
):
identities: Final = {kind: f"chaos-{kind}-{uuid.uuid4().hex}" for kind in _KINDS}
_register_on_slot(slot, identities)
stable_identity: Final = f"chaos-stable-{uuid.uuid4().hex}"
stable_handle: Final = register_scenario(stable_identity, json_response(CHAT))
model_list: Final[list[JsonValue]] = [
*(
{
"model_name": _KINDS[kind][2],
"litellm_params": {
"model": _KINDS[kind][3],
"api_base": f"{slot.url}/{identity}",
"api_key": identity,
},
}
for kind, identity in identities.items()
),
{
"model_name": "chaos-stable",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_base": stable_handle.api_base(),
"api_key": stable_identity,
},
},
]
config: Final = directory / "chaos.yaml"
config.write_text(
json.dumps({"model_list": model_list, "router_settings": {"num_retries": 0, "disable_cooldowns": True}}),
encoding="utf-8",
)
burst_key: Final = scenario.key(key_alias=f"chaos-burst-{uuid.uuid4().hex}")
other_key: Final = scenario.key(key_alias=f"chaos-other-{uuid.uuid4().hex}", models=["chaos-stable"])
with owned_proxy_process(gateway, directory, {}, config=config, workers=2) as owned:
eventually(lambda: _worker_pids(owned.log), lambda pids: len(pids) == 2, seconds=120)
yield _Chaos(
Gateway(owned.gateway.client, owned.gateway.key, gateway.upstream_url),
owned.log,
slot,
identities,
stable_identity,
burst_key,
other_key,
)
def _worker_pids(log: Path) -> tuple[int, ...]:
return tuple(int(found[1]) for found in _STARTED_WORKER.finditer(log.read_text()))
def _call(chaos: _Chaos, kind: str, marker: str, *, key: str | None = None) -> _Outcome:
endpoint, stream, model_name, _target, _response = _KINDS[kind]
body: Final[dict[str, JsonValue]] = {
**ENDPOINT_BODIES[endpoint],
**({"stream": True} if stream else {}),
"model": model_name,
**({"input": marker} if endpoint == "responses" else {"messages": [{"role": "user", "content": marker}]}),
}
headers: Final = {
"Authorization": f"Bearer {chaos.burst_key if key is None else key}",
_MARKER_HEADER: json.dumps({"marker": marker}),
}
try:
with httpx.Client(base_url=str(chaos.gateway.client.base_url), trust_env=False, timeout=60) as client:
with client.stream("POST", ENDPOINT_PATHS[endpoint], json=body, headers=headers) as response:
lines: Final = tuple(response.iter_lines())
status: Final = response.status_code
except httpx.TransportError as error:
return _Outcome(marker, kind, None, repr(error))
return _Outcome(marker, kind, status, "\n".join(lines), not stream or stream_finished(endpoint, lines))
def _burst(chaos: _Chaos, phase: str) -> tuple[_Outcome, ...]:
kinds: Final = tuple(_KINDS)
plan: Final = tuple(
(kinds[index % len(kinds)], f"{phase}-{kinds[index % len(kinds)]}-{index}-{uuid.uuid4().hex}")
for index in range(_BURST)
)
with ThreadPoolExecutor(max_workers=12) as pool:
return tuple(pool.map(lambda item: _call(chaos, item[0], item[1]), plan))
def _upstream_hits(slot: UpstreamSlot, markers: frozenset[str]) -> Mapping[str, int]:
bodies: Final = tuple(json.dumps(item.get("body")) for item in Upstream(slot.url).observed())
return {marker: sum(marker in body for body in bodies) for marker in markers}
def _spend_markers(key: str, markers: frozenset[str]) -> Mapping[str, tuple[str, ...]]:
rows: Final = read_rows(
"SELECT status, metadata->'spend_logs_metadata'->>'marker' AS marker "
'FROM "LiteLLM_SpendLogs" WHERE api_key = %s',
(sha256(key.encode()).hexdigest(),),
)
return {
marker: found
for marker in markers
if (found := tuple(str(row["status"]) for row in rows if str(row["marker"]) == marker))
}
def _assert_served(outcomes: tuple[_Outcome, ...]) -> None:
assert [(outcome.kind, outcome.status, outcome.finished) for outcome in outcomes] == [
(outcome.kind, 200, True) for outcome in outcomes
], [(outcome.kind, outcome.status, outcome.text[:300]) for outcome in outcomes if outcome.status != 200]
def _stable_call(chaos: _Chaos) -> httpx.Response:
return chaos.gateway.client.post(
"/v1/chat/completions",
json={"model": "chaos-stable", "messages": [{"role": "user", "content": "other key"}]},
headers={"Authorization": f"Bearer {chaos.other_key}"},
timeout=30,
)
def _settled(chaos: _Chaos) -> bool:
return all(_call(chaos, kind, f"settle-{kind}-{uuid.uuid4().hex}").status == 200 for kind in _KINDS)
def _stop_mid_burst(chaos: _Chaos) -> tuple[_Outcome, ...]:
with ThreadPoolExecutor(max_workers=1) as stopper:
stopping: Final = stopper.submit(chaos.slot.stop)
during: Final = _burst(chaos, "during")
stopping.result(timeout=120)
return during
def _restart(chaos: _Chaos) -> None:
chaos.slot.start()
_register_on_slot(chaos.slot, chaos.identities)
eventually(lambda: _settled(chaos), lambda ok: ok, seconds=90)
def test_provider_outage_mid_burst_errors_reach_callers_and_recovery_serves_once(chaos: _Chaos) -> None:
before: Final = _burst(chaos, "before")
_assert_served(before)
during: Final = _stop_mid_burst(chaos)
outage: Final = _burst(chaos, "outage")
for outcome in outage:
assert outcome.status is not None and outcome.status >= 500, (outcome.kind, outcome.status, outcome.text)
assert '"error"' in outcome.text, (outcome.kind, outcome.text)
for outcome in during:
assert (outcome.status == 200 and outcome.finished) or (
outcome.status is not None and outcome.status >= 500 and '"error"' in outcome.text
), (outcome.kind, outcome.status, outcome.text[:300])
assert chaos.gateway.request("GET", "/health/liveliness").status_code == 200
stable: Final = _stable_call(chaos)
assert stable.status_code == 200, stable.text
_restart(chaos)
after: Final = _burst(chaos, "after")
_assert_served(after)
after_markers: Final = frozenset(outcome.marker for outcome in after)
assert _upstream_hits(chaos.slot, after_markers) == dict.fromkeys(after_markers, 1)
served: Final = frozenset(outcome.marker for outcome in (*before, *after))
landed: Final = eventually(
lambda: _spend_markers(chaos.burst_key, served), lambda found: frozenset(found) == served, seconds=120
)
assert landed == dict.fromkeys(served, ("success",)), landed
assert len(_worker_pids(chaos.log)) >= 2
def _tolerant_status(chaos: _Chaos, kind: str) -> int | None:
return _call(chaos, kind, f"kill-probe-{kind}-{uuid.uuid4().hex}").status
def test_killed_worker_leaves_the_sibling_serving_and_the_respawn_serving(chaos: _Chaos) -> None:
workers: Final = _worker_pids(chaos.log)
assert len(workers) >= 2, workers
os.kill(workers[-1], signal.SIGKILL)
eventually(lambda: _tolerant_status(chaos, "chat"), lambda status: status == 200, seconds=60)
_assert_served(_burst(chaos, "killed"))
stable: Final = _stable_call(chaos)
assert stable.status_code == 200, stable.text
eventually(lambda: _worker_pids(chaos.log), lambda pids: len(pids) == len(workers) + 1, seconds=180)
eventually(lambda: _settled(chaos), lambda ok: ok, seconds=120)
_assert_served(_burst(chaos, "respawned"))