93 lines
3.1 KiB
Python
93 lines
3.1 KiB
Python
"""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")
|