Isolation & Contracts

Simulating Timeouts and Connection Errors

Every HTTP client has a failure path — a timeout, a refused connection, a reset halfway through a response — and it is the code that runs during incidents, when correctness matters most. It is also the code tests exercise least, because producing a real timeout means waiting for one, and producing a real connection reset means breaking something. Transport-level fakes solve both: they raise the exact exceptions the HTTP library raises, instantly, on whichever request the test chooses.

Failure-path tests also document behaviour that is otherwise only discoverable during an outage: how many times the client retries, which errors it considers retryable, what the caller receives when it finally gives up. Writing those down as tests turns an operational question — "what does checkout do when billing is slow?" — into something answered by reading a test file rather than by waiting for the next incident. The key detail is raising the library's own exception types. Code that catches httpx.ConnectTimeout does not catch a generic TimeoutError, so a test raising the wrong type exercises a path that never runs in production and misses the one that does.

Prerequisites

  • respx >= 0.21 for httpx, or responses >= 0.25 for requests.
  • pytest >= 8.0, with pytest-asyncio for async clients.
  • The transport-faking approach from mocking httpx clients with respx.

Solution

Python
import httpx
import pytest
import respx


@respx.mock
def test_connect_timeout_becomes_a_domain_error():
    respx.get("https://billing.test/invoices/inv_1").mock(
        side_effect=httpx.ConnectTimeout("connect timed out")   # the real type
    )

    with pytest.raises(BillingUnavailable):
        BillingClient("https://billing.test").fetch("inv_1")


@respx.mock
def test_retry_recovers_after_two_read_timeouts():
    route = respx.get("https://billing.test/invoices/inv_1").mock(side_effect=[
        httpx.ReadTimeout("slow"),
        httpx.ReadTimeout("slow"),
        httpx.Response(200, json={"id": "inv_1", "total_minor": 1234}),
    ])

    invoice = BillingClient("https://billing.test", retries=3).fetch("inv_1")

    assert invoice.total_minor == 1234
    assert route.call_count == 3                 # two failures, one success


@respx.mock
def test_refused_connection_is_not_retried_forever():
    route = respx.get("https://billing.test/health").mock(
        side_effect=httpx.ConnectError("connection refused")
    )

    with pytest.raises(BillingUnavailable):
        BillingClient("https://billing.test", retries=3).health()

    assert route.call_count == 3                 # bounded, not infinite
Where network failures occur in a request A request's lifecycle has three failure points. ConnectError or ConnectTimeout happens before any bytes are sent. ReadTimeout happens after the request is sent while waiting for a response. A RemoteProtocolError or read error mid-body happens after a status line has arrived. Each has different retry safety and needs its own test. Three failure points, three different meanings connect send + wait read body ConnectError / Timeout nothing was sent always safe to retry ReadTimeout request may have run retry only if idempotent error mid-body server acted, reply lost partial data handling
A client that treats all three alike will either retry non-idempotent requests after a read timeout or give up on connection errors that were safe to retry.

Why this works

respx replaces httpx's transport, the layer that actually opens sockets. When a route's side effect is an exception, the transport raises it at the point a real network error would surface, so everything above — the client's retry wrapper, its error translation, its logging — runs exactly as in production. The test controls which request fails, how many times, and with which error, without any real network involved.

The call history on each route is equally useful. route.calls records every request that reached the fake, including headers and body, so a test can assert not only how many attempts were made but what each one sent — which is how idempotency and retry headers are verified below. Because failure is scripted rather than waited for, the tests run in milliseconds. The client's configured timeouts are irrelevant; the exception arrives immediately, carrying the same type and message the client's error handling inspects.

Edge cases and failure modes

  • Generic exceptions. Raising TimeoutError or Exception bypasses handlers written for the library's types. Always use the library's exception classes.
  • Retrying non-idempotent requests. A POST that timed out while reading may already have been processed. Test that the client does not blindly retry it, or that it sends an idempotency key if it does.
  • Timeouts on the fake itself. Wrapping a route in a real sleep to "simulate slowness" makes tests slow and still does not trigger the client's timeout reliably. Raise the timeout exception directly.
  • Unmatched routes. respx raises for requests that match no route by default, which is the right behaviour; do not disable it, or an unexpected URL silently succeeds.
  • Async clients. The same routes work with httpx.AsyncClient; the exceptions are raised on await. Run the tests under an async runner.

Testing idempotency under read timeouts

The middle column of the diagram above is where real incidents come from. A read timeout means the request left the client and the response never came back — so the server may have charged the card, created the order, or sent the email. Retrying blindly doubles the effect. The tests that matter are the ones that pin down what the client does in exactly that case.

