Design¶
How headgate is built, for contributors and for anyone deciding whether to trust it with their clients. The user guides describe what each piece does; this describes how they fit together.
Principles¶
- An endpoint method stays one line.
return await self.get(t"series/{id}", expect=SERIES). Every capability is configured once on the client, in an option object, or is a value passed at the call. No endpoint repeats boilerplate, so no endpoint can get it wrong. - Composable, typed, frozen values over flags. Policies are frozen, slotted, keyword-only dataclasses that validate
in
__post_init__and freeze the collections they hold (tuple,frozenset,MappingProxyType). - One place per concern. Each policy lives in one module or subpackage.
_client.pyorchestrates a call and owns no policy logic;_stack.pyassembles the layers. - Safe by default, explicit to loosen. Credentials, cache sharing, redirects, retries of writes, the environment and cookies all default to the safe behavior; loosening each is a named option.
- No logging, no environment reads. Observability is typed events delivered to observers the caller installs.
Nothing reads
os.environ; the HTTP client and the default transport are built withtrust_env=False. - Injectable time and randomness. Every clock, sleep and random source is a parameter with a production default.
headgate.testingsupplies fakes; no test patches a private global.
Packages and the import boundary¶
src/headgate/
__init__.py the public API, re-exported
_client.py AsyncBaseClient: a call end to end
_stack.py the transport stack, assembled in one place
_options.py TransportPolicy, HttpOptions, Timeouts, IdempotencyKeys, Route
_responses.py Reader, Expect, RefusedError and the built-in readers
_failures.py the ClientError a failed exchange becomes
_auth.py StaticHeaderAuth, QueryParamAuth, CachedTokenAuth
_codec.py the Codec protocol, and the readers and writers built on one
_body.py, _request_body.py, _redirects.py, _middleware.py, _attempts.py, _deprecation.py, _view.py, _url.py,
_user_agent.py, _curl.py
_core/ shared seams only: time, origins, CallContext, events, the policy switch
retry/ RetryPolicy and its values, RetryTransport, send tracking, the budget ledger
breaker/ BreakerPolicy, BreakerRegistry, CircuitBreaker
ratelimit/ Rate, RateBudget, Adaptive, RateRule, RateLimitTransport, server feedback
cache/ HttpCache, partitions, the resilient cache transport, conditional reads
security/ the credential guard, the https-downgrade refusal, the SSRF guard
streaming/ Stream
pagination/ Pager, its strategies, Paginated
errors.py, events.py, health.py, policy.py, otel.py, curl.py, msgspec.py, pydantic.py, testing/
retry, breaker and ratelimit may import only the standard library, httpx2, their own subpackage and
headgate._core; _core only the standard library and httpx2. They never import the client, the stack, the cache or
each other, so any of them could ship as a distribution of its own. Where they must cooperate (the retry layer asking
whether the breaker opened, the rate budget and the retry layer reading Retry-After the same way) they meet at a
seam defined in _core and wired in _stack.py or _client.py. tests/package/test_boundaries.py parses every
import, including those under TYPE_CHECKING, and fails on a crossing.
The codecs are the other boundary: the core imports no serialization library at runtime, and
tests/package/test_import_isolation.py checks it. It reaches one only through the Codec protocol
(headgate/_codec.py), which headgate.msgspec and headgate.pydantic implement and any library can.
A call, top to bottom¶
AsyncBaseClient.get(url, expect=..., **options)
_prepare render the t-string; build the httpx2.Request once; stamp its CallContext
_reported CallStarted ... CallFinished; the call's notes added to the ClientError it raises
_deadline asyncio.timeout(time left) around everything below
_exchange the origin's breaker: admission, then track() around the send and the body read
httpx2.AsyncClient.send
auth flow once per call; a mint's hops are sent through this same stack
request hooks every hop: the credential guard; the SSRF check over a custom transport
RetryTransport attempts, classification, backoff, Retry-After, budget, hedging
AttemptTransport AttemptFinished per send (only with observers)
MiddlewareTransport HttpOptions.middleware, once per attempt, then the credential guard again
ResilientCacheTransport opt-in hishel cache; a hit stops here
RateLimitTransport the rate budget: pacing, in-flight slots, server feedback
SendTrackingTransport whether each send connected, and the request bytes it wrote
network transport HTTP/2 AsyncHTTPTransport (pinned under the SSRF guard), or HttpOptions.transport
response hooks every hop: refuse an https downgrade; refuse to re-send a one-shot body; drop a
followed redirect's body
_sent a surviving 429/5xx raised; the breaker charged with what the exchange showed
read_capped the body, up to max_response_bytes, decompressed bytes counted
_dispatch refusal_check, then the declared answers, then the status ladder
stream() takes the same path, except that the response is handed to the caller's block open, with the breaker scope
held until the block exits, and the deadline bounds only opening it. Each page of paginate() is a get.
The breaker and the deadline are driven by the client, not by a transport layer, because only the client's scope spans
both the send and the body read. The cache is below the retry layer, so a hit is never retried, and above the rate
budget, so a hit spends nothing. Middleware is inside the retry layer, so it runs per attempt, and above the cache, so it
sees cache hits too. build_transport_stack builds the layers bottom-up, one statement each.
CallContext¶
A request travels through httpx2, hooks, a cache that rebuilds it, and transports that know nothing of each other. The
per-call state they share rides on the request as a single extension, a CallContext, which httpx2 copies to every
redirect hop and the cache layer restores below hishel. Every layer finds it with CallContext.of(request):
- what the call is:
info(method, sanitized URL, route template, absolute URL, call id); - when it must end:
deadline, absolute on the client's clock,remaining(), andfit_timeouts(request), which cuts a send's timeouts to the time left; - its sends:
begin_attempt(),attempt, and the current attempt'sprogress(connected, bytes sent); - what it carries:
idempotency_key,follow_redirects; - what happened:
add_note()lines, which the client adds to the error, andemit()for events; - per-layer data: a layer that needs a value of its own declares a typed
Slotin its own module and usesgetandput.RETRY_POLICY,REPLAYABLE_BODYandRETRY_OUTCOME(inretry) andBREAKER_GATE(in_core) are the slots today.
A request no client call built (a test driving one transport, a hop an auth flow yields) gets a standalone context with no deadline.
Events¶
The event types are defined in _core, below every layer, so each layer emits the events it owns through the call's
context: the client emits CallStarted, CallFinished, BreakerChanged and Deprecated; the attempt layer
AttemptFinished; the retry layer RetryScheduled; the rate budget and its transport RateLimited; the cache
CacheResult. A layer checks context.observed before building one, so a client without observers pays nothing.
Emitter delivers to each observer in turn, swallowing and counting what one raises.
Adding a layer¶
- Write it as an
httpx2.AsyncBaseTransportthat wraps the transport below it and closes it inaclose()(httpx2.AsyncBaseTransport.acloseis a no-op, so a missing override leaks the pool). - Decide where it sits: above the retry layer it runs once per call; below it, once per attempt; above the cache, for hits too; below the rate budget, only for what reaches the network.
- Configure it with a frozen option value on
TransportPolicyorHttpOptions, validated in__post_init__. - Keep its per-call state in a
Sloton theCallContext, never in a request extension of its own, and its waits on the injectedTiming. - Emit a typed event for what an operator would want to see, with sanitized data only, after checking
context.observed. - Add one statement to
build_transport_stack, in its place. - Cover every branch, add its invariants to the property tests where it touches retries, and update the snapshots it
changes deliberately (
tests/package/test_api_surface.pypins the public API;tests/observepins event sequences and spans).
Verification¶
The suite runs at 100% line and branch coverage with no suppression comments. Beside behavior tests per area:
- property tests (Hypothesis) drive the retry layer over random
FaultyTransportschedules, checking that a write without an idempotency key is never re-sent after a byte was written, that no attempt or wait outlasts the deadline, that retries stay within the budget, that every response not returned is closed, and that the error's history matches what was sent; a state machine drives the breaker through concurrent calls and clock jumps; - integration tests exercise the seams between areas: the breaker gate in the retry loop, one deadline across every layer, pagination and streams under the credential and SSRF guards, the cache over the real auths;
- snapshots (syrupy) pin the public API's signatures, error messages, event sequences and OpenTelemetry spans;
- benchmarks (pytest-codspeed) and deterministic memory tests guard the hot paths, and mutation testing (mutmut) checks that the tests notice a changed line (see mutation testing for how to run it, the latest score and the equivalent mutants).