Mocking External APIs with respx: Routes, Side Effects, and Honest Failure Tests
Your API bills customers through Stripe, enriches records from a data vendor, and pushes events to a webhook consumer. Every one of those calls is a network hop you do not control, and every one of them is a place your test suite either tells the truth or lies to you. This page resolves a specific choice: how to fake third-party HTTP traffic inside a pytest suite so the fake stays accurate, the suite stays fast, and a vendor outage never turns your build red. It extends Testing Python APIs with pytest, the parent guide, which sets up the fixtures and event loop the examples here assume.
My recommendation is narrow and firm: use respx for everything your code initiates over HTTP, because it intercepts at the httpx transport layer instead of monkey-patching your own functions. That distinction matters more than it sounds. A monkey-patched fetch_rates() proves your test double works. A respx route proves your real client code builds the right URL, sends the right headers, serialises the right JSON, and handles the real status codes — with zero packets leaving the process.
The four ways to fake a vendor, compared
Before the code, be clear about what you are choosing between. Four approaches show up in real Python suites, and they differ mainly in how much of your own code stays live during the test.
| Approach | Your client code runs | Drift risk | Speed |
|---|---|---|---|
| respx routes | Fully | Medium | Microseconds |
| Monkeypatched function | No | High | Microseconds |
| Local fake server | Fully | Medium | Milliseconds |
| Vendor sandbox | Fully | None | Seconds |
Monkeypatching your own get_rates() is the option to delete. It skips URL building, header injection, serialisation and status handling — precisely the code most likely to be wrong. A local fake server (a small FastAPI app on a random port) runs everything including the socket, but it costs a process, a port, and a startup wait per test session, and you still hand-write the responses, so its accuracy is no better than respx. The vendor sandbox is the only option with zero drift, and it belongs in a nightly job, not in the suite that gates your pull requests. respx sits at the sweet spot: full client code, no sockets, no ports, no flakes.
Drift is the real enemy here, not speed. A mock is a claim about someone else's server, and that claim silently rots the day the vendor adds a required field or changes an error envelope. The last section covers how to bound that rot without slowing your suite down.
Routing requests: the shape that scales
Start with a client that reads everything from the environment. The test suite then points that same client at an unroutable host, which guarantees a mistake in your mock setup fails loudly instead of hitting a live endpoint.
# app/rates_client.py
import asyncio
import os
from dataclasses import dataclass
from functools import lru_cache
import httpx
@dataclass(frozen=True, slots=True)
class RatesConfig:
base_url: str
api_key: str
timeout: float
@lru_cache
def get_config() -> RatesConfig:
return RatesConfig(
base_url=os.environ["RATES_API_BASE"],
api_key=os.environ["RATES_API_KEY"],
timeout=float(os.getenv("RATES_TIMEOUT_SECONDS", "5.0")),
)
def build_client() -> httpx.AsyncClient:
config = get_config()
return httpx.AsyncClient(
base_url=config.base_url,
headers={"Authorization": f"Bearer {config.api_key}"},
timeout=httpx.Timeout(config.timeout),
)
async def convert(client: httpx.AsyncClient, amount_cents: int, target: str) -> int:
response = await client.post(
"/v1/convert",
json={"amount": amount_cents, "to": target},
headers={"Idempotency-Key": f"conv-{amount_cents}-{target}"},
)
response.raise_for_status()
return int(response.json()["converted"])
async def convert_with_retry(
client: httpx.AsyncClient, amount_cents: int, target: str, attempts: int = 4
) -> int:
for attempt in range(attempts):
try:
return await convert(client, amount_cents, target)
except httpx.HTTPStatusError as exc:
if exc.response.status_code != 429 or attempt == attempts - 1:
raise
await asyncio.sleep(float(exc.response.headers.get("Retry-After", "1")))
raise RuntimeError("unreachable")
The fixture below injects the environment and clears the cached config so each test starts clean. Point the base URL at a .invalid host — that top-level domain is reserved and can never resolve, so an unmocked request fails instantly rather than leaking traffic.
# tests/conftest.py
import os
import pytest
from app.rates_client import build_client, get_config
FAKE_BASE = os.getenv("TEST_RATES_API_BASE", "https://rates.invalid")
@pytest.fixture(autouse=True)
def rates_env(monkeypatch):
monkeypatch.setenv("RATES_API_BASE", FAKE_BASE)
monkeypatch.setenv("RATES_API_KEY", os.getenv("TEST_RATES_API_KEY", "test-key"))
get_config.cache_clear()
yield
get_config.cache_clear()
@pytest.fixture
async def rates_client():
async with build_client() as client:
yield client
Now the actual mock. respx ships a pytest plugin, so you get a respx_mock router fixture for free, and the respx marker sets a base URL so routes stay short.
# tests/test_rates_client.py
import httpx
import pytest
import respx
from app.rates_client import convert
from tests.conftest import FAKE_BASE
pytestmark = pytest.mark.anyio
@pytest.mark.respx(base_url=FAKE_BASE)
async def test_convert_parses_cents(respx_mock, rates_client):
route = respx_mock.post("/v1/convert").mock(
return_value=httpx.Response(200, json={"converted": 1837, "rate": 0.9185})
)
assert await convert(rates_client, 2000, "EUR") == 1837
assert route.call_count == 1
Two defaults do heavy lifting and you should leave both alone. assert_all_mocked makes any request that matches no route raise immediately, so a typo in a path surfaces as a test failure instead of a real network call. assert_all_called makes a declared-but-unused route fail the test at teardown, which catches the dead mocks that accumulate after you refactor a code path away. Together they keep the mock set honest as the codebase moves.
Assert what you sent, not only what you got
Half the value of respx is the recorded call list. A test that only checks the return value proves your parser works; it says nothing about whether you sent a valid request. That gap is where the expensive bugs live — a missing idempotency key that double-charges a customer, a currency sent as a float, an auth header that never made it onto the request.
Two techniques cover it. Encode strict expectations in the route pattern so a wrong request simply fails to match, and inspect route.calls.last.request for anything you want to assert with a clearer message.
# tests/test_request_payload.py
import json
import httpx
import pytest
from app.rates_client import convert
from tests.conftest import FAKE_BASE
pytestmark = pytest.mark.anyio
@pytest.mark.respx(base_url=FAKE_BASE)
async def test_request_carries_auth_and_idempotency(respx_mock, rates_client):
route = respx_mock.post(
"/v1/convert",
json__eq={"amount": 2000, "to": "EUR"},
).mock(return_value=httpx.Response(200, json={"converted": 1837}))
await convert(rates_client, 2000, "EUR")
request = route.calls.last.request
assert request.headers["Authorization"].startswith("Bearer ")
assert request.headers["Idempotency-Key"] == "conv-2000-EUR"
body = json.loads(request.content)
assert isinstance(body["amount"], int) # cents, never floats
assert body["to"] == "EUR"
The json__eq lookup is the strict form: any extra or missing key makes the route miss, and the test fails with an unmatched-request error. Use it on the one or two calls where the payload contract is the point of the test, and use plain path routes plus explicit assertions everywhere else. Over-constraining every route turns a single field rename into forty red tests, which trains your team to loosen the mocks instead of reading them.
Do assert on types, not just values. isinstance(body["amount"], int) catches the day someone divides by 100 upstream and starts sending 20.0 where the vendor expects an integer. If your outbound payloads are built by Pydantic v2 models, you can go further and re-validate request.content against the same model in the test, which turns the mock into a genuine schema check.
Simulating timeouts, 429s, and the retry loop
This is where respx pays for itself. Anything you can raise, you can inject: pass an exception instance as side_effect and the client sees it exactly as it would see a real socket failure. Pass a list and each successive call pops the next item, so a retry sequence becomes a plain, deterministic test.
# tests/test_failure_modes.py
import asyncio
import httpx
import pytest
from app.rates_client import convert, convert_with_retry
from tests.conftest import FAKE_BASE
pytestmark = pytest.mark.anyio
@pytest.mark.respx(base_url=FAKE_BASE)
async def test_timeout_propagates(respx_mock, rates_client):
respx_mock.post("/v1/convert").mock(
side_effect=httpx.ReadTimeout("simulated upstream stall")
)
with pytest.raises(httpx.ReadTimeout):
await convert(rates_client, 2000, "EUR")
@pytest.mark.respx(base_url=FAKE_BASE)
async def test_retries_through_two_429s(respx_mock, rates_client, monkeypatch):
slept: list[float] = []
async def no_sleep(seconds: float) -> None:
slept.append(seconds)
monkeypatch.setattr(asyncio, "sleep", no_sleep)
route = respx_mock.post("/v1/convert").mock(
side_effect=[
httpx.Response(429, headers={"Retry-After": "1"}),
httpx.Response(429, headers={"Retry-After": "1"}),
httpx.Response(200, json={"converted": 1837}),
]
)
assert await convert_with_retry(rates_client, 2000, "EUR") == 1837
assert route.call_count == 3
assert slept == [1.0, 1.0] # honoured Retry-After, did not use blind backoff
Patching asyncio.sleep is the trick that keeps this test instant. Without it, a realistic backoff schedule turns three retries into nearly two seconds of wall clock, and a suite with thirty such tests spends a minute doing nothing. Capturing the sleep durations also lets you assert the policy, not just the outcome — this test proves the client honoured Retry-After rather than falling back to a fixed delay, which is the behaviour that keeps you inside a vendor's quota. If you drive retries with tenacity, the same pattern applies; see retrying failed HTTP requests with tenacity for the decorator form, and debugging 429 errors for reading the headers correctly.
Cover four failure modes for every vendor you depend on, because these are the ones that actually page you: a connect timeout, a read timeout mid-stream, a 429 with Retry-After, and a 500 that never recovers. The fourth matters most commercially — it decides whether a vendor outage degrades one feature or takes your whole API down. Write the test that asserts your endpoint still returns a useful response when the vendor is dead, then make it pass.
When to record real responses instead
Hand-written mocks encode what you believe the vendor returns. Belief drifts. The fix is not to abandon respx for full HTTP cassettes — cassette libraries record headers, timings and cookies you never asserted on, and those recordings rot into unreadable diffs. Record the part that carries meaning: the response body.
Capture a real payload once with a small script that hits the vendor sandbox, redacts secrets, and writes JSON into tests/fixtures/. Load those files into respx responses so your routes replay a genuine payload while the routing stays declarative and readable.
# tests/test_recorded_payloads.py
import json
import os
from pathlib import Path
import httpx
import pytest
from app.rates_client import convert
from tests.conftest import FAKE_BASE
FIXTURES = Path(os.getenv("TEST_FIXTURE_DIR", "tests/fixtures"))
pytestmark = pytest.mark.anyio
def load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
@pytest.mark.respx(base_url=FAKE_BASE)
async def test_parses_recorded_vendor_payload(respx_mock, rates_client):
respx_mock.post("/v1/convert").mock(
return_value=httpx.Response(200, json=load("convert_200.json"))
)
assert await convert(rates_client, 2000, "EUR") == 1837
@pytest.mark.skipif(
not os.getenv("RUN_CONTRACT_TESTS"), reason="nightly job only"
)
@pytest.mark.anyio
async def test_live_contract_still_matches_fixture():
"""Hits the real sandbox. Fails when the vendor changes shape."""
async with httpx.AsyncClient(
base_url=os.environ["RATES_SANDBOX_BASE"],
headers={"Authorization": f"Bearer {os.environ['RATES_SANDBOX_KEY']}"},
) as client:
live = await client.post("/v1/convert", json={"amount": 2000, "to": "EUR"})
assert live.status_code == 200
assert set(live.json()) >= set(load("convert_200.json"))
That second test is the whole strategy in eight lines. It never runs in your pull-request suite, so it cannot flake on a vendor outage and cannot slow anyone down. It runs nightly with RUN_CONTRACT_TESTS=1, and when the vendor drops a field or renames one, you find out from a scheduled job at 3am instead of from a customer. Comparing key sets rather than whole payloads keeps it stable against harmless additions while still catching removals.
When to choose respx, and when to skip it
Choose respx whenever your code is the HTTP client and you are past the toy stage — which for a commercial API is essentially day one. It costs one dependency and a few lines per test, and it removes an entire class of flaky failures from your build. At any traffic level it is the right default, because the cost model is flat: mocked calls consume no vendor quota, so a suite that runs 400 times a day costs nothing in metered API spend. That last point is real money when you depend on an LLM provider whose per-token costs make an accidentally-live test suite a genuine line item.
Skip respx in two situations. First, when the third party ships a first-party test harness that models state you would otherwise have to reimplement — Stripe's test clocks are the clear example, and testing Stripe integrations with test clocks explains why you should not hand-mock subscription lifecycles. Second, when the calls are inbound rather than outbound. respx intercepts requests your process makes; testing your own endpoints is the job of an httpx ASGITransport client, covered in testing async FastAPI endpoints with httpx.
Migrating an existing suite
If you already have monkeypatched client functions, switch incrementally in four steps. First, add respx to your dev dependencies and turn on the plugin's assertions by leaving the defaults. Second, pick the module with the most monkeypatch.setattr calls against your own HTTP helpers and replace each one with a route that mocks the same endpoint. Third, delete the now-unused fake return values, and let assert_all_called tell you which routes were never exercised. Fourth, add one recorded-payload test per vendor endpoint plus a single nightly contract test. If you are still on requests rather than httpx, migrate the client first — httpx versus requests for async work covers that move, and it is a prerequisite because respx only patches httpx transports.
Builder verdict
respx wins, and it is not close. For the price of one dev dependency you get tests that exercise your real client code, run in microseconds, never burn vendor quota, and can reproduce a timeout or a rate-limit storm on demand — failures you would otherwise ship untested because they are hard to trigger. Compared with a local fake server it saves you a process and a port; compared with monkeypatching it tests the code that actually breaks; compared with a live sandbox it keeps a vendor's bad afternoon out of your deploy pipeline. The shipping-velocity argument is the strongest one: a suite that can simulate a 429 in four milliseconds means you write the resilience test before the incident rather than after it, and resilience you tested is the difference between a degraded feature and a refunded month. Pair it with recorded JSON payloads for the shapes you parse and one nightly live contract job per vendor, and you have covered both halves of the risk — your code being wrong, and their server changing — for roughly an hour of setup.
FAQ
Does mocking with respx cost me vendor API quota during CI? No, and that is a large part of its commercial case. Mocked calls never leave the process, so a suite running hundreds of times a day consumes zero metered requests. Only the nightly contract job touches the vendor, which keeps your test-driven spend to a handful of calls a day instead of thousands.
How do I keep mocks from drifting after a vendor changes their API? Record real response bodies as JSON fixtures and replay them through respx, then run one live contract test per endpoint on a schedule that compares the live key set against the fixture. When the vendor renames a field, the nightly job fails and you fix it on your timetable rather than during an outage.
Should I mock my payment provider or use its sandbox? Use the provider's own test infrastructure for anything involving state over time — subscriptions, dunning, renewals. Use respx for the thin edges: asserting you sent the right idempotency key, and simulating the 500s and timeouts the sandbox will not produce on demand.
Will these tests still pass after I rotate an API key? Yes, because the client reads its key from the environment and the fixture injects a fake value. Rotation only affects the nightly contract job, so store that credential as a separate secret and treat its failure as an alert about the rotation, not about your code.
Is it risky to migrate a large monkeypatched suite to respx? Low risk, because the two coexist. Convert one module at a time and keep the old doubles until each route is green. The main surprise is that respx fails tests that previously passed, usually because your client was building a URL or header you never asserted on — that is the bug it was hired to find.
Related
Same track:
- Testing Python APIs with pytest — the parent guide with the fixtures, event-loop config and CI job these tests plug into.
- Testing Async FastAPI Endpoints with httpx — the inbound half: testing your own routes through an ASGI transport.
- Scaling and Operating Production Python APIs — the wider operations area this page sits in.
Other tracks:
- Retrying Failed HTTP Requests with tenacity — the retry policy you assert on in the 429 test.
- Testing Stripe Integrations with Test Clocks — when a vendor's own harness beats a hand-written mock.