Testing a client¶
headgate.testing tests a client through its real stack (retries, the rate budget, the breaker, the error mapping)
with nothing private patched. Two seams make that possible:
- the network is
HttpOptions(transport=...). Have the client accept a transport and pass it through, as the examples do:CatalogClient(api_key, transport=fake); - time and breaker state are read when a client is constructed, from context variables the kit's context managers set. Build the client inside them; entering one around a client that already exists changes nothing.
from collections.abc import Iterator
import pytest
from headgate.testing import FakeTransport, Respond, instant_retries, isolated_breakers
@pytest.fixture
def catalog() -> Iterator[CatalogClient]:
with instant_retries(), isolated_breakers():
yield CatalogClient("test-key", transport=FakeTransport().reply("GET", "/v1/series/GDP", Respond(json=GDP)))
FakeTransport¶
A route table of canned replies and a log of every request:
fake = (
FakeTransport()
.reply("GET", "/v1/series/GDP", httpx2.Response(503), httpx2.Response(200, json=GDP))
.reply("DELETE", "/v1/series/GDP", 204)
)
- A route matches the method (case-insensitively) and the path exactly as sent: with the client's base path, and
percent-encoded as a t-string escapes it (
/v1/series/A%2FB). The query is not part of the match. - A route's replies are served in order, the last repeating, so a retry sequence is one route.
- A reply is an
httpx2.Responsebuilt withcontent=,json=ortext=(copied for each request), a bare status, or a function of the request returning a response, sync or async. A function can raise an httpx2 error to simulate a network failure. fake.callsis every request sent, in order, body read;fake.calls_to(method, path)filters it;fake.closedsays whether the client closed it.- A request with no route raises
UnmatchedRequestError, anAssertionErrorthe client neither retries nor maps, naming the request and the routes there are.
TestClock and instant retries¶
TestClock() is a monotonic clock that moves only when told: clock.now, clock.advance(seconds), and an async
clock.sleep that records the wait in clock.sleeps and advances the clock instead of waiting. Clients built inside
clock.install() measure every duration on it (deadlines, events, rate pacing, and the cooldowns of a registry built by
isolated_breakers() in the same block) and wait through its sleep, so a test of a 60-second cooldown runs in no time.
instant_retries() keeps the real clock and makes every wait return at once, so a mocked failure is retried exactly as
production retries it, without the backoff.
Isolated breakers¶
isolated_breakers() gives the clients built in its block a fresh BreakerRegistry (and yields it), so failures in one
test open no breaker in the next. Assert on it with headgate.health.breaker_states(registry). A client configured
with a registry of its own keeps it.
Fault injection¶
FaultyTransport(schedule=[...], sleep=asyncio.sleep) answers each send with the next step of its schedule, reports
send progress the way httpx2's own transport does (so the retry layer's "was anything written?" rule sees real facts),
and records each send as a FaultCall: the step, method, URL, headers, whether it connected, bytes_sent, the
response, whether that response was closed, and the error raised.
| Step | Does |
|---|---|
ConnectFail(timeout=False) |
raises ConnectError (ConnectTimeout with timeout=True); nothing sent |
PoolFull() |
raises PoolTimeout; nothing sent |
Respond(status_code=200, headers=..., json=..., content=..., retry_after=None) |
answers |
DropMidBody(after_bytes) |
writes that many request bytes, then loses the connection |
Slow(seconds, then=Respond()) |
waits (honoring the read timeout), then takes then |
RefusedStream() |
resets the HTTP/2 stream with REFUSED_STREAM, exactly as httpx2 raises it |
from headgate.testing import ConnectFail, DropMidBody, FaultyTransport, Respond
faults = FaultyTransport(schedule=[ConnectFail(), DropMidBody(after_bytes=200), Respond(201, json={"id": "o-1"})])
With that schedule, a POST without an idempotency key is re-sent after the ConnectFail (nothing was written) but not
after the DropMidBody (bytes were written): the call raises TransportError and faults.calls shows two sends. A
send past the end of the schedule raises AssertionError.
Recorded cassettes¶
vcrpy 8.3 and later record httpx2's own transports, so they see what headgate's default network transport sends, once
per attempt, below the whole stack. CassetteFilter keeps credentials out of the cassette:
import pytest
from headgate import HttpOptions
from headgate.testing import CassetteFilter
CASSETTES = CassetteFilter(auths=(partner_auth,), headers=("x-partner-signature",))
@pytest.fixture(scope="module")
def vcr_config() -> dict[str, object]:
return CASSETTES.vcr_config()
http = HttpOptions(middleware=(CASSETTES,)) # on the client under test
It filters Authorization, Proxy-Authorization, Cookie, every header and parameter the auths declare and the names
given, redacts secret query values and userinfo before a request is recorded or matched, and drops Set-Cookie from
recorded responses. Installed as middleware, it marks a CachedTokenAuth mint's hops so they are not recorded: a token
response is a credential. When replaying, seed the auth with a token (auth.seed("test-token", 3600)). A custom
transport (FakeTransport, the curl transport) is not patched by vcrpy, so it is neither recorded nor replayed.
Switching the policy off¶
Some tests, and connectivity probes, want the first failure as it is: no retries, no breaker, no pacing.
headgate.policy.policy_disabled() builds the clients constructed in its block with retries off (whatever a call or
route asks for), no breaker (the client never reads a registry, so it cannot be refused by one another client opened),
and an effectively unlimited rate, also for its rate_limits rules and a shared RateBudget passed in.
from collections.abc import Iterator
import pytest
from headgate.policy import policy_disabled
@pytest.fixture(autouse=True)
def _no_transport_policy() -> Iterator[None]:
with policy_disabled():
yield
Prefer instant_retries() when the retry behavior is part of what you test: it keeps the retries and makes them
instant.