SERPent / circuit_breaker.py
Claude
Add a circuit breaker to the multi-backend search fallback (item from review)
ba2278a unverified
Raw History Blame Contribute Delete
2.63 kB
"""A minimal per-key circuit breaker for outbound scraping backends.
When a backend (e.g. one search engine) is rate-limiting or blocking us,
retrying it on every single query just makes that worse - it's the
opposite of what you want when the goal is to stop looking suspicious to
that backend. This tracks consecutive failures per key and, once a
threshold is hit, short-circuits further calls to that key for a cooldown
period instead of attempting (and paying the timeout cost of) a call that
is very likely to fail anyway. After the cooldown it lets exactly one
trial call through (half-open); success closes the circuit again, failure
re-opens it for another full cooldown.
Not thread-safe (no locking) - fine given every caller in this codebase
only ever runs on a single asyncio event loop.
"""
import time
from dataclasses import dataclass
class CircuitOpenError(Exception):
"""Raised by `before_call` instead of letting a call through while a
key's circuit is open."""
def __init__(self, key: str, retry_after: float):
self.key = key
self.retry_after = retry_after
super().__init__(f"Circuit open for '{key}'; retry after {retry_after:.0f}s")
@dataclass
class _BreakerState:
failure_count: int = 0
opened_at: float = 0.0
class CircuitBreaker:
def __init__(self, failure_threshold: int = 3, cooldown_seconds: float = 60.0):
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self._states: dict[str, _BreakerState] = {}
def _state(self, key: str) -> _BreakerState:
return self._states.setdefault(key, _BreakerState())
def before_call(self, key: str) -> None:
"""Raise CircuitOpenError if `key` is currently open and its
cooldown hasn't elapsed. Call this before attempting the call.
"""
state = self._state(key)
if state.failure_count < self.failure_threshold:
return
remaining = self.cooldown_seconds - (time.monotonic() - state.opened_at)
if remaining > 0:
raise CircuitOpenError(key, remaining)
# Cooldown elapsed: half-open - let this one call through. A
# renewed failure (record_failure) re-opens it for another cooldown;
# a success (record_success) clears it entirely.
def record_success(self, key: str) -> None:
self._states.pop(key, None)
def record_failure(self, key: str) -> None:
state = self._state(key)
state.failure_count += 1
if state.failure_count >= self.failure_threshold:
state.opened_at = time.monotonic()