Skip to content

httpx

The interlock-cb[httpx] extra wraps an httpx transport so a circuit breaker is applied per host transparently. It supports httpx 0.27.0 and newer.

uv add 'interlock-cb[httpx]'
pip install 'interlock-cb[httpx]'
poetry add 'interlock-cb[httpx]'

Synchronous client

import httpx
from interlock.integrations.httpx import CircuitBreakerTransport

transport = CircuitBreakerTransport(httpx.HTTPTransport())
client = httpx.Client(transport=transport)

response = client.get('https://api.example.com/v1/users')

Asynchronous client

import httpx
from interlock.integrations.httpx import AsyncCircuitBreakerTransport

transport = AsyncCircuitBreakerTransport(httpx.AsyncHTTPTransport())
client = httpx.AsyncClient(transport=transport)

response = await client.get('https://api.example.com/v1/users')

Use the client as a context manager. Closing it delegates close() or aclose() to the wrapped transport and releases both the connection pool and every breaker created by the transport.

Safe production rollout

Start in shadow mode when introducing the integration to existing traffic:

import httpx

from interlock import LoggingEventListener, State
from interlock.integrations.httpx import AsyncCircuitBreakerTransport

transport = AsyncCircuitBreakerTransport(
    httpx.AsyncHTTPTransport(),
    initial_state=State.METRICS_ONLY,
    listener=LoggingEventListener(),
)

Every host created later starts in METRICS_ONLY before its first request: it records outcomes but never raises CircuitOpenError. LoggingEventListener writes every event through stdlib logging; swap it for an EventListener that exports to your metrics backend. For local diagnosis, transport.registry.get_existing(host) returns an existing breaker without creating one; inspect its state and snapshot(). Hosts are only known at runtime, so transport.registry.items() lists the breakers created so far and names() just the names they were created under. Both are point-in-time copies: a breaker created afterwards is not in them.

After tuning thresholds, deploy a new transport with the default initial_state=State.CLOSED. Do not reset only the currently known hosts: a transport configured for shadow mode would still create future hosts in METRICS_ONLY. See the complete safe-rollout guide.

Per-host isolation

Each host gets its own lazily created breaker. A failing api.a.example.com trips only that host; traffic to api.b.example.com continues normally. An open breaker raises CircuitOpenTransportError before the wrapped transport performs I/O. A request URL without a host raises ValueError for the same reason: there is no dependency identity to key on.

What a rejection looks like

An open circuit rejects the request with CircuitOpenTransportError, which is both an httpx.TransportError and interlock's CircuitOpenError:

import httpx

from interlock.integrations.httpx import CircuitOpenTransportError

try:
    response = client.get('https://api.example.com/v1/users')
except httpx.TransportError as exc:
    # The dependency being unreachable and the breaker rejecting both land here.
    if isinstance(exc, CircuitOpenTransportError):
        ...  # rejected before any I/O; the next probe is exc.retry_after away
    raise

That is the point of the type: the degradation paths an application already writes in httpx's own idiom keep working the day a breaker leaves shadow mode. The rejection is still a CircuitOpenError too, so except CircuitOpenError, a FallbackStrategy(on=(CircuitOpenError,)) or a framework exception handler registered for it catch exactly what they caught before. It carries breaker_name, retry_after and last_failure, plus httpx's own .request.

The base type is deliberately TransportError and never a leaf such as ConnectError or TimeoutException: nothing was connected and nothing timed out, and those leaves are exactly what retry predicates key on — a retried rejection burns an attempt against a circuit that is still open.

Two further types cover the other interlock errors that can reach the caller through the transport, raised when a layer of your own inside the wrapped transport (a pipeline timeout, a bulkhead) fails the request:

interlock error dialect type httpx base
CircuitOpenError CircuitOpenTransportError httpx.TransportError
CallTimeoutError CallTimeoutTransportError httpx.TimeoutException
BulkheadFullError BulkheadFullTransportError httpx.PoolTimeout

Those two describe transient local conditions — a deadline, a busy slot pool — so unlike a rejection they sit under httpx's timeout types on purpose, where retry predicates do fire on them. An error raised by the wrapped transport itself is never retyped, and neither is one that already carries an httpx hierarchy.

Custom breaker keys

Pass name_resolver when the request host is transport plumbing rather than the logical dependency identity. The callback receives the native httpx.Request and returns the breaker name:

import httpx

from interlock.integrations.httpx import AsyncCircuitBreakerTransport

transport = AsyncCircuitBreakerTransport(
    httpx.AsyncHTTPTransport(),
    name_resolver=lambda request: request.url.host.removesuffix('.query.consul'),
)

The same callback can split one gateway host into independent breakers, for example by returning a name derived from the first path segment. It must return a non-empty string containing something other than whitespace; invalid results raise ValueError with the request URL before the wrapped transport performs I/O.

