Thread-safe pool of API keys with per-key concurrency slots. Each key has *max_concurrent* slots (default 5). :meth:`acquire` picks the least-loaded available key — multiple callers can share the same key as long as slots remain. Only rate-limited keys (HTTP 429) are taken out of
| 129 | |
| 130 | |
| 131 | class ApiKeyPool: |
| 132 | """Thread-safe pool of API keys with per-key concurrency slots. |
| 133 | |
| 134 | Each key has *max_concurrent* slots (default 5). :meth:`acquire` picks |
| 135 | the least-loaded available key — multiple callers can share the same key |
| 136 | as long as slots remain. Only rate-limited keys (HTTP 429) are taken |
| 137 | out of rotation; the pool only blocks when every non-rate-limited key |
| 138 | is at capacity. |
| 139 | |
| 140 | Usage:: |
| 141 | |
| 142 | pool = ApiKeyPool([ApiKey("sk-a", ...), ApiKey("sk-b", ...)]) |
| 143 | key = pool.acquire() # blocks only if all keys full |
| 144 | try: |
| 145 | llm_call(key) |
| 146 | pool.release(key, success=True) |
| 147 | except RateLimitError: |
| 148 | pool.release(key, success=False) |
| 149 | key = pool.acquire() |
| 150 | """ |
| 151 | |
| 152 | def __init__(self, keys: list[ApiKey]) -> None: |
| 153 | if not keys: |
| 154 | raise ValueError("ApiKeyPool requires at least one key") |
| 155 | self._keys = list(keys) |
| 156 | self._lock = threading.Lock() |
| 157 | self._condition = threading.Condition(self._lock) |
| 158 | self._rate_limits_hit: int = 0 |
| 159 | self._retry_successes: int = 0 |
| 160 | self._total_requests_served: int = 0 |
| 161 | self._peak_active_requests: int = 0 |
| 162 | |
| 163 | # -- Public API ----------------------------------------------------------- |
| 164 | |
| 165 | def acquire(self, timeout: float | None = None) -> ApiKey: |
| 166 | """Acquire a slot on the least-loaded available key. |
| 167 | |
| 168 | Scheduling priority: |
| 169 | |
| 170 | 1. **Recovered keys** — rate-limited keys whose backoff has expired |
| 171 | become available again. |
| 172 | 2. **Least-loaded key** — among available keys, pick the one with |
| 173 | the fewest ``active_requests``. |
| 174 | 3. **Block** — if every non-rate-limited key is at capacity, wait |
| 175 | for a slot to free up or a rate-limited key to recover. |
| 176 | |
| 177 | Parameters |
| 178 | ---------- |
| 179 | timeout : |
| 180 | Maximum seconds to wait. ``None`` means wait indefinitely. |
| 181 | |
| 182 | Returns |
| 183 | ------- |
| 184 | ApiKey |
| 185 | A key with at least one available slot. |
| 186 | |
| 187 | Raises |
| 188 | ------ |
no outgoing calls