49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
95 lines
4.4 KiB
Python
95 lines
4.4 KiB
Python
"""Contract tests for the suite's streaming ASGI bridge.
|
|
|
|
These pin what `StreamingASGITransport` itself guarantees — chunk-by-chunk delivery, disconnect
|
|
propagation, and failure handling — against minimal hand-written ASGI applications, so the MCP
|
|
transport tests built on top of it never have to wonder what the harness provides. They are
|
|
harness self-tests, not interaction-model tests, and are exempted from the requirement-coverage
|
|
contract in `test_coverage.py`.
|
|
"""
|
|
|
|
import anyio
|
|
import httpx
|
|
import pytest
|
|
from starlette.types import Message, Receive, Scope, Send
|
|
|
|
from tests.interaction.transports._bridge import StreamingASGITransport
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
async def test_response_chunks_arrive_as_the_application_sends_them() -> None:
|
|
"""Each body chunk is delivered as sent, empty chunks are skipped, and the stream ends with the application."""
|
|
|
|
async def chunked_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
assert scope["type"] == "http"
|
|
assert (await receive())["type"] == "http.request"
|
|
await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]})
|
|
await send({"type": "http.response.body", "body": b"first", "more_body": True})
|
|
await send({"type": "http.response.body", "body": b"", "more_body": True})
|
|
await send({"type": "http.response.body", "body": b"second", "more_body": False})
|
|
|
|
async with (
|
|
httpx.AsyncClient(transport=StreamingASGITransport(chunked_app), base_url="http://bridge") as http,
|
|
http.stream("GET", "/chunks") as response,
|
|
):
|
|
with anyio.fail_after(5):
|
|
chunks = [chunk async for chunk in response.aiter_raw()]
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "text/plain"
|
|
assert chunks == [b"first", b"second"]
|
|
|
|
|
|
async def test_closing_the_response_delivers_a_disconnect_to_the_application() -> None:
|
|
"""A client that closes the response early is seen by the application as an http.disconnect."""
|
|
seen_after_request: list[Message] = []
|
|
disconnect_seen = anyio.Event()
|
|
|
|
async def waiting_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
assert scope["type"] == "http"
|
|
assert (await receive())["type"] == "http.request"
|
|
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
seen_after_request.append(await receive())
|
|
disconnect_seen.set()
|
|
|
|
async with httpx.AsyncClient(transport=StreamingASGITransport(waiting_app), base_url="http://bridge") as http:
|
|
async with http.stream("GET", "/wait") as response:
|
|
assert response.status_code == 200
|
|
# Leaving the stream block closes the response while the application is still mid-response.
|
|
with anyio.fail_after(5):
|
|
await disconnect_seen.wait()
|
|
|
|
assert seen_after_request == [{"type": "http.disconnect"}]
|
|
|
|
|
|
async def test_an_application_failure_before_the_response_starts_fails_the_request() -> None:
|
|
"""An exception raised before http.response.start reaches the caller as that same exception."""
|
|
|
|
async def broken_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
raise RuntimeError("the demo application is broken")
|
|
|
|
async with httpx.AsyncClient(transport=StreamingASGITransport(broken_app), base_url="http://bridge") as http:
|
|
with pytest.raises(RuntimeError, match="the demo application is broken"):
|
|
await http.get("/broken")
|
|
|
|
|
|
async def test_disabling_cancel_on_close_lets_the_application_finish_after_disconnect() -> None:
|
|
"""With cancel_on_close=False, an application that runs cleanup after seeing http.disconnect
|
|
completes that cleanup before the transport finishes closing."""
|
|
cleanup_ran = anyio.Event()
|
|
|
|
async def lingering_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
assert scope["type"] == "http"
|
|
await receive()
|
|
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
assert (await receive())["type"] == "http.disconnect"
|
|
cleanup_ran.set()
|
|
|
|
transport = StreamingASGITransport(lingering_app, cancel_on_close=False)
|
|
with anyio.fail_after(5):
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://bridge") as http:
|
|
async with http.stream("GET", "/linger") as response:
|
|
assert response.status_code == 200
|
|
assert not cleanup_ran.is_set()
|
|
assert cleanup_ran.is_set()
|