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.
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 CircuitBreaker — config,
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.