Authentication, transports and lifecycle
Authentication belongs to its implementation
HttpClient(auth=...) accepts the exported structural HttpAuth interface. The
authenticator owns credentials, signing rules, expiry, refresh coordination,
token storage and its handshake step budget. HTTP sends messages, supplies
response evidence and releases exchange resources. No PostgreSQL, TOTP or browser
dependencies are imported by this integration.
A basic fixed-token adapter can be an ordinary Python object:
from collections.abc import AsyncGeneratorfrom pydantic import SecretStrfrom common_http import HttpClient, HttpResponseHead, Request
class BearerAuth: def __init__(self, token: str): self._token = SecretStr(token)
async def flow(self, request: Request) -> AsyncGenerator[Request, HttpResponseHead]: headers = request.headers.replace( "Authorization", [f"Bearer {self._token.get_secret_value()}"] ) yield request.model_copy(update={"headers": headers})
def requires_body(self, response: HttpResponseHead) -> bool: return False
http = HttpClient.create(base_url="https://provider.example/", auth=BearerAuth("example-token"))This example intentionally has no refresh algorithm. The shared common-auth implementation can implement the same interface without a different HTTP client.
Multi-step flows and signing
The flow yields a Request and receives its response through asend. Ending the
flow accepts that response. Yielding another request authorizes another exchange.
There is no hardcoded single-401 retry or implicit refresh in HTTP.
requires_body(head) selects responses that must be buffered before the flow
continues. Those arrive as HttpResponse with retained bytes, or empty-response
semantics when no bytes exist. Buffering obeys max_response_bytes, including
when the caller uses streaming. A fresh flow is created per attempt and followed
original-origin redirect; concurrent flows need independent handshake state.
Before authentication, transport.prepare freezes the canonical target, headers
and encoded body bytes, including multipart boundaries. Auth yields final binary
bodies and canonical URLs. This lets body signatures cover bytes actually sent.
Authentication follow-ups stay on the original origin. The adapter owns its step
limit and shared refresh synchronization; buffered operations also have their
configured per-attempt timeout. Refresh/token calls should use a separate
unauthenticated client to avoid recursion and pool contention.
Per-exchange admission
async def admit(request: Request) -> None: await limiter.acquire(account="provider-account", cost=1)
http = HttpClient.create(base_url="https://provider.example/", admit=admit)limiter is supplied by the application or future shared rate package. The hook
runs before every physical exchange, including retries, redirects and auth
follow-ups. A hook may wait or reject by raising. It does not imply a built-in
distributed backend. Separate token-refresh clients need their own admission.
Cookie and redirect policy
Cookie persistence defaults on. Domain, path, secure and expiry rules control
attachment; an explicit Cookie field takes precedence. Set persist_cookies=False
to disable the client’s jar. The jar is in memory and cleared when the client closes.
Redirects default off. With follow_redirects=True, max_redirects bounds the
chain. POST 301/302 and non-HEAD 303 become GET without a body. 307/308 preserve
method/body. Cross-origin redirects forward only Accept, Accept-Language and
User-Agent from carried headers, rebuild destination cookies, and disable the
original authenticator. Automatic HTTPS downgrade and cross-origin body replay
are rejected. Use an explicit new request when a provider requires such a step
and the consumer has decided which credentials/body may be sent.
Stream response bytes
import asynciofrom common_http import HttpClient
async with HttpClient.create(base_url="https://provider.example/") as http: request = http.build_request("/download") async with asyncio.timeout(120): async with http.stream(request) as (head, chunks): if not head.is_success: raise RuntimeError(f"Download failed: HTTP {head.status}") async for chunk in chunks: await sink.write(chunk)The sink is application-owned. Chunks are HTTP content-decoded bytes. Exiting the context early or cancellation closes the response. Streaming does not create a buffered HttpResult, run automatic retries or apply runner batch/pacing settings. Phase timeouts and per-exchange admission still apply. The client does not enforce the buffered response cap on an ordinary stream; the sink controls total size.
Owned and borrowed resources
The default HTTPX transport is owned by HttpClient and closed with it. Injected transports and authenticators are borrowed. Each client/transport belongs to one asyncio loop and closed instances cannot reopen. Close rejects active logical operations, including retry waits and batches; finish or cancel and await those operations before closing.
import httpxfrom common_http import HttpClient, HttpxTransport
async with httpx.AsyncClient() as engine: transport = HttpxTransport(engine=engine) try: async with HttpClient(transport=transport) as http: result = await http.get("https://provider.example/health") finally: await transport.aclose()Configure pool/TLS/proxy/protocol settings on a borrowed transport/engine itself. Explicit creation overrides are rejected when borrowing. HTTPX engine default headers, query, cookies and auth are not merged into prepared messages. A borrowed engine may still store received cookies in its own jar. Custom native transports must avoid hidden retries if the caller expects one execution owner.
BaseTransport defines lifecycle and one managed exchange. Implement its native
open/close/exchange/capability seams for another engine; override prepare when
it serializes structured bodies. It never invokes execution runners or batching.
See the transport reference.