Failure classification¶
What counts as a failure is a separate concern from when to trip (thresholds,
in Config). It is decided by a FailureClassifier.
Default policy¶
By default, a call is a failure exactly when it raises, and any returned value is a success:
from interlock import CircuitBreaker
breaker = CircuitBreaker(name='svc') # DefaultFailureClassifier
This is right for code that signals errors by raising. It is not enough when
failure is encoded in a return value — for example an HTTP response object
whose 503 status means the dependency is unhealthy.
Classify by result¶
A classifier implements one method. The result/exception pair is mutually
exclusive: when exception is not None the call raised; otherwise result
holds the return value. exception is always an Exception — a
BaseException such as CancelledError says nothing about the dependency, so
the breaker releases the call without classifying it.
from interlock import CircuitBreaker
class StatusClassifier:
def is_failure(self, *, result: object, exception: Exception | None) -> bool:
if exception is not None:
return True
return getattr(result, 'status_code', 200) >= 500
breaker = CircuitBreaker(name='api', classifier=StatusClassifier())
result = breaker.call(client.get, url) # a 503 response now counts as a failure
Result-based classification needs the return value, so it works with the
decorator and call, but not the context manager (which only sees
exceptions and duration).
Ignore expected errors¶
Business errors — a 404, a validation failure — should not open the circuit.
Encode that by treating only the exceptions you care about as failures:
class IgnoreNotFound:
def is_failure(self, *, result: object, exception: Exception | None) -> bool:
if isinstance(exception, NotFoundError):
return False # expected, not a dependency problem
return exception is not None
An ignored exception is recorded as a success — the sliding window has only two outcomes — and still propagates to the caller. It therefore dilutes the failure rate rather than being invisible to it.
HTTP out of the box¶
For HTTP clients, you do not need to write this yourself — the
httpx2, httpx,
aiohttp, and
requests integrations ship
HttpStatusClassifier, which treats the canonical retryable statuses
(429, 500, 502, 503, 504) and every non-excluded transport exception as
failures.
Errors the caller caused are excluded for the same reason as a 404: a
scheme-less URL or a local protocol violation is a bug in your code, not
evidence that the dependency is unhealthy, and counting it would open the
circuit of a host that is answering fine. The default set differs per
integration, because each client library raises different things inside the
guarded call:
| Integration | Excluded by default |
|---|---|
| httpx2, httpx | UnsupportedProtocol, LocalProtocolError |
| requests | InvalidURL (and its InvalidProxyURL subclass) |
| aiohttp | nothing — it rejects malformed URLs before the middleware chain runs, so every handler exception counts unless you exclude it |
All four take excluded_exceptions=(...) to replace that set — () counts
every exception as a failure. Entries must be Exception subclasses;
anything else raises TypeError at construction.