|
| 1 | +"""Resilience interaction with max_response_body_bytes. |
| 2 | +
|
| 3 | +ResponseTooLargeError is a non-status ClientError, so it must fall outside the |
| 4 | +retry/circuit-breaker failure classifications. These tests lock that behavior so |
| 5 | +a future refactor can't silently make a cap trip retryable or breaker-counting. |
| 6 | +""" |
| 7 | + |
| 8 | +import httpx2 |
| 9 | +import pytest |
| 10 | + |
| 11 | +from httpware import AsyncClient, CircuitState, ResponseTooLargeError |
| 12 | +from httpware.middleware.resilience.circuit_breaker import AsyncCircuitBreaker |
| 13 | +from httpware.middleware.resilience.retry import AsyncRetry |
| 14 | + |
| 15 | + |
| 16 | +class _CountingHandler: |
| 17 | + """Mock transport that counts calls and always returns the same response.""" |
| 18 | + |
| 19 | + def __init__(self, status: int, body: bytes) -> None: |
| 20 | + self.status = status |
| 21 | + self.body = body |
| 22 | + self.calls = 0 |
| 23 | + |
| 24 | + def __call__(self, request: httpx2.Request) -> httpx2.Response: |
| 25 | + self.calls += 1 |
| 26 | + return httpx2.Response(self.status, content=self.body, request=request) |
| 27 | + |
| 28 | + |
| 29 | +def _client(handler: _CountingHandler, *, middleware: list[object], cap: int) -> AsyncClient: |
| 30 | + return AsyncClient( |
| 31 | + httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), |
| 32 | + middleware=middleware, # ty: ignore[invalid-argument-type] |
| 33 | + max_response_body_bytes=cap, |
| 34 | + ) |
| 35 | + |
| 36 | + |
| 37 | +async def test_response_too_large_is_not_retried() -> None: |
| 38 | + handler = _CountingHandler(200, b"x" * 200) |
| 39 | + client = _client(handler, middleware=[AsyncRetry()], cap=10) |
| 40 | + request = client.build_request("GET", "https://example.test/x") |
| 41 | + with pytest.raises(ResponseTooLargeError): |
| 42 | + await client.send(request) |
| 43 | + assert handler.calls == 1 # not retried — a single terminal attempt |
| 44 | + await client.aclose() |
| 45 | + |
| 46 | + |
| 47 | +async def test_over_cap_retryable_5xx_surfaces_as_too_large_not_retried() -> None: |
| 48 | + # 503 is retryable, but the cap trips first: cap-wins / fail-hard. |
| 49 | + handler = _CountingHandler(503, b"x" * 200) |
| 50 | + client = _client(handler, middleware=[AsyncRetry()], cap=10) |
| 51 | + request = client.build_request("GET", "https://example.test/x") |
| 52 | + with pytest.raises(ResponseTooLargeError) as caught: |
| 53 | + await client.send(request) |
| 54 | + assert caught.value.status_code == 503 # noqa: PLR2004 — the retryable status, surfaced not retried |
| 55 | + assert handler.calls == 1 |
| 56 | + await client.aclose() |
| 57 | + |
| 58 | + |
| 59 | +async def test_response_too_large_does_not_trip_circuit_breaker() -> None: |
| 60 | + # failure_threshold=1: one real failure would open the circuit; a cap trip must not. |
| 61 | + handler = _CountingHandler(500, b"x" * 200) |
| 62 | + breaker = AsyncCircuitBreaker(failure_threshold=1) |
| 63 | + client = _client(handler, middleware=[breaker], cap=10) |
| 64 | + request = client.build_request("GET", "https://example.test/x") |
| 65 | + for _ in range(3): |
| 66 | + with pytest.raises(ResponseTooLargeError): |
| 67 | + await client.send(request) |
| 68 | + assert breaker.state is CircuitState.CLOSED # neither success nor failure recorded |
| 69 | + assert handler.calls == 3 # noqa: PLR2004 — breaker never opened, every call reached the transport |
| 70 | + await client.aclose() |
0 commit comments