The resolved name is the registry key and the name carried by CircuitOpenError and every listener event. Use the resolver, rather than rewriting labels in a listener, so breaker state and observability labels stay aligned. Both synchronous and asynchronous transports accept the option.

Share one registry across clients

Inject one caller-owned Registry when several clients should observe the same dependency health. The transports then resolve the same name to the same breaker and contribute to one sliding window:

import httpx

from interlock import Config, Registry
from interlock.integrations.httpx import AsyncCircuitBreakerTransport, HttpStatusClassifier

registry = Registry(
    config=Config(failure_rate_threshold=0.25, minimum_number_of_calls=50),
    classifier=HttpStatusClassifier(),
)

client_a = httpx.AsyncClient(
    transport=AsyncCircuitBreakerTransport(httpx.AsyncHTTPTransport(), registry=registry)
)
client_b = httpx.AsyncClient(
    transport=AsyncCircuitBreakerTransport(httpx.AsyncHTTPTransport(), registry=registry)
)

The classifier is intentional: a bare Registry classifies raised exceptions but treats returned responses, including 503, as successes. Configure HttpStatusClassifier to retain the transport's default status policy. A supplied registry also owns config, clock, initial_state, classifier, and listener; combining registry with any of those transport options raises ValueError instead of silently ignoring one source of configuration.

Share such a registry with HTTP clients only. HttpStatusClassifier reads .status_code off every result it records, so a breaker taken from the same registry for non-HTTP work — registry.get('db') — raises AttributeError the first time that call returns. Keep a separate registry for those.

Closing a client automatically closes its breakers only when the transport owns the registry. An injected registry remains open while the wrapped connection pool closes; the application must explicitly call await registry.aclose_all() during async shutdown, or registry.close_all() when every guarded client is synchronous.

Reach the wrapped transport

transport.wrapped returns the transport being guarded, so a composed object can be unwrapped without touching private attributes — verifying the pool limits, TLS context or proxy the inner transport was built with, inspecting it in a REPL, or walking a chain of wrappers:

import httpx

from interlock.integrations.httpx import AsyncCircuitBreakerTransport

inner = httpx.AsyncHTTPTransport(limits=httpx.Limits(max_connections=20))
transport = AsyncCircuitBreakerTransport(inner)

assert transport.wrapped is inner

The property is read-only: the wrapped transport is fixed at construction. Both the synchronous and asynchronous classes expose it.

What counts as a failure

The default HttpStatusClassifier counts these as failures:

  • transport exceptions raised before a response is returned;
  • response statuses 429, 500, 502, 503, 504.

Other responses, including caller errors such as 404, count as successes. So are the transport exceptions httpx raises for the caller's own bug — UnsupportedProtocol (a scheme-less or unsupported URL) and LocalProtocolError (the local side violating HTTP). They are deterministic and say nothing about the dependency, so a burst of them must not open the circuit of a healthy host. They still propagate to the caller unchanged.

PoolTimeout is not excluded: an exhausted pool is usually the dependency holding connections open, and shedding load then is the point. Exclude it explicitly when your pool is sized below your own burst:

import httpx
from interlock.integrations.httpx import HttpStatusClassifier

classifier = HttpStatusClassifier(
    excluded_exceptions=(httpx.LocalProtocolError, httpx.UnsupportedProtocol, httpx.PoolTimeout),
)

excluded_exceptions replaces the default set — pass () to count every exception as a failure. An excluded exception is recorded as a success: the sliding window has no third outcome.

Pass HttpStatusClassifier(failure_statuses={...}) or another FailureClassifier to change the status side of the policy.

Streaming responses

The wrapper returns the original httpx.Response unchanged, so sync and async streaming remain lazy and connection cleanup keeps httpx's normal semantics. Context entry and exit are delegated to the wrapped transport, including for custom transports that acquire resources in __enter__ or __aenter__. Because the circuit-breaker call completes when response headers arrive, an exception raised later while consuming a streaming body is outside that call and is not recorded by the breaker.

Tuning

config, clock, initial_state, classifier, and listener are shared by every breaker created by the transport:

import httpx

from interlock import Config, LoggingEventListener
from interlock.integrations.httpx import CircuitBreakerTransport

transport = CircuitBreakerTransport(
    httpx.HTTPTransport(),
    config=Config(failure_rate_threshold=0.25, minimum_number_of_calls=50),
    listener=LoggingEventListener(),
)

The transport never retries. If another layer owns retries, keep them bounded and stop retrying when the breaker rejects: CircuitOpenTransportError sits outside the httpx leaf types retry predicates key on, so a predicate written against ConnectError or TimeoutException already leaves it alone.