| """Round-robin and failover API-key pool for provider credential rotation.""" |
|
|
| import asyncio |
| from collections.abc import Sequence |
|
|
| from free_claude_code.providers.failure_policy import ( |
| is_transient_overload_error, |
| retryable_transient_status, |
| ) |
|
|
| _TRANSIENT_ROTATION_STATUSES = frozenset({429, 500, 502, 503, 504}) |
|
|
|
|
| class ApiKeyPool: |
| """Coordinate rotation across one provider's configured credential keys. |
| |
| ``acquire`` returns the current key index, rotating only after |
| ``per_key_quota`` consecutive acquires on the same key so a balanced burst |
| of requests fans out evenly across the pool. ``mark_failure`` jumps |
| immediately to the next key when an upstream error is a transient HTTP |
| 429/5xx or a reported overload, bypassing the per-key budget for that one |
| credential. ``advance`` moves the cursor unconditionally and is preserved |
| for callers that want strict one-rotate-per-call semantics. |
| """ |
|
|
| __slots__ = ("_calls", "_index", "_keys", "_length", "_lock", "_quota") |
|
|
| def __init__(self, keys: Sequence[str], *, per_key_quota: int = 3) -> None: |
| if per_key_quota < 1: |
| raise ValueError("per_key_quota must be >= 1") |
| self._keys = tuple(keys) |
| if not self._keys: |
| raise ValueError("ApiKeyPool requires at least one key") |
| self._length = len(self._keys) |
| self._quota = per_key_quota |
| self._index = 0 |
| self._calls = 0 |
| self._lock = asyncio.Lock() |
|
|
| @property |
| def keys(self) -> tuple[str, ...]: |
| return self._keys |
|
|
| @property |
| def size(self) -> int: |
| return self._length |
|
|
| @property |
| def per_key_quota(self) -> int: |
| return self._quota |
|
|
| async def current(self) -> int: |
| """Return the current key index without advancing.""" |
| async with self._lock: |
| return self._index |
|
|
| async def acquire(self) -> int: |
| """Return the current key, rotating after ``per_key_quota`` acquires. |
| |
| The first ``per_key_quota - 1`` acquires on a key return its index |
| unchanged; the ``per_key_quota``-th advance rotates to the next key |
| and resets the count for the new key. Concurrent acquires are |
| serialized through the event-loop lock so distinct callers never |
| observe a stale index or skip a rotation. |
| """ |
| async with self._lock: |
| self._calls += 1 |
| if self._calls >= self._quota: |
| self._index = (self._index + 1) % self._length |
| self._calls = 0 |
| return self._index |
|
|
| async def advance(self) -> int: |
| """Advance to the next key (round-robin) and return its new index. |
| |
| Legacy unconditional-rotate contract. Resets the per-key acquire |
| counter so a subsequent ``acquire`` chain starts fresh on the new key. |
| """ |
| async with self._lock: |
| self._index = (self._index + 1) % self._length |
| self._calls = 0 |
| return self._index |
|
|
| async def mark_failure(self, error: BaseException) -> tuple[int, int] | None: |
| """Rotate to the next key when ``error`` warrants credential failover. |
| |
| Returns ``(rotated_from_index, rotated_to_index)`` when the error is a |
| transient HTTP 429/5xx or a reported upstream overload; otherwise |
| returns ``None`` and leaves the pool unchanged. The rotation resets |
| the per-key acquire counter so the new key begins a fresh quota |
| window. |
| """ |
| if not self._should_rotate(error): |
| return None |
| async with self._lock: |
| previous = self._index |
| self._index = (self._index + 1) % self._length |
| self._calls = 0 |
| return previous, self._index |
|
|
| @staticmethod |
| def _should_rotate(error: BaseException) -> bool: |
| status = retryable_transient_status(error) |
| if status is not None and status in _TRANSIENT_ROTATION_STATUSES: |
| return True |
| return is_transient_overload_error(error) |
|
|