litellm/tests/unit/integration_support/test_upstream.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

285 lines
12 KiB
Python

import asyncio
from collections import deque
from pathlib import Path
from typing import Final
import httpx
import pytest
from starlette.types import Message, Scope
from starlette.websockets import WebSocket
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", (OSError("client disconnected"), RuntimeError("unexpected upstream failure")))
async def test_realtime_disconnect_after_session_creation_keeps_unexpected_errors_visible(
failure: Exception, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2]))
from integration._support.upstream import Provider
from integration.cost_calculation.cost_tracking_case import RealtimeResponse
provider: Final = Provider()
response: Final = RealtimeResponse(content_type="application/x-realtime", events=({"type": "response.done"},))
provider.scenario_store.put("owned-scenario", response)
incoming: Final[deque[Message]] = deque(
(
{"type": "websocket.connect"},
{"type": "websocket.receive", "text": '{"type":"response.create"}'},
{"type": "websocket.disconnect", "code": 1006},
)
)
outgoing: Final[deque[Message]] = deque()
async def receive() -> Message:
return incoming.popleft()
async def send(message: Message) -> None:
if message["type"] == "websocket.send" and any(item["type"] == "websocket.send" for item in outgoing):
raise failure
outgoing.append(message)
scope: Final[Scope] = {
"type": "websocket",
"path": "/v1/realtime",
"headers": [(b"authorization", b"Bearer owned-scenario")],
"query_string": b"model=synthetic",
}
socket: Final = WebSocket(scope, receive, send)
if isinstance(failure, RuntimeError):
with pytest.raises(RuntimeError, match="unexpected upstream failure"):
await provider.realtime(socket)
else:
await provider.realtime(socket)
assert [item["type"] for item in outgoing] == ["websocket.accept", "websocket.send"]
observed: Final = provider.observations.snapshot()
assert [(item.method, item.body) for item in observed] == [
("WEBSOCKET", {"query": [["model", "synthetic"]]}),
("WEBSOCKET_FRAME", {"type": "response.create"}),
]
@pytest.mark.asyncio
async def test_observations_read_without_draining_until_explicitly_reset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2]))
from integration._support.upstream import Provider
transport: Final = httpx.ASGITransport(app=Provider().app())
async with httpx.AsyncClient(transport=transport, base_url="http://upstream") as client:
sent: Final = await client.post(
"/v1/chat/completions",
json={"model": "synthetic", "messages": [{"role": "user", "content": "observed-marker"}]},
)
assert sent.status_code == 200, sent.text
first: Final = (await client.get("/__observations")).json()["requests"]
second: Final = (await client.get("/__observations")).json()["requests"]
assert [(item["path"], item["body"]["messages"][0]["content"]) for item in first] == [
("/v1/chat/completions", "observed-marker")
]
assert second == first
reset: Final = await client.delete("/__observations")
assert reset.status_code == 204
assert (await client.get("/__observations")).json()["requests"] == []
@pytest.mark.asyncio
@pytest.mark.parametrize("method,status", (("POST", 200), ("POST", 400), ("DELETE", 200)))
async def test_mcp_notification_ordering_preserves_other_responses(
method: str, status: int, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2]))
from integration._support.mcp import _acknowledge_notifications_after_dispatch
from starlette.types import Receive, Send
outgoing: Final[list[Message]] = []
messages: Final[tuple[Message, ...]] = (
{"type": "http.response.start", "status": status, "headers": []},
{"type": "http.response.body", "body": b"result", "more_body": True},
{"type": "http.response.body", "body": b"", "more_body": False},
)
async def receive() -> Message:
return {"type": "http.disconnect"}
async def send(message: Message) -> None:
outgoing.append(message)
async def app(scope: Scope, receive: Receive, send: Send) -> None:
for index, message in enumerate(messages):
await send(message)
assert outgoing == list(messages[: index + 1]), "Ordinary response streaming was buffered"
await _acknowledge_notifications_after_dispatch(app)({"type": "http", "method": method}, receive, send)
assert outgoing == list(messages)
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", (RuntimeError("dispatch failed"), asyncio.CancelledError()))
async def test_mcp_notification_dispatch_failure_is_not_acknowledged(
failure: BaseException, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2]))
from integration._support.mcp import _acknowledge_notifications_after_dispatch
from starlette.types import Receive, Send
outgoing: Final[list[Message]] = []
async def receive() -> Message:
return {"type": "http.disconnect"}
async def send(message: Message) -> None:
outgoing.append(message)
async def app(scope: Scope, receive: Receive, send: Send) -> None:
if scope["method"] == "DELETE":
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b""})
return
await send({"type": "http.response.start", "status": 202, "headers": []})
await send({"type": "http.response.body", "body": b""})
raise failure
ordered: Final = _acknowledge_notifications_after_dispatch(app)
scope: Final[Scope] = {"type": "http", "headers": [(b"mcp-session-id", b"failed-session")]}
with pytest.raises(type(failure)) as caught:
await ordered({**scope, "method": "POST"}, receive, send)
assert caught.value is failure
assert outgoing == [], "Failed dispatch must not send an acknowledgement"
await asyncio.wait_for(ordered({**scope, "method": "DELETE"}, receive, send), 2)
assert outgoing[0]["status"] == 200
@pytest.mark.asyncio
@pytest.mark.parametrize(
"responses",
(
(),
({"type": "http.response.body", "body": b"unexpected"},),
({"type": "http.response.body", "body": b"", "more_body": True},),
({"type": "http.response.start", "status": 500, "headers": []},),
({"type": "http.response.body", "body": b""}, {"type": "http.response.body", "body": b""}),
),
)
async def test_mcp_notification_ordering_rejects_invalid_acknowledgements(
responses: tuple[Message, ...], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2]))
from integration._support.mcp import _acknowledge_notifications_after_dispatch
from starlette.types import Receive, Send
outgoing: Final[list[Message]] = []
async def receive() -> Message:
return {"type": "http.disconnect"}
async def send(message: Message) -> None:
outgoing.append(message)
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await send({"type": "http.response.start", "status": 202, "headers": []})
for message in responses:
await send(message)
with pytest.raises(RuntimeError, match="MCP notification acknowledgement"):
await _acknowledge_notifications_after_dispatch(app)({"type": "http", "method": "POST"}, receive, send)
assert outgoing == [], "Invalid acknowledgement must not be reported as accepted"
@pytest.mark.asyncio
async def test_mcp_session_deletion_waits_for_concurrent_notification_dispatch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2]))
from integration._support.mcp import _acknowledge_notifications_after_dispatch
from starlette.types import Receive, Send
started: Final = [asyncio.Event(), asyncio.Event()]
release: Final = asyncio.Event()
deletion_started: Final = asyncio.Event()
deleted: Final = asyncio.Event()
other_deleted: Final = asyncio.Event()
completed: Final[list[str]] = []
async def receive() -> Message:
return {"type": "http.disconnect"}
async def send(message: Message) -> None:
return None
async def app(scope: Scope, receive: Receive, send: Send) -> None:
if scope["method"] == "POST":
started[int(scope["path"])].set()
await release.wait()
completed.append(scope["path"])
await send({"type": "http.response.start", "status": 202, "headers": []})
await send({"type": "http.response.body", "body": b""})
else:
if scope["headers"][0][1] == b"other-session":
other_deleted.set()
else:
deleted.set()
completed.append("DELETE")
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b""})
ordered: Final = _acknowledge_notifications_after_dispatch(app)
scope: Final[Scope] = {"type": "http", "headers": [(b"mcp-session-id", b"owned-session")]}
posts: Final = [
asyncio.create_task(ordered({**scope, "method": "POST", "path": str(index)}, receive, send))
for index in range(2)
]
async def delete() -> None:
deletion_started.set()
await ordered({**scope, "method": "DELETE"}, receive, send)
deletion = None
try:
await asyncio.wait_for(asyncio.gather(*(event.wait() for event in started)), 2)
deletion = asyncio.create_task(delete())
await deletion_started.wait()
await asyncio.wait_for(
ordered({**scope, "method": "DELETE", "headers": [(b"mcp-session-id", b"other-session")]}, receive, send), 2
)
assert other_deleted.is_set(), "Unrelated session deletion was blocked"
assert not deleted.is_set(), "Session deletion overtook in-flight notification dispatch"
finally:
release.set()
await asyncio.wait_for(asyncio.gather(*posts, *([deletion] if deletion is not None else [])), 2)
assert set(completed[:2]) == {"0", "1"}
assert completed[-1] == "DELETE"
@pytest.mark.asyncio
async def test_mcp_session_deletion_can_terminate_streaming_request(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2]))
from integration._support.mcp import _acknowledge_notifications_after_dispatch
from starlette.types import Receive, Send
streaming: Final = asyncio.Event()
deleted: Final = asyncio.Event()
async def receive() -> Message:
return {"type": "http.disconnect"}
async def send(message: Message) -> None:
return None
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await send({"type": "http.response.start", "status": 200, "headers": []})
if scope["method"] == "POST":
streaming.set()
await deleted.wait()
else:
deleted.set()
await send({"type": "http.response.body", "body": b""})
ordered: Final = _acknowledge_notifications_after_dispatch(app)
scope: Final[Scope] = {"type": "http", "headers": [(b"mcp-session-id", b"streaming-session")]}
post: Final = asyncio.create_task(ordered({**scope, "method": "POST"}, receive, send))
try:
await asyncio.wait_for(streaming.wait(), 2)
await asyncio.wait_for(ordered({**scope, "method": "DELETE"}, receive, send), 2)
assert deleted.is_set(), "Session deletion must terminate an ordinary streaming request"
finally:
deleted.set()
await asyncio.wait_for(post, 2)