Skip to content

requests

The interlock-cb[requests] extra guards every request a Session sends with a circuit breaker per host — mounted once, no decorators in call sites.

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

Usage

CircuitBreakerAdapter subclasses requests.adapters.HTTPAdapter — the library's native transport extension point — so it mounts like any adapter:

import requests

from interlock.integrations.requests import CircuitBreakerAdapter

session = requests.Session()
adapter = CircuitBreakerAdapter()
session.mount('https://', adapter)
session.mount('http://', adapter)

response = session.get('https://api.example.com/orders')

Closing the session closes the adapter's connection pools and every breaker it created.

Each host gets its own breaker (a failing api.a never trips api.b), created lazily and shared across requests. When a host's circuit is open the request raises CircuitOpenRequestError before a connection is made.

What a rejection looks like

An open circuit rejects the request with CircuitOpenRequestError, which is both a requests.exceptions.ConnectionError and interlock's CircuitOpenError:

import requests

from interlock.integrations.requests import CircuitOpenRequestError

try:
    response = session.get('https://api.example.com/v1/users')
except requests.exceptions.RequestException as exc:
    # The dependency being unreachable and the breaker rejecting both land here.
    if isinstance(exc, CircuitOpenRequestError):
        ...  # 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 requests' 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 requests' own .request and .response.

The base type is deliberately ConnectionError and never a leaf such as SSLError or ConnectTimeout: no connection was attempted 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. urllib3's own Retry never sees it either: that runs inside HTTPAdapter.send, which the rejection replaces rather than enters.

Only the rejection is retyped. The httpx transports also pair CallTimeoutError with httpx.TimeoutException and BulkheadFullError with httpx.PoolTimeout; requests has no honest counterpart for "no local slot was free" — the nearest type is requests.exceptions.ConnectionError, which the rejection already uses, so the pairing would claim a distinction it cannot express. A CallTimeoutError raised by a pipeline of your own inside the guarded call therefore stays an interlock error on its way out.

Custom breaker keys

Pass name_resolver when the request host is not the logical dependency identity. The callback receives the native requests.PreparedRequest and returns the breaker name. For example, the first path segment can separate independent upstreams behind one gateway host:

from interlock.integrations.requests import CircuitBreakerAdapter

adapter = CircuitBreakerAdapter(
    name_resolver=lambda request: request.path_url.split('/')[1],
)

Returning one name for several discovery hosts instead makes them share a breaker. The result must be a non-empty string containing something other than whitespace; invalid results raise ValueError with the request URL before the adapter performs I/O.

The resolved name is used consistently as the registry key, in CircuitOpenError, and in every listener event. Resolve the identity here instead of rewriting listener labels so metrics remain aligned with breaker state.

Share one registry across sessions

Inject a caller-owned Registry when independent sessions should use one breaker and one sliding window for the same resolved name:

import requests

from interlock import Config, Registry
from interlock.integrations.requests import CircuitBreakerAdapter, HttpStatusClassifier

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

session_a = requests.Session()
session_a.mount('https://', CircuitBreakerAdapter(registry=registry))

session_b = requests.Session()
session_b.mount('https://', CircuitBreakerAdapter(registry=registry))

The explicit classifier preserves the adapter's normal status policy. Without it, a bare Registry classifies exceptions only and a returned 503 counts as a success. The registry owns config, clock, initial_state, classifier, and listener; combining registry with any of those adapter options raises ValueError.

Share such a registry with HTTP sessions 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 session automatically closes its breakers only when the adapter owns the registry. An injected registry remains open while its connection pools close; the application must explicitly call registry.close_all() during shutdown.

Safe production rollout

Pass initial_state=State.METRICS_ONLY to record real outcomes without rejecting requests. The state is applied to every lazily created host before its first request:

from interlock import LoggingEventListener, State
from interlock.integrations.requests import CircuitBreakerAdapter

adapter = CircuitBreakerAdapter(
    initial_state=State.METRICS_ONLY,
    listener=LoggingEventListener(),
)

The public adapter.registry supports local diagnosis with get_existing(host), state and snapshot(), plus names() and items() for the breakers created so far — point-in-time copies, without the ones created afterwards. LoggingEventListener writes every event through stdlib logging; swap it for an EventListener that exports to your metrics backend, then deploy a new adapter with the default CLOSED state to begin enforcement. See Safe rollout.

Failure policy

By default a response counts as a failure when its status is in the canonical retryable set (429, 500, 502, 503, 504) and a transport exception (connect/read errors) is a failure; 4xx client mistakes like 404 are successes.

InvalidURL — raised by the adapter when the request or proxy URL carries no host, and the parent of InvalidProxyURL — is a success too. It is the caller's own bug: deterministic, and no evidence about the dependency, so a burst of them must not open the circuit of a healthy host. The exception still propagates to the caller. Replace that set with excluded_exceptions=(...), or pass () to count every exception as a failure; an excluded exception is recorded as a success, since the sliding window has no third outcome.

Change the statuses, or the whole policy:

from interlock import Config
from interlock.integrations.requests import CircuitBreakerAdapter, HttpStatusClassifier

adapter = CircuitBreakerAdapter(
    config=Config(failure_rate_threshold=0.3),
    classifier=HttpStatusClassifier(failure_statuses={408, 429, 500, 502, 503, 504}),
)

Any custom FailureClassifier works too — see Failure classification.

Tuning and observability

The adapter accepts the same collaborators as CircuitBreakerconfig, clock, initial_state, classifier, listener — and forwards everything else (pool_connections, max_retries, ...) to HTTPAdapter. Note that max_retries is urllib3's connection-level retry; for application-level retries combine with the tenacity integration and read Retries and circuit breakers first.