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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.