open-keypool 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- open_keypool/__init__.py +57 -0
- open_keypool/core.py +845 -0
- open_keypool/py.typed +0 -0
- open_keypool-0.2.0.dist-info/METADATA +172 -0
- open_keypool-0.2.0.dist-info/RECORD +7 -0
- open_keypool-0.2.0.dist-info/WHEEL +4 -0
- open_keypool-0.2.0.dist-info/licenses/LICENSE +21 -0
open_keypool/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""open_keypool — a minimal Python library for pooling and rotating API keys.
|
|
2
|
+
|
|
3
|
+
Avoids HTTP 429 rate-limit errors by cycling through a pool of keys with
|
|
4
|
+
cooldown and disablement support. Provide a list of keys (or pull them from
|
|
5
|
+
Doppler), choose a rotation strategy (round-robin or least-recently-used),
|
|
6
|
+
and the pool handles cooldown on rate-limit responses and permanent
|
|
7
|
+
disablement on invalid keys — all thread-safe.
|
|
8
|
+
|
|
9
|
+
Install
|
|
10
|
+
-------
|
|
11
|
+
.. code-block:: bash
|
|
12
|
+
|
|
13
|
+
pip install open-keypool
|
|
14
|
+
|
|
15
|
+
Quickstart — Local keys array
|
|
16
|
+
-----------------------------
|
|
17
|
+
.. code-block:: python
|
|
18
|
+
|
|
19
|
+
from open_keypool import KeyPool, AllKeysExhaustedError
|
|
20
|
+
|
|
21
|
+
pool = KeyPool(keys=["sk-key1", "sk-key2", "sk-key3"], strategy="round_robin")
|
|
22
|
+
|
|
23
|
+
for attempt in range(pool.max_retries):
|
|
24
|
+
key = pool.get_key()
|
|
25
|
+
response = call_your_api(key)
|
|
26
|
+
if response.status_code == 429:
|
|
27
|
+
retry_after = float(response.headers.get("Retry-After", 0))
|
|
28
|
+
pool.mark_rate_limited(key, retry_after=retry_after or None)
|
|
29
|
+
elif response.status_code in (401, 403):
|
|
30
|
+
pool.mark_invalid(key)
|
|
31
|
+
else:
|
|
32
|
+
pool.mark_success(key)
|
|
33
|
+
break
|
|
34
|
+
|
|
35
|
+
Quickstart — Doppler
|
|
36
|
+
--------------------
|
|
37
|
+
.. code-block:: python
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
from open_keypool import KeyPool
|
|
41
|
+
|
|
42
|
+
DOPPLER_TOKEN = os.getenv("DOPPLER_TOKEN", "dp.st.YOUR_SERVICE_TOKEN")
|
|
43
|
+
PROJECT_NAME = "refactor-ai"
|
|
44
|
+
CONFIG_NAME = "dev"
|
|
45
|
+
|
|
46
|
+
pool = KeyPool.from_doppler(
|
|
47
|
+
token=DOPPLER_TOKEN,
|
|
48
|
+
project=PROJECT_NAME,
|
|
49
|
+
config=CONFIG_NAME,
|
|
50
|
+
key_prefix="MY_APP_",
|
|
51
|
+
strategy="lru",
|
|
52
|
+
)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from open_keypool.core import AllKeysExhaustedError, KeyPool, KeyState
|
|
56
|
+
|
|
57
|
+
__all__ = ["KeyPool", "AllKeysExhaustedError", "KeyState"]
|
open_keypool/core.py
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
"""Core implementation of the open_keypool library.
|
|
2
|
+
|
|
3
|
+
Provides the `KeyPool` class for managing a pool of API keys with automatic
|
|
4
|
+
cooldown, disablement, and rotation strategies to avoid HTTP 429 rate-limit
|
|
5
|
+
errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
import cachetools
|
|
16
|
+
import httpx
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KeyState(Enum):
|
|
21
|
+
"""Possible states for a key in the pool.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
ACTIVE: The key is healthy and available for use.
|
|
25
|
+
COOLDOWN: The key is temporarily unavailable (rate-limited) and will
|
|
26
|
+
automatically recover after *cooldown_seconds* elapses.
|
|
27
|
+
DISABLED: The key is permanently unusable and will never auto-recover.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
ACTIVE = "active"
|
|
31
|
+
COOLDOWN = "cooldown"
|
|
32
|
+
DISABLED = "disabled"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class _KeyRecord:
|
|
37
|
+
"""Internal representation of a single API key and its health metadata."""
|
|
38
|
+
|
|
39
|
+
key: str
|
|
40
|
+
state: KeyState = KeyState.ACTIVE
|
|
41
|
+
cooldown_until: float | None = None
|
|
42
|
+
last_used: float = field(default_factory=time.monotonic)
|
|
43
|
+
failure_count: int = 0
|
|
44
|
+
last_status_code: int | None = None
|
|
45
|
+
last_error_code: str | None = None
|
|
46
|
+
last_error_message: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AllKeysExhaustedError(Exception):
|
|
50
|
+
"""Raised when no ACTIVE key is available in the pool.
|
|
51
|
+
|
|
52
|
+
The exception message includes the soonest recovery time in seconds if any
|
|
53
|
+
key is in COOLDOWN, otherwise it reports that all keys are disabled.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def mask(key: str) -> str:
|
|
60
|
+
"""Return a masked representation of an API key.
|
|
61
|
+
|
|
62
|
+
Produces a string of the form ``"sk-1...b2c9"`` — the first few characters
|
|
63
|
+
followed by ``...`` and the last 4 characters. Never use the raw key value
|
|
64
|
+
in logs, print statements, or exception messages.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
key : str
|
|
69
|
+
The raw API key to mask.
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
str
|
|
74
|
+
Masked key string, e.g. ``"sk-1a2b3...b2c9"``.
|
|
75
|
+
|
|
76
|
+
Examples
|
|
77
|
+
--------
|
|
78
|
+
>>> mask("sk-1a2b3c4d5e6f7g8h9i0j")
|
|
79
|
+
'sk-1a2b3...9i0j'
|
|
80
|
+
"""
|
|
81
|
+
if len(key) <= 7:
|
|
82
|
+
return key[:3] + "..." + key[-4:] if len(key) >= 4 else "..."
|
|
83
|
+
return key[:6] + "..." + key[-4:]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_DOPPLER_CACHE: cachetools.TTLCache = cachetools.TTLCache(maxsize=64, ttl=3600)
|
|
87
|
+
_DOPPLER_DOWNLOAD_URL = "https://api.doppler.com/v3/configs/config/secrets"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class KeyPool:
|
|
91
|
+
"""A thread-safe pool of API keys with cooldown and rotation strategies.
|
|
92
|
+
|
|
93
|
+
Manages a collection of API keys, cycling through them using a configurable
|
|
94
|
+
strategy (round-robin or least-recently-used). Keys that receive HTTP 429
|
|
95
|
+
responses can be placed on cooldown and will automatically recover after
|
|
96
|
+
the cooldown period. Permanently invalid keys can be explicitly disabled.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
keys : list[str]
|
|
101
|
+
Initial list of API key strings. At least one key is required.
|
|
102
|
+
max_retries : int, optional
|
|
103
|
+
Maximum number of retries the caller should attempt per operation.
|
|
104
|
+
Stored as ``self.max_retries`` for the caller's reference; the library
|
|
105
|
+
does not perform automatic retries. Default is 3.
|
|
106
|
+
cooldown_seconds : int, optional
|
|
107
|
+
Number of seconds a key stays in COOLDOWN after being rate-limited
|
|
108
|
+
before it becomes ACTIVE again. Default is 60.
|
|
109
|
+
strategy : str, optional
|
|
110
|
+
Rotation strategy. ``"round_robin"`` cycles through keys in insertion
|
|
111
|
+
order. ``"lru"`` selects the key with the oldest ``last_used``
|
|
112
|
+
timestamp. Default is ``"round_robin"``.
|
|
113
|
+
|
|
114
|
+
Raises
|
|
115
|
+
------
|
|
116
|
+
ValueError
|
|
117
|
+
If *keys* is empty or ``None``.
|
|
118
|
+
|
|
119
|
+
Examples
|
|
120
|
+
--------
|
|
121
|
+
>>> pool = KeyPool(["key-a", "key-b", "key-c"], strategy="round_robin")
|
|
122
|
+
>>> pool.get_key()
|
|
123
|
+
>>> pool.mark_success(pool.get_key())
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
keys: list[str] | None = None,
|
|
129
|
+
max_retries: int = 3,
|
|
130
|
+
cooldown_seconds: int = 60,
|
|
131
|
+
strategy: str = "round_robin",
|
|
132
|
+
) -> None:
|
|
133
|
+
if not keys:
|
|
134
|
+
raise ValueError("KeyPool requires at least one key (keys must be a non-empty list).")
|
|
135
|
+
|
|
136
|
+
if strategy not in ("round_robin", "lru"):
|
|
137
|
+
raise ValueError(f"Unknown strategy '{strategy}'. Use 'round_robin' or 'lru'.")
|
|
138
|
+
|
|
139
|
+
self.max_retries: int = max_retries
|
|
140
|
+
self.cooldown_seconds: int = cooldown_seconds
|
|
141
|
+
self.strategy: str = strategy
|
|
142
|
+
|
|
143
|
+
self._records: list[_KeyRecord] = [_KeyRecord(key=k) for k in keys]
|
|
144
|
+
self._round_robin_index: int = 0
|
|
145
|
+
self._lock: threading.Lock = threading.Lock()
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Internal helpers
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def _find_record(self, key: str) -> _KeyRecord | None:
|
|
152
|
+
"""Return the ``_KeyRecord`` for *key*, or ``None`` if not found."""
|
|
153
|
+
for rec in self._records:
|
|
154
|
+
if rec.key == key:
|
|
155
|
+
return rec
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def _recover_cooldown_keys(self) -> None:
|
|
159
|
+
"""Flip COOLDOWN keys whose cooldown has expired back to ACTIVE."""
|
|
160
|
+
now = time.monotonic()
|
|
161
|
+
for rec in self._records:
|
|
162
|
+
if rec.state == KeyState.COOLDOWN and rec.cooldown_until is not None and now >= rec.cooldown_until:
|
|
163
|
+
rec.state = KeyState.ACTIVE
|
|
164
|
+
rec.cooldown_until = None
|
|
165
|
+
|
|
166
|
+
def _active_records(self) -> list[_KeyRecord]:
|
|
167
|
+
"""Return all currently-ACTIVE records."""
|
|
168
|
+
return [r for r in self._records if r.state == KeyState.ACTIVE]
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def from_doppler(
|
|
172
|
+
cls,
|
|
173
|
+
token: str,
|
|
174
|
+
project: str,
|
|
175
|
+
config: str,
|
|
176
|
+
key_prefix: str | None = None,
|
|
177
|
+
max_retries: int = 3,
|
|
178
|
+
cooldown_seconds: int = 60,
|
|
179
|
+
strategy: str = "round_robin",
|
|
180
|
+
force_refresh: bool = False,
|
|
181
|
+
) -> KeyPool:
|
|
182
|
+
"""Create a ``KeyPool`` by fetching API keys from Doppler.
|
|
183
|
+
|
|
184
|
+
Calls the Doppler secrets-download REST endpoint and populates the
|
|
185
|
+
pool with secret values that match *key_prefix* (if given). Results
|
|
186
|
+
are cached in an in-memory, module-level ``TTLCache`` (1 hour TTL)
|
|
187
|
+
keyed by ``(project, config, key_prefix)`` so that repeated calls
|
|
188
|
+
within the same process avoid redundant network requests.
|
|
189
|
+
|
|
190
|
+
The cache is purely in-memory and empties naturally on every fresh
|
|
191
|
+
process start — it is never persisted to disk.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
token : str
|
|
196
|
+
Doppler service-token for bearer authentication
|
|
197
|
+
(e.g. ``"dp.st.YOUR_SERVICE_TOKEN"``).
|
|
198
|
+
project : str
|
|
199
|
+
Doppler project name.
|
|
200
|
+
config : str
|
|
201
|
+
Doppler config/environment name (e.g. ``"dev"``, ``"prd"``).
|
|
202
|
+
key_prefix : str | None, optional
|
|
203
|
+
If provided, only secrets whose name starts with this string are
|
|
204
|
+
included as keys. ``None`` (default) includes all secrets.
|
|
205
|
+
max_retries : int, optional
|
|
206
|
+
Passed through to the ``KeyPool`` constructor. Default is 3.
|
|
207
|
+
cooldown_seconds : int, optional
|
|
208
|
+
Passed through to the ``KeyPool`` constructor. Default is 60.
|
|
209
|
+
strategy : str, optional
|
|
210
|
+
Passed through to the ``KeyPool`` constructor.
|
|
211
|
+
Default is ``"round_robin"``.
|
|
212
|
+
force_refresh : bool, optional
|
|
213
|
+
If ``True``, bypass the cache and re-fetch from Doppler even when
|
|
214
|
+
a valid cache entry exists. Default is ``False``.
|
|
215
|
+
|
|
216
|
+
Returns
|
|
217
|
+
-------
|
|
218
|
+
KeyPool
|
|
219
|
+
A new ``KeyPool`` instance populated with the fetched keys.
|
|
220
|
+
|
|
221
|
+
Raises
|
|
222
|
+
------
|
|
223
|
+
RuntimeError
|
|
224
|
+
If the Doppler API call fails (non-2xx status) or returns zero
|
|
225
|
+
keys — the cache is **not** populated on failure.
|
|
226
|
+
|
|
227
|
+
Examples
|
|
228
|
+
--------
|
|
229
|
+
>>> import os
|
|
230
|
+
>>> pool = KeyPool.from_doppler(
|
|
231
|
+
... token=os.getenv("DOPPLER_TOKEN", "dp.st.YOUR_SERVICE_TOKEN"),
|
|
232
|
+
... project="refactor-ai",
|
|
233
|
+
... config="dev",
|
|
234
|
+
... key_prefix="API_KEY_",
|
|
235
|
+
... )
|
|
236
|
+
"""
|
|
237
|
+
cache_key = (project, config, key_prefix)
|
|
238
|
+
|
|
239
|
+
if not force_refresh:
|
|
240
|
+
cached = _DOPPLER_CACHE.get(cache_key)
|
|
241
|
+
if cached is not None:
|
|
242
|
+
return cls(
|
|
243
|
+
keys=list(cached),
|
|
244
|
+
max_retries=max_retries,
|
|
245
|
+
cooldown_seconds=cooldown_seconds,
|
|
246
|
+
strategy=strategy,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
response = httpx.get(
|
|
251
|
+
_DOPPLER_DOWNLOAD_URL,
|
|
252
|
+
params={"project": project, "config": config},
|
|
253
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
254
|
+
)
|
|
255
|
+
response.raise_for_status()
|
|
256
|
+
data = response.json()
|
|
257
|
+
except httpx.HTTPError as exc:
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"Doppler API request failed: {exc.__class__.__name__}"
|
|
260
|
+
) from exc
|
|
261
|
+
|
|
262
|
+
secrets: dict[str, str] = data.get("secrets", {})
|
|
263
|
+
fetched_keys: list[str] = []
|
|
264
|
+
for name, secret_data in secrets.items():
|
|
265
|
+
if isinstance(secret_data, dict):
|
|
266
|
+
raw = secret_data.get("raw") or secret_data.get("computed", "")
|
|
267
|
+
else:
|
|
268
|
+
raw = str(secret_data)
|
|
269
|
+
if key_prefix is None or name.startswith(key_prefix):
|
|
270
|
+
fetched_keys.append(raw)
|
|
271
|
+
|
|
272
|
+
if not fetched_keys:
|
|
273
|
+
raise RuntimeError(
|
|
274
|
+
f"Doppler returned zero keys for project='{project}', "
|
|
275
|
+
f"config='{config}', key_prefix={key_prefix!r}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
_DOPPLER_CACHE[cache_key] = tuple(fetched_keys)
|
|
279
|
+
|
|
280
|
+
return cls(
|
|
281
|
+
keys=fetched_keys,
|
|
282
|
+
max_retries=max_retries,
|
|
283
|
+
cooldown_seconds=cooldown_seconds,
|
|
284
|
+
strategy=strategy,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def from_env(
|
|
289
|
+
cls,
|
|
290
|
+
suffix: str,
|
|
291
|
+
env_file: str | None = None,
|
|
292
|
+
max_retries: int = 3,
|
|
293
|
+
cooldown_seconds: int = 60,
|
|
294
|
+
strategy: str = "round_robin",
|
|
295
|
+
) -> KeyPool:
|
|
296
|
+
"""Create a ``KeyPool`` from environment variables matching a suffix.
|
|
297
|
+
|
|
298
|
+
Calls ``python-dotenv``'s ``load_dotenv()`` to load a ``.env`` file
|
|
299
|
+
(if *env_file* is given or a ``.env`` exists in the current directory),
|
|
300
|
+
then scans **all** environment variables for names ending with
|
|
301
|
+
*suffix*. The matching variable values are used as API keys.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
suffix : str
|
|
306
|
+
Environment-variable name suffix to match (case-sensitive).
|
|
307
|
+
For example, ``"GROQ_KEY"`` matches ``TSN_GROQ_KEY``,
|
|
308
|
+
``BACKUP_GROQ_KEY``, etc.
|
|
309
|
+
env_file : str | None, optional
|
|
310
|
+
Path to a ``.env`` file to load before scanning. ``None``
|
|
311
|
+
(default) lets ``load_dotenv()`` find ``.env`` automatically.
|
|
312
|
+
max_retries : int, optional
|
|
313
|
+
Passed through to the ``KeyPool`` constructor. Default is 3.
|
|
314
|
+
cooldown_seconds : int, optional
|
|
315
|
+
Passed through to the ``KeyPool`` constructor. Default is 60.
|
|
316
|
+
strategy : str, optional
|
|
317
|
+
Passed through to the ``KeyPool`` constructor.
|
|
318
|
+
Default is ``"round_robin"``.
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
KeyPool
|
|
323
|
+
A new ``KeyPool`` instance populated with matching env values.
|
|
324
|
+
|
|
325
|
+
Raises
|
|
326
|
+
------
|
|
327
|
+
ValueError
|
|
328
|
+
If *suffix* is empty or ``None``.
|
|
329
|
+
RuntimeError
|
|
330
|
+
If no environment variables match *suffix*.
|
|
331
|
+
|
|
332
|
+
Examples
|
|
333
|
+
--------
|
|
334
|
+
>>> # .env contains: TSN_GROQ_KEY_1=sk-abc TSN_GROQ_KEY_2=sk-def
|
|
335
|
+
>>> pool = KeyPool.from_env(suffix="GROQ_KEY")
|
|
336
|
+
"""
|
|
337
|
+
if not suffix:
|
|
338
|
+
raise ValueError("suffix must be a non-empty string.")
|
|
339
|
+
|
|
340
|
+
load_dotenv(env_file)
|
|
341
|
+
|
|
342
|
+
import os
|
|
343
|
+
|
|
344
|
+
matched: list[str] = []
|
|
345
|
+
for name, value in os.environ.items():
|
|
346
|
+
if name.endswith(suffix) and value.strip():
|
|
347
|
+
matched.append(value.strip())
|
|
348
|
+
|
|
349
|
+
if not matched:
|
|
350
|
+
raise RuntimeError(
|
|
351
|
+
f"No environment variables ending with '{suffix}' found "
|
|
352
|
+
f"(env_file={env_file!r})."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return cls(
|
|
356
|
+
keys=matched,
|
|
357
|
+
max_retries=max_retries,
|
|
358
|
+
cooldown_seconds=cooldown_seconds,
|
|
359
|
+
strategy=strategy,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
@classmethod
|
|
363
|
+
def from_json(
|
|
364
|
+
cls,
|
|
365
|
+
path: str,
|
|
366
|
+
suffix: str | None = None,
|
|
367
|
+
max_retries: int = 3,
|
|
368
|
+
cooldown_seconds: int = 60,
|
|
369
|
+
strategy: str = "round_robin",
|
|
370
|
+
) -> KeyPool:
|
|
371
|
+
"""Create a ``KeyPool`` from a JSON file.
|
|
372
|
+
|
|
373
|
+
The JSON file must contain a **flat object** whose values are the
|
|
374
|
+
API key strings. Only entries whose *key name* ends with *suffix*
|
|
375
|
+
are included (if *suffix* is ``None``, all entries are used).
|
|
376
|
+
|
|
377
|
+
Expected JSON format::
|
|
378
|
+
|
|
379
|
+
{
|
|
380
|
+
"TSN_GROQ_KEY_1": "sk-abc123",
|
|
381
|
+
"TSN_GROQ_KEY_2": "sk-def456",
|
|
382
|
+
"OTHER_SECRET": "sk-ghi789"
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
Parameters
|
|
386
|
+
----------
|
|
387
|
+
path : str
|
|
388
|
+
Path to the JSON file.
|
|
389
|
+
suffix : str | None, optional
|
|
390
|
+
If provided, only entries whose key name ends with this string
|
|
391
|
+
are included. ``None`` (default) includes all entries.
|
|
392
|
+
max_retries : int, optional
|
|
393
|
+
Passed through to the ``KeyPool`` constructor. Default is 3.
|
|
394
|
+
cooldown_seconds : int, optional
|
|
395
|
+
Passed through to the ``KeyPool`` constructor. Default is 60.
|
|
396
|
+
strategy : str, optional
|
|
397
|
+
Passed through to the ``KeyPool`` constructor.
|
|
398
|
+
Default is ``"round_robin"``.
|
|
399
|
+
|
|
400
|
+
Returns
|
|
401
|
+
-------
|
|
402
|
+
KeyPool
|
|
403
|
+
A new ``KeyPool`` instance populated with matching JSON values.
|
|
404
|
+
|
|
405
|
+
Raises
|
|
406
|
+
------
|
|
407
|
+
FileNotFoundError
|
|
408
|
+
If *path* does not exist.
|
|
409
|
+
ValueError
|
|
410
|
+
If the file is not valid JSON or is not a flat object.
|
|
411
|
+
RuntimeError
|
|
412
|
+
If no entries match *suffix* (or the object is empty).
|
|
413
|
+
|
|
414
|
+
Examples
|
|
415
|
+
--------
|
|
416
|
+
>>> # keys.json: {"GROQ_1": "sk-abc", "GROQ_2": "sk-def", "OTHER": "sk-ghi"}
|
|
417
|
+
>>> pool = KeyPool.from_json("keys.json", suffix="GROQ")
|
|
418
|
+
"""
|
|
419
|
+
import json as _json
|
|
420
|
+
import os
|
|
421
|
+
|
|
422
|
+
if not os.path.isfile(path):
|
|
423
|
+
raise FileNotFoundError(f"JSON file not found: {path}")
|
|
424
|
+
|
|
425
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
426
|
+
try:
|
|
427
|
+
data = _json.load(fh)
|
|
428
|
+
except _json.JSONDecodeError as exc:
|
|
429
|
+
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
|
430
|
+
|
|
431
|
+
if not isinstance(data, dict):
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"Expected a JSON object at top level in {path}, got {type(data).__name__}."
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if not data:
|
|
437
|
+
raise RuntimeError(f"JSON object in {path} is empty.")
|
|
438
|
+
|
|
439
|
+
matched: list[str] = []
|
|
440
|
+
for name, value in data.items():
|
|
441
|
+
if not isinstance(value, str):
|
|
442
|
+
raise ValueError(
|
|
443
|
+
f"All values must be strings in {path}. "
|
|
444
|
+
f"Key '{name}' has type {type(value).__name__}."
|
|
445
|
+
)
|
|
446
|
+
if suffix is None or name.endswith(suffix):
|
|
447
|
+
if value.strip():
|
|
448
|
+
matched.append(value.strip())
|
|
449
|
+
|
|
450
|
+
if not matched:
|
|
451
|
+
raise RuntimeError(
|
|
452
|
+
f"No entries ending with '{suffix}' found in {path}."
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
return cls(
|
|
456
|
+
keys=matched,
|
|
457
|
+
max_retries=max_retries,
|
|
458
|
+
cooldown_seconds=cooldown_seconds,
|
|
459
|
+
strategy=strategy,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
# Public API
|
|
464
|
+
# ------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def add_key(self, key: str) -> None:
|
|
467
|
+
"""Add a new ACTIVE key to the pool at runtime.
|
|
468
|
+
|
|
469
|
+
If the key is already present in the pool, this is a no-op.
|
|
470
|
+
|
|
471
|
+
Parameters
|
|
472
|
+
----------
|
|
473
|
+
key : str
|
|
474
|
+
The API key string to add.
|
|
475
|
+
|
|
476
|
+
Examples
|
|
477
|
+
--------
|
|
478
|
+
>>> pool = KeyPool(["key-a"])
|
|
479
|
+
>>> pool.add_key("key-b")
|
|
480
|
+
"""
|
|
481
|
+
with self._lock:
|
|
482
|
+
if self._find_record(key) is None:
|
|
483
|
+
self._records.append(_KeyRecord(key=key))
|
|
484
|
+
|
|
485
|
+
def remove_key(self, key: str) -> None:
|
|
486
|
+
"""Remove a key from the pool regardless of its current state.
|
|
487
|
+
|
|
488
|
+
If the key is not in the pool, this is a no-op.
|
|
489
|
+
|
|
490
|
+
Parameters
|
|
491
|
+
----------
|
|
492
|
+
key : str
|
|
493
|
+
The API key string to remove.
|
|
494
|
+
|
|
495
|
+
Examples
|
|
496
|
+
--------
|
|
497
|
+
>>> pool = KeyPool(["key-a", "key-b"])
|
|
498
|
+
>>> pool.remove_key("key-b")
|
|
499
|
+
"""
|
|
500
|
+
with self._lock:
|
|
501
|
+
rec = self._find_record(key)
|
|
502
|
+
if rec is not None:
|
|
503
|
+
self._records.remove(rec)
|
|
504
|
+
|
|
505
|
+
def get_key(self) -> str:
|
|
506
|
+
"""Return the next available ACTIVE key according to the pool's strategy.
|
|
507
|
+
|
|
508
|
+
Before selecting, any COOLDOWN key whose cooldown period has expired
|
|
509
|
+
is automatically flipped back to ACTIVE.
|
|
510
|
+
|
|
511
|
+
**Round-robin** (``strategy="round_robin"``): iterates through keys
|
|
512
|
+
in insertion order, maintaining an internal cursor that wraps around.
|
|
513
|
+
|
|
514
|
+
**LRU** (``strategy="lru"``): picks the ACTIVE key with the oldest
|
|
515
|
+
``last_used`` timestamp and updates it to now upon selection.
|
|
516
|
+
|
|
517
|
+
Returns
|
|
518
|
+
-------
|
|
519
|
+
str
|
|
520
|
+
An ACTIVE API key.
|
|
521
|
+
|
|
522
|
+
Raises
|
|
523
|
+
------
|
|
524
|
+
AllKeysExhaustedError
|
|
525
|
+
If no ACTIVE key exists in the pool. The message includes the
|
|
526
|
+
soonest recovery time in seconds when at least one key is in
|
|
527
|
+
COOLDOWN, otherwise it says all keys are disabled.
|
|
528
|
+
|
|
529
|
+
Examples
|
|
530
|
+
--------
|
|
531
|
+
>>> pool = KeyPool(["key-a", "key-b"])
|
|
532
|
+
>>> key = pool.get_key()
|
|
533
|
+
>>> pool.mark_success(key)
|
|
534
|
+
"""
|
|
535
|
+
with self._lock:
|
|
536
|
+
self._recover_cooldown_keys()
|
|
537
|
+
active = self._active_records()
|
|
538
|
+
|
|
539
|
+
if not active:
|
|
540
|
+
cooldown_records = [r for r in self._records if r.state == KeyState.COOLDOWN]
|
|
541
|
+
if cooldown_records:
|
|
542
|
+
now = time.monotonic()
|
|
543
|
+
soonest = min(
|
|
544
|
+
(r.cooldown_until - now for r in cooldown_records if r.cooldown_until is not None),
|
|
545
|
+
default=None,
|
|
546
|
+
)
|
|
547
|
+
if soonest is not None and soonest > 0:
|
|
548
|
+
raise AllKeysExhaustedError(
|
|
549
|
+
f"No active keys available. Recovery in {soonest:.1f}s "
|
|
550
|
+
f"({self._cooldown_summary(cooldown_records, now)})."
|
|
551
|
+
)
|
|
552
|
+
else:
|
|
553
|
+
raise AllKeysExhaustedError(
|
|
554
|
+
"No active keys available. All keys are in cooldown or disabled."
|
|
555
|
+
)
|
|
556
|
+
raise AllKeysExhaustedError("No active keys available. All keys are disabled.")
|
|
557
|
+
|
|
558
|
+
if self.strategy == "round_robin":
|
|
559
|
+
self._round_robin_index %= len(active)
|
|
560
|
+
rec = active[self._round_robin_index]
|
|
561
|
+
self._round_robin_index += 1
|
|
562
|
+
else: # lru
|
|
563
|
+
rec = min(active, key=lambda r: r.last_used)
|
|
564
|
+
rec.last_used = time.monotonic()
|
|
565
|
+
|
|
566
|
+
return rec.key
|
|
567
|
+
|
|
568
|
+
def handle_response(
|
|
569
|
+
self,
|
|
570
|
+
key: str,
|
|
571
|
+
status_code: int,
|
|
572
|
+
headers: dict[str, str] | None = None,
|
|
573
|
+
body: dict | str | None = None,
|
|
574
|
+
) -> KeyState:
|
|
575
|
+
"""Feed an HTTP response to the pool — it decides what to do with the key.
|
|
576
|
+
|
|
577
|
+
Introspects the status code and response body and automatically:
|
|
578
|
+
|
|
579
|
+
- On **2xx**: marks the key as successful (``mark_success``).
|
|
580
|
+
- On **429** or **413**, or when the response body contains
|
|
581
|
+
``error.code == "rate_limit_exceeded"``: places the key on
|
|
582
|
+
COOLDOWN using ``Retry-After`` if present, otherwise the pool's
|
|
583
|
+
``cooldown_seconds``.
|
|
584
|
+
- On **401** or **403**: permanently disables the key
|
|
585
|
+
(``mark_invalid``).
|
|
586
|
+
- On **5xx**: places the key on COOLDOWN (transient server error).
|
|
587
|
+
|
|
588
|
+
All relevant details (status code, error code, error message) are
|
|
589
|
+
stored on the key record and surfaced in ``status()``.
|
|
590
|
+
|
|
591
|
+
Parameters
|
|
592
|
+
----------
|
|
593
|
+
key : str
|
|
594
|
+
The API key that was used for the request.
|
|
595
|
+
status_code : int
|
|
596
|
+
HTTP status code from the response.
|
|
597
|
+
headers : dict[str, str] | None, optional
|
|
598
|
+
Response headers (used to extract ``Retry-After``).
|
|
599
|
+
body : dict | str | None, optional
|
|
600
|
+
Parsed JSON body (``dict``) or raw response text (``str``).
|
|
601
|
+
|
|
602
|
+
Returns
|
|
603
|
+
-------
|
|
604
|
+
KeyState
|
|
605
|
+
The new state of the key after processing.
|
|
606
|
+
|
|
607
|
+
Examples
|
|
608
|
+
--------
|
|
609
|
+
>>> pool = KeyPool(["key-a", "key-b"])
|
|
610
|
+
>>> k = pool.get_key()
|
|
611
|
+
>>> # Successful call:
|
|
612
|
+
>>> pool.handle_response(k, 200, body={"choices": [...]})
|
|
613
|
+
<KeyState.ACTIVE: 'active'>
|
|
614
|
+
>>> # Rate-limit (Groq-style in-body):
|
|
615
|
+
>>> pool.handle_response(k, 200, body={"error": {"code": "rate_limit_exceeded", "message": "TPM limit"}})
|
|
616
|
+
<KeyState.COOLDOWN: 'cooldown'>
|
|
617
|
+
>>> # Re-raise to get a fresh key on cooldown:
|
|
618
|
+
>>> k2 = pool.get_key()
|
|
619
|
+
"""
|
|
620
|
+
headers = headers or {}
|
|
621
|
+
|
|
622
|
+
# ── parse body for error details ──
|
|
623
|
+
error_code = str(status_code)
|
|
624
|
+
error_message = ""
|
|
625
|
+
if isinstance(body, dict):
|
|
626
|
+
err = body.get("error", {})
|
|
627
|
+
if isinstance(err, dict):
|
|
628
|
+
if err.get("code") == "rate_limit_exceeded":
|
|
629
|
+
error_code = "rate_limit_exceeded"
|
|
630
|
+
error_message = err.get("message", "")
|
|
631
|
+
elif isinstance(err, str):
|
|
632
|
+
error_message = err
|
|
633
|
+
elif isinstance(body, str):
|
|
634
|
+
error_message = body[:200]
|
|
635
|
+
|
|
636
|
+
with self._lock:
|
|
637
|
+
rec = self._find_record(key)
|
|
638
|
+
if rec is None:
|
|
639
|
+
return KeyState.DISABLED # key doesn't exist, nothing to do
|
|
640
|
+
|
|
641
|
+
rec.last_status_code = status_code
|
|
642
|
+
|
|
643
|
+
# ── 2xx success ──
|
|
644
|
+
if 200 <= status_code < 300:
|
|
645
|
+
rec.failure_count = 0
|
|
646
|
+
rec.state = KeyState.ACTIVE
|
|
647
|
+
rec.last_error_code = None
|
|
648
|
+
rec.last_error_message = None
|
|
649
|
+
return KeyState.ACTIVE
|
|
650
|
+
|
|
651
|
+
# ── rate-limit (429, 413, or rate_limit_exceeded in body) ──
|
|
652
|
+
if status_code in (429, 413) or error_code == "rate_limit_exceeded":
|
|
653
|
+
ra = headers.get("Retry-After")
|
|
654
|
+
try:
|
|
655
|
+
retry_after = float(ra) if ra else None
|
|
656
|
+
except (ValueError, TypeError):
|
|
657
|
+
retry_after = None
|
|
658
|
+
rec.state = KeyState.COOLDOWN
|
|
659
|
+
rec.cooldown_until = time.monotonic() + (
|
|
660
|
+
retry_after if retry_after is not None else self.cooldown_seconds
|
|
661
|
+
)
|
|
662
|
+
rec.failure_count += 1
|
|
663
|
+
rec.last_error_code = error_code
|
|
664
|
+
rec.last_error_message = error_message
|
|
665
|
+
return KeyState.COOLDOWN
|
|
666
|
+
|
|
667
|
+
# ── auth failure (401, 403) ──
|
|
668
|
+
if status_code in (401, 403):
|
|
669
|
+
rec.state = KeyState.DISABLED
|
|
670
|
+
rec.last_error_code = error_code
|
|
671
|
+
rec.last_error_message = error_message
|
|
672
|
+
return KeyState.DISABLED
|
|
673
|
+
|
|
674
|
+
# ── server error (5xx) — transient, put on cooldown ──
|
|
675
|
+
if 500 <= status_code < 600:
|
|
676
|
+
rec.state = KeyState.COOLDOWN
|
|
677
|
+
rec.cooldown_until = time.monotonic() + self.cooldown_seconds
|
|
678
|
+
rec.failure_count += 1
|
|
679
|
+
rec.last_error_code = error_code
|
|
680
|
+
rec.last_error_message = error_message
|
|
681
|
+
return KeyState.COOLDOWN
|
|
682
|
+
|
|
683
|
+
# ── unknown status — also cooldown ──
|
|
684
|
+
rec.state = KeyState.COOLDOWN
|
|
685
|
+
rec.cooldown_until = time.monotonic() + self.cooldown_seconds
|
|
686
|
+
rec.failure_count += 1
|
|
687
|
+
rec.last_error_code = error_code
|
|
688
|
+
rec.last_error_message = error_message
|
|
689
|
+
return KeyState.COOLDOWN
|
|
690
|
+
|
|
691
|
+
def mark_rate_limited(
|
|
692
|
+
self,
|
|
693
|
+
key: str,
|
|
694
|
+
retry_after: float | None = None,
|
|
695
|
+
error_code: str | None = None,
|
|
696
|
+
error_message: str | None = None,
|
|
697
|
+
) -> None:
|
|
698
|
+
"""Mark a key as rate-limited (COOLDOWN).
|
|
699
|
+
|
|
700
|
+
The key will remain in COOLDOWN for *retry_after* seconds (or the
|
|
701
|
+
pool's ``cooldown_seconds`` if *retry_after* is ``None``). Its
|
|
702
|
+
``failure_count`` is incremented.
|
|
703
|
+
|
|
704
|
+
Parameters
|
|
705
|
+
----------
|
|
706
|
+
key : str
|
|
707
|
+
The API key string.
|
|
708
|
+
retry_after : float | None, optional
|
|
709
|
+
Custom cooldown duration in seconds. If ``None``, defaults to
|
|
710
|
+
``self.cooldown_seconds``.
|
|
711
|
+
error_code : str | None, optional
|
|
712
|
+
Machine-readable code for the last rate-limit error (e.g.
|
|
713
|
+
``"rate_limit_exceeded"``, ``"413"``). Stored and surfaced in
|
|
714
|
+
``status()``.
|
|
715
|
+
error_message : str | None, optional
|
|
716
|
+
Human-readable description of the last rate-limit error.
|
|
717
|
+
Stored and surfaced in ``status()``.
|
|
718
|
+
|
|
719
|
+
Examples
|
|
720
|
+
--------
|
|
721
|
+
>>> pool = KeyPool(["key-a"])
|
|
722
|
+
>>> pool.mark_rate_limited("key-a", retry_after=30)
|
|
723
|
+
>>> pool.mark_rate_limited("key-a", error_code="rate_limit_exceeded",
|
|
724
|
+
... error_message="TPM limit 8000 exceeded")
|
|
725
|
+
"""
|
|
726
|
+
with self._lock:
|
|
727
|
+
rec = self._find_record(key)
|
|
728
|
+
if rec is None:
|
|
729
|
+
return
|
|
730
|
+
rec.state = KeyState.COOLDOWN
|
|
731
|
+
rec.cooldown_until = time.monotonic() + (retry_after if retry_after is not None else self.cooldown_seconds)
|
|
732
|
+
rec.failure_count += 1
|
|
733
|
+
rec.last_error_code = error_code
|
|
734
|
+
rec.last_error_message = error_message
|
|
735
|
+
|
|
736
|
+
def mark_invalid(
|
|
737
|
+
self,
|
|
738
|
+
key: str,
|
|
739
|
+
error_code: str | None = None,
|
|
740
|
+
error_message: str | None = None,
|
|
741
|
+
) -> None:
|
|
742
|
+
"""Permanently disable a key (DISABLED).
|
|
743
|
+
|
|
744
|
+
Disabled keys never auto-recover. Use this when a key returns an
|
|
745
|
+
authentication error (e.g. HTTP 401) rather than a rate-limit error.
|
|
746
|
+
|
|
747
|
+
Parameters
|
|
748
|
+
----------
|
|
749
|
+
key : str
|
|
750
|
+
The API key string.
|
|
751
|
+
error_code : str | None, optional
|
|
752
|
+
Machine-readable error code (e.g. ``"401"``, ``"invalid_api_key"``).
|
|
753
|
+
error_message : str | None, optional
|
|
754
|
+
Human-readable error description.
|
|
755
|
+
|
|
756
|
+
Examples
|
|
757
|
+
--------
|
|
758
|
+
>>> pool = KeyPool(["key-a"])
|
|
759
|
+
>>> pool.mark_invalid("key-a")
|
|
760
|
+
>>> pool.mark_invalid("key-a", error_code="401", error_message="Invalid API key")
|
|
761
|
+
"""
|
|
762
|
+
with self._lock:
|
|
763
|
+
rec = self._find_record(key)
|
|
764
|
+
if rec is None:
|
|
765
|
+
return
|
|
766
|
+
rec.state = KeyState.DISABLED
|
|
767
|
+
rec.last_error_code = error_code
|
|
768
|
+
rec.last_error_message = error_message
|
|
769
|
+
|
|
770
|
+
def mark_success(self, key: str) -> None:
|
|
771
|
+
"""Reset a key's failure count to 0 and keep it ACTIVE.
|
|
772
|
+
|
|
773
|
+
Call this after a successful API response to indicate the key is
|
|
774
|
+
healthy and reset any transient failure tracking.
|
|
775
|
+
|
|
776
|
+
Parameters
|
|
777
|
+
----------
|
|
778
|
+
key : str
|
|
779
|
+
The API key string.
|
|
780
|
+
|
|
781
|
+
Examples
|
|
782
|
+
--------
|
|
783
|
+
>>> pool = KeyPool(["key-a"])
|
|
784
|
+
>>> k = pool.get_key()
|
|
785
|
+
>>> pool.mark_success(k)
|
|
786
|
+
"""
|
|
787
|
+
with self._lock:
|
|
788
|
+
rec = self._find_record(key)
|
|
789
|
+
if rec is None:
|
|
790
|
+
return
|
|
791
|
+
rec.failure_count = 0
|
|
792
|
+
rec.state = KeyState.ACTIVE
|
|
793
|
+
rec.last_status_code = None
|
|
794
|
+
rec.last_error_code = None
|
|
795
|
+
rec.last_error_message = None
|
|
796
|
+
|
|
797
|
+
def status(self) -> dict[str, dict]:
|
|
798
|
+
"""Return a snapshot of every key's state without exposing raw keys.
|
|
799
|
+
|
|
800
|
+
Every key value in the returned dictionary is passed through ``mask()``
|
|
801
|
+
so the caller can safely log or print the result.
|
|
802
|
+
|
|
803
|
+
Returns
|
|
804
|
+
-------
|
|
805
|
+
dict[str, dict]
|
|
806
|
+
A mapping of ``{masked_key: {"state": str, "failure_count": int,
|
|
807
|
+
"cooldown_remaining": float | None, "last_status_code": int | None,
|
|
808
|
+
"last_error_code": str | None, "last_error_message": str | None}}``
|
|
809
|
+
for every key in the pool.
|
|
810
|
+
|
|
811
|
+
Examples
|
|
812
|
+
--------
|
|
813
|
+
>>> pool = KeyPool(["sk-abcdef1234567890"])
|
|
814
|
+
>>> pool.status()
|
|
815
|
+
{'sk-abc...7890': {'state': 'active', 'failure_count': 0, 'cooldown_remaining': None, 'last_status_code': None, 'last_error_code': None, 'last_error_message': None}}
|
|
816
|
+
"""
|
|
817
|
+
with self._lock:
|
|
818
|
+
result: dict[str, dict] = {}
|
|
819
|
+
now = time.monotonic()
|
|
820
|
+
for rec in self._records:
|
|
821
|
+
if rec.state == KeyState.COOLDOWN and rec.cooldown_until is not None:
|
|
822
|
+
remaining = max(0.0, rec.cooldown_until - now)
|
|
823
|
+
else:
|
|
824
|
+
remaining = None
|
|
825
|
+
result[mask(rec.key)] = {
|
|
826
|
+
"state": rec.state.value,
|
|
827
|
+
"failure_count": rec.failure_count,
|
|
828
|
+
"cooldown_remaining": round(remaining, 1) if remaining is not None else None,
|
|
829
|
+
"last_status_code": rec.last_status_code,
|
|
830
|
+
"last_error_code": rec.last_error_code,
|
|
831
|
+
"last_error_message": rec.last_error_message,
|
|
832
|
+
}
|
|
833
|
+
return result
|
|
834
|
+
|
|
835
|
+
# ------------------------------------------------------------------
|
|
836
|
+
# Private helpers
|
|
837
|
+
# ------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
def _cooldown_summary(self, cooldown_records: list[_KeyRecord], now: float) -> str:
|
|
840
|
+
"""Return a brief summary of cooldown keys for error messages."""
|
|
841
|
+
parts: list[str] = []
|
|
842
|
+
for rec in cooldown_records:
|
|
843
|
+
if rec.cooldown_until is not None:
|
|
844
|
+
parts.append(f"{mask(rec.key)} in {max(0, rec.cooldown_until - now):.1f}s")
|
|
845
|
+
return ", ".join(parts) if parts else "unknown"
|
open_keypool/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: open-keypool
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Minimal Python library for pooling and rotating API keys to avoid HTTP 429 rate-limit errors.
|
|
5
|
+
Author: Open KeyPool Contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Open KeyPool Contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Keywords: api-keys,key-pool,key-rotation,rate-limiting
|
|
29
|
+
Classifier: Development Status :: 4 - Beta
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Requires-Python: >=3.9
|
|
39
|
+
Requires-Dist: cachetools>=5.3.0
|
|
40
|
+
Requires-Dist: httpx>=0.27.0
|
|
41
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
42
|
+
Provides-Extra: dev
|
|
43
|
+
Requires-Dist: pdoc>=15.0.0; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
45
|
+
Requires-Dist: respx>=0.22.0; extra == 'dev'
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# open-keypool
|
|
49
|
+
|
|
50
|
+
Minimal Python library for pooling and rotating API keys to avoid HTTP 429 rate-limit errors. Provide a list of keys (or pull them from Doppler), choose a rotation strategy (round-robin or least-recently-used), and the pool handles cooldown on rate-limit responses and permanent disablement on invalid keys — all thread-safe.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# From TestPyPI (until published on PyPI):
|
|
56
|
+
pip install --index-url https://test.pypi.org/simple/ open-keypool
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quickstart
|
|
60
|
+
|
|
61
|
+
### Local keys array
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from open_keypool import KeyPool, AllKeysExhaustedError, KeyState
|
|
65
|
+
|
|
66
|
+
pool = KeyPool(keys=["sk-key1", "sk-key2", "sk-key3"], strategy="round_robin")
|
|
67
|
+
|
|
68
|
+
for attempt in range(pool.max_retries):
|
|
69
|
+
key = pool.get_key()
|
|
70
|
+
response = call_your_api(key)
|
|
71
|
+
|
|
72
|
+
# Feed the response — the pool decides success / cooldown / disable
|
|
73
|
+
new_state = pool.handle_response(
|
|
74
|
+
key, response.status_code,
|
|
75
|
+
headers=dict(response.headers),
|
|
76
|
+
body=response.json(),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if new_state == KeyState.ACTIVE:
|
|
80
|
+
break # success
|
|
81
|
+
elif new_state == KeyState.COOLDOWN:
|
|
82
|
+
continue # key is rate-limited, rotate to next
|
|
83
|
+
elif new_state == KeyState.DISABLED:
|
|
84
|
+
continue # key is invalid, rotate to next
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Handle response auto-dispatching
|
|
88
|
+
|
|
89
|
+
`pool.handle_response(key, status_code, headers, body)` introspects the HTTP response and automatically:
|
|
90
|
+
|
|
91
|
+
| Status | Action |
|
|
92
|
+
|---|---|
|
|
93
|
+
| **2xx** | Marks success — clears errors, resets failure count |
|
|
94
|
+
| **429**, **413**, or `"rate_limit_exceeded"` in body | Marks cooldown, reads `Retry-After` header |
|
|
95
|
+
| **401**, **403** | Permanently disables the key |
|
|
96
|
+
| **5xx** | Places on cooldown (transient) |
|
|
97
|
+
|
|
98
|
+
Returns `KeyState` so you can branch on the result.
|
|
99
|
+
|
|
100
|
+
### Multi-key Doppler pool with status tracking
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import os
|
|
104
|
+
from open_keypool import KeyPool
|
|
105
|
+
|
|
106
|
+
DOPPLER_TOKEN = os.getenv("DOPPLER_TOKEN", "dp.st.YOUR_SERVICE_TOKEN")
|
|
107
|
+
|
|
108
|
+
pool = KeyPool.from_doppler(
|
|
109
|
+
token=DOPPLER_TOKEN,
|
|
110
|
+
project="refactor-ai",
|
|
111
|
+
config="dev",
|
|
112
|
+
key_prefix="GROQ_",
|
|
113
|
+
strategy="round_robin",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Every key's state, error history, and cooldown — safely masked
|
|
117
|
+
for masked_key, info in pool.status().items():
|
|
118
|
+
print(f"{masked_key} state={info['state']} "
|
|
119
|
+
f"http={info.get('last_status_code')} "
|
|
120
|
+
f"err=[{info.get('last_error_code')}] "
|
|
121
|
+
f"failures={info['failure_count']}")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Load keys from `.env` file
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from open_keypool import KeyPool
|
|
128
|
+
|
|
129
|
+
# .env contains:
|
|
130
|
+
# TSN_GROQ_KEY=sk-aaa
|
|
131
|
+
# BACKUP_GROQ_KEY=sk-bbb
|
|
132
|
+
# OTHER_SECRET=sk-ccc
|
|
133
|
+
|
|
134
|
+
pool = KeyPool.from_env(suffix="GROQ_KEY")
|
|
135
|
+
# Picks TSN_GROQ_KEY and BACKUP_GROQ_KEY (ends with "GROQ_KEY")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Load keys from JSON file
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"TSN_GROQ_KEY": "sk-aaa",
|
|
143
|
+
"BACKUP_GROQ_KEY": "sk-bbb",
|
|
144
|
+
"OTHER_SECRET": "sk-ccc"
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from open_keypool import KeyPool
|
|
150
|
+
|
|
151
|
+
pool = KeyPool.from_json("keys.json", suffix="GROQ_KEY")
|
|
152
|
+
# Picks TSN_GROQ_KEY and BACKUP_GROQ_KEY (ends with "GROQ_KEY")
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Constructor parameters
|
|
156
|
+
|
|
157
|
+
| Parameter | Type | Default | Description |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
| `keys` | `list[str]` | *required* | Initial API key strings (non-empty). |
|
|
160
|
+
| `max_retries` | `int` | `3` | Max retry count reference for the caller's loop. |
|
|
161
|
+
| `cooldown_seconds` | `int` | `60` | How long a rate-limited key stays in cooldown. |
|
|
162
|
+
| `strategy` | `str` | `"round_robin"` | Rotation strategy: `"round_robin"` or `"lru"`. |
|
|
163
|
+
|
|
164
|
+
## Doppler caching
|
|
165
|
+
|
|
166
|
+
`KeyPool.from_doppler()` uses an in-memory TTL cache with a 1-hour expiration. On the first call within a process, keys are fetched from Doppler and cached. Subsequent calls within the same hour serve keys from memory without touching the network. After one hour (if the process is still running), the cache entry expires and the next call fetches fresh keys automatically. The cache is never persisted across process restarts — every fresh process starts with an empty cache.
|
|
167
|
+
|
|
168
|
+
Pass `force_refresh=True` to bypass the cache and re-fetch immediately (useful after rotating keys in Doppler when you don't want to wait out the TTL).
|
|
169
|
+
|
|
170
|
+
## Full API reference
|
|
171
|
+
|
|
172
|
+
[docs/index.html](docs/index.html) — self-contained HTML page with quickstart + class/method documentation generated from docstrings.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
open_keypool/__init__.py,sha256=OvAC-__PfSsvGsjHSbRGbFmOj8YEm4CSY_bZClCYN5s,1739
|
|
2
|
+
open_keypool/core.py,sha256=UT7TZ3GuucTXwPxzDIVhrAe0LGp5JDFGfN3DL1X5YBc,30430
|
|
3
|
+
open_keypool/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
open_keypool-0.2.0.dist-info/METADATA,sha256=Wp-fc4YiIaS0X3iiVW4aaoHia-_S5Cen5MXYgwOxbjA,6504
|
|
5
|
+
open_keypool-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
open_keypool-0.2.0.dist-info/licenses/LICENSE,sha256=4AiRILGrgOBZ1vgRMbnMyi8u2OppGtw-Z7AVd-HzVkE,1082
|
|
7
|
+
open_keypool-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Open KeyPool Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|