Initial scaffold: .gitignore, config, requirements, data seeds, directory structure
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user