"""Rate limiter for coordinating parallel API calls. Provides thread-safe rate limiting to prevent overwhelming the Claude API when multiple book workers are running in parallel. """ from __future__ import annotations import threading import time from contextlib import contextmanager from typing import Generator class SharedRateLimiter: """Thread-safe rate limiter using a bounded semaphore. Coordinates API access across multiple worker threads to prevent rate limiting errors. Uses a BoundedSemaphore to limit concurrent requests and adds a delay between releases. Example: limiter = SharedRateLimiter(max_concurrent=5, delay=1.2) # In worker thread: with limiter: call_claude_api() Attributes: max_concurrent: Maximum concurrent API calls allowed. delay: Delay in seconds between API calls. """ def __init__( self, max_concurrent: int = 5, delay: float = 1.2, ) -> None: """Initialize the rate limiter. Args: max_concurrent: Maximum concurrent API calls (default: 5). delay: Minimum delay between calls in seconds (default: 1.2). """ self.max_concurrent = max_concurrent self.delay = delay # Create semaphore for limiting concurrent calls self._semaphore = threading.BoundedSemaphore(max_concurrent) # Create lock for coordinating delays self._delay_lock = threading.Lock() # Track last call time self._last_call_time = 0.0 def acquire(self) -> None: """Acquire a rate limit slot. Blocks until a slot is available and the minimum delay has passed since the last call. """ # First, acquire semaphore slot self._semaphore.acquire() # Then, ensure minimum delay between calls with self._delay_lock: now = time.time() elapsed = now - self._last_call_time if elapsed < self.delay: time.sleep(self.delay - elapsed) self._last_call_time = time.time() def release(self) -> None: """Release a rate limit slot.""" self._semaphore.release() @contextmanager def __call__(self) -> Generator[None, None, None]: """Context manager for rate limiting. Yields: None, but blocks until slot is available. """ self.acquire() try: yield finally: self.release() def __enter__(self) -> SharedRateLimiter: """Enter context manager.""" self.acquire() return self def __exit__(self, *args: object) -> None: """Exit context manager.""" self.release() def shutdown(self) -> None: """Clean up resources (no-op for thread-based limiter).""" pass class DummyRateLimiter: """No-op rate limiter for single-process usage or testing. Provides the same interface as SharedRateLimiter but does nothing. Useful for testing or when rate limiting is not needed. """ def __init__( self, max_concurrent: int = 5, delay: float = 1.2, ) -> None: """Initialize the dummy rate limiter. Args: max_concurrent: Ignored (for API compatibility). delay: Delay in seconds between calls (still applied). """ self.max_concurrent = max_concurrent self.delay = delay self._last_call_time = 0.0 def acquire(self) -> None: """Acquire rate limit slot (applies delay only).""" now = time.time() elapsed = now - self._last_call_time if elapsed < self.delay: time.sleep(self.delay - elapsed) self._last_call_time = time.time() def release(self) -> None: """Release rate limit slot (no-op).""" pass @contextmanager def __call__(self) -> Generator[None, None, None]: """Context manager for rate limiting.""" self.acquire() try: yield finally: self.release() def __enter__(self) -> DummyRateLimiter: """Enter context manager.""" self.acquire() return self def __exit__(self, *args: object) -> None: """Exit context manager.""" self.release() def shutdown(self) -> None: """Clean up resources (no-op).""" pass