"""Exponential backoff rate limiter with circuit breaker for Anthropic API.""" import time import random import threading import logging from typing import Optional logger = logging.getLogger(__name__) class CircuitBreakerOpen(Exception): """Raised when circuit breaker is open (too many consecutive failures).""" pass class APIRateLimiter: """Exponential backoff with jitter and circuit breaker for API calls.""" def __init__(self, base_delay: float = 1.0, max_delay: float = 60.0, circuit_threshold: int = 5): """ Args: base_delay: Initial backoff delay in seconds max_delay: Maximum backoff delay in seconds circuit_threshold: Consecutive failures before circuit opens """ self.base_delay = base_delay self.max_delay = max_delay self.circuit_threshold = circuit_threshold self._consecutive_failures = 0 self._current_delay = 0.0 self._lock = threading.Lock() @property def is_circuit_open(self) -> bool: return self._consecutive_failures >= self.circuit_threshold def record_success(self): """Record a successful API call — reset backoff.""" with self._lock: self._consecutive_failures = 0 self._current_delay = 0.0 def record_failure(self, error: Optional[str] = None): """Record a failed API call — increase backoff.""" with self._lock: self._consecutive_failures += 1 logger.warning( "API failure #%d/%d: %s", self._consecutive_failures, self.circuit_threshold, error or "unknown" ) if self.is_circuit_open: logger.error( "Circuit breaker OPEN: %d consecutive failures. " "Halting API calls until manual reset.", self._consecutive_failures ) def wait_if_needed(self): """Wait with exponential backoff if there have been failures. Raises CircuitBreakerOpen.""" with self._lock: if self.is_circuit_open: raise CircuitBreakerOpen( f"Circuit breaker open after {self._consecutive_failures} consecutive failures. " f"Call reset() to retry." ) if self._consecutive_failures == 0: return delay = min( self.base_delay * (2 ** (self._consecutive_failures - 1)), self.max_delay ) # Add jitter (±25%) jitter = delay * random.uniform(-0.25, 0.25) actual_delay = max(0, delay + jitter) logger.info("API backoff: waiting %.1fs (attempt %d)", actual_delay, self._consecutive_failures + 1) # Sleep outside the lock time.sleep(actual_delay) def reset(self): """Manually reset the circuit breaker.""" with self._lock: self._consecutive_failures = 0 self._current_delay = 0.0 logger.info("Circuit breaker reset")