For safe methods — GET, HEAD — retrying after a read timeout is fine, and the test is the retry-recovers test above. For unsafe methods, one of two behaviours is correct, and each gets its own test. Either the client does not retry at all and surfaces an ambiguous-outcome error the caller must handle; or it retries with an idempotency key that lets the server recognise the duplicate. The second is testable directly: record the headers of every attempt and assert they carry the same key.

Python
@respx.mock
def test_charge_retry_reuses_the_idempotency_key():
    route = respx.post("https://pay.test/charges").mock(side_effect=[
        httpx.ReadTimeout("slow"),
        httpx.Response(201, json={"id": "ch_1"}),
    ])

    PaymentsClient("https://pay.test", retries=2).charge(amount_minor=4999)

    keys = {call.request.headers["Idempotency-Key"] for call in route.calls}
    assert len(keys) == 1                        # same key on every attempt
    assert route.call_count == 2

That single assertion — one distinct key across every attempt — is what separates a client that is safe to retry from one that double-charges customers during a slow afternoon at the payment provider. It is short, deterministic, and worth having on every client that retries unsafe requests.

Retrying an unsafe request after a read timeout A POST charge request times out while reading the response, after the server may already have processed it. Without an idempotency key, the retry creates a second charge. With the same idempotency key on both attempts, the server recognises the retry and returns the original charge, so the customer is charged once. The retry is safe only if the server can recognise it no key attempt 1: charged, reply lost attempt 2: charged again customer charged twice same Idempotency-Key attempt 1: charged, reply lost attempt 2: server returns ch_1 charged once
The test asserts the left column cannot happen by checking every attempt carried the same key.

Partial responses

The rightmost column covers the case most suites never test: the server started responding and the connection died mid-body. For a streamed download that means a truncated file; for JSON it means a parse error on half a document. A streamed response whose chunk iterator raises after the first chunk reproduces it exactly, and the assertion is that the client reports a transport failure rather than returning or parsing partial data. Clients that validate the declared Content-Length against the bytes received, or that parse only complete documents, pass; clients that hand whatever arrived to the caller fail — and that failure is precisely the bug worth finding before it corrupts a record in production.

Handling a response that dies mid-body A streamed response delivers its status line and first chunk, then the connection breaks. A careless client returns or parses the partial data, producing a truncated file or a broken record. A careful client detects the short read against the declared length and raises a transport error instead. 200 OK, then nothing status + chunk 1 connection reset chunks that never arrive careless client returns truncated data as success careful client detects short read, raises
Only a test that breaks the stream partway can tell these two clients apart; both pass every whole-response test.

Building a failure matrix for a client

A client with retries, timeouts and error translation has a small, finite set of behaviours worth pinning down, and writing them as a table before writing tests makes gaps obvious. The rows are the failure types — connect error, connect timeout, read timeout, a 5xx status, a 429 with a retry-after header, a mid-body reset. The columns are the request kinds the client makes — safe reads, unsafe writes with an idempotency key, unsafe writes without one. Each cell states the expected outcome: retried and recovered, retried and surfaced after the limit, not retried and surfaced immediately.

Most clients turn out to need a dozen or so cells, and each is a three-line test with respx and a side-effect sequence. The table itself is worth keeping as a comment at the top of the test module, because it is the clearest statement anywhere in the codebase of how the client behaves when the network misbehaves — the question every on-call engineer eventually asks, usually at an inconvenient hour.

Two cells tend to be missing from suites that never built the table. The first is the 429 row: clients that retry 5xx responses aggressively often treat rate limiting the same way and hammer a provider that asked them to back off. The second is the unsafe-write-without-key row under a read timeout, which is exactly the double-charge scenario above. Filling those two cells alone justifies the exercise, and the rest of the table comes almost for free once the test pattern is established. When the client's retry policy later changes, the table is also the first thing to update, which keeps the tests and the documented behaviour in step without anyone having to remember they are related. A dozen short tests and one comment block are a small price for that. They also run in well under a second.

Frequently Asked Questions

Should a test simulate a timeout by actually waiting? No. Raise the library's timeout exception from the transport fake instead. The code under test sees exactly what it would see after a real timeout, and the test runs in milliseconds rather than waiting out the configured deadline.

Which exception types should the fake raise? The ones the real HTTP library raises: httpx.ConnectTimeout, httpx.ReadTimeout and httpx.ConnectError for httpx; requests.exceptions.ConnectTimeout, ReadTimeout and ConnectionError for requests. Raising a generic TimeoutError tests a path production never takes.

How do I test a failure that happens after some data arrived? Use a streamed response whose iterator raises partway through, or a side effect sequence that returns a partial body and then raises a read error on the next call. That exercises the partial-read handling that simple error tests skip.

← Back to Mocking Network and HTTP Calls