keycycle 0.3.2__tar.gz → 0.4.0__tar.gz

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.
Files changed (53) hide show
  1. {keycycle-0.3.2 → keycycle-0.4.0}/PKG-INFO +1 -1
  2. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/adapters/generic_adapter.py +15 -15
  3. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/adapters/openai_adapter.py +15 -15
  4. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/constants.py +7 -0
  5. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/core/__init__.py +2 -0
  6. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/core/utils.py +23 -0
  7. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/key_rotation/rotating_mixin.py +11 -11
  8. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/key_rotation/rotation_manager.py +63 -0
  9. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/PKG-INFO +1 -1
  10. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/SOURCES.txt +2 -0
  11. {keycycle-0.3.2 → keycycle-0.4.0}/pyproject.toml +1 -1
  12. keycycle-0.4.0/tests/test_dead_key_rotation.py +124 -0
  13. keycycle-0.4.0/tests/test_key_wait.py +78 -0
  14. {keycycle-0.3.2 → keycycle-0.4.0}/MANIFEST.in +0 -0
  15. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/__init__.py +0 -0
  16. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/adapters/__init__.py +0 -0
  17. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/__init__.py +0 -0
  18. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/dataclasses.py +0 -0
  19. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/enums.py +0 -0
  20. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/loader.py +0 -0
  21. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/log_config.py +0 -0
  22. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/cerebras.yaml +0 -0
  23. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/cohere.yaml +0 -0
  24. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/gemini.yaml +0 -0
  25. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/groq.yaml +0 -0
  26. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/mistral.yaml +0 -0
  27. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/moonshot.yaml +0 -0
  28. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/openrouter.yaml +0 -0
  29. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/openrouter_models.yaml +0 -0
  30. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models.py +0 -0
  31. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/core/backoff.py +0 -0
  32. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/core/exceptions.py +0 -0
  33. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/key_rotation/__init__.py +0 -0
  34. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/legacy_multi_provider_wrapper.py +0 -0
  35. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/multi_client_wrapper.py +0 -0
  36. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/py.typed +0 -0
  37. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/usage/__init__.py +0 -0
  38. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/usage/db_logic.py +0 -0
  39. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/usage/usage_logger.py +0 -0
  40. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/utils.py +0 -0
  41. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/dependency_links.txt +0 -0
  42. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/requires.txt +0 -0
  43. {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/top_level.txt +0 -0
  44. {keycycle-0.3.2 → keycycle-0.4.0}/setup.cfg +0 -0
  45. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_api_key_pinning.py +0 -0
  46. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_api_key_retrieval.py +0 -0
  47. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_cerebras_direct.py +0 -0
  48. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_custom_key_limits.py +0 -0
  49. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_multi_client_wrapper.py +0 -0
  50. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_payment_required_rotation.py +0 -0
  51. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_rate_limit_logic.py +0 -0
  52. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_specific_key_retrieval.py +0 -0
  53. {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_streams_recording.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keycycle
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A thread-safe key rotation and rate limiting manager for API keys.
5
5
  Author-email: William Jin <jinwilliam.jin@gmail.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -35,7 +35,7 @@ from ..config.constants import (
35
35
  from ..core.utils import (
36
36
  is_rate_limit_error,
37
37
  is_temporary_rate_limit_error,
38
- is_payment_required_error,
38
+ is_dead_key_error,
39
39
  get_key_suffix,
40
40
  )
41
41
  from ..core.backoff import ExponentialBackoff, BackoffConfig
@@ -305,9 +305,9 @@ class SyncGenericRotatingClient(BaseGenericRotatingClient[T]):
305
305
  limits = self.limit_resolver(model_id, None)
306
306
 
307
307
  for attempt in range(self.config.max_retries + 1):
308
- key_usage = self.manager.get_key(model_id, limits, self.config.estimated_tokens)
308
+ key_usage = self.manager.acquire_key(model_id, limits, self.config.estimated_tokens)
309
309
  if not key_usage:
310
- raise RuntimeError(f"No available keys for {model_id}")
310
+ raise RuntimeError(self.manager.unavailable_message(model_id))
311
311
 
312
312
  # Create backoff for temporary rate limits
313
313
  temp_backoff = ExponentialBackoff(BackoffConfig(
@@ -348,11 +348,11 @@ class SyncGenericRotatingClient(BaseGenericRotatingClient[T]):
348
348
  time.sleep(delay)
349
349
  continue # Retry with SAME key
350
350
 
351
- # Payment required (quota/credits exhausted) - key is dead
351
+ # Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
352
352
  # for this process, rotate to next key
353
- if is_payment_required_error(e) and attempt < self.config.max_retries:
353
+ if is_dead_key_error(e) and attempt < self.config.max_retries:
354
354
  logger.warning(
355
- "402/PaymentRequired hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
355
+ "Dead key (401/402/403) hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
356
356
  model_id, get_key_suffix(key_usage.api_key),
357
357
  attempt + 1, self.config.max_retries + 1
358
358
  )
@@ -401,9 +401,9 @@ class SyncGenericRotatingClient(BaseGenericRotatingClient[T]):
401
401
  final_tokens = chunk_tokens
402
402
  yield chunk
403
403
  except Exception as e:
404
- if is_payment_required_error(e):
404
+ if is_dead_key_error(e):
405
405
  logger.warning(
406
- "402/PaymentRequired hit during streaming for %s on key ...%s. Marking key dead.",
406
+ "Dead key (401/402/403) hit during streaming for %s on key ...%s. Marking key dead.",
407
407
  model_id, get_key_suffix(key_usage.api_key)
408
408
  )
409
409
  key_usage.mark_dead()
@@ -446,9 +446,9 @@ class AsyncGenericRotatingClient(BaseGenericRotatingClient[T]):
446
446
  limits = self.limit_resolver(model_id, None)
447
447
 
448
448
  for attempt in range(self.config.max_retries + 1):
449
- key_usage = self.manager.get_key(model_id, limits, self.config.estimated_tokens)
449
+ key_usage = await self.manager.acquire_key_async(model_id, limits, self.config.estimated_tokens)
450
450
  if not key_usage:
451
- raise RuntimeError(f"No available keys for {model_id}")
451
+ raise RuntimeError(self.manager.unavailable_message(model_id))
452
452
 
453
453
  # Create backoff for temporary rate limits
454
454
  temp_backoff = ExponentialBackoff(BackoffConfig(
@@ -487,11 +487,11 @@ class AsyncGenericRotatingClient(BaseGenericRotatingClient[T]):
487
487
  await asyncio.sleep(delay)
488
488
  continue # Retry with SAME key
489
489
 
490
- # Payment required (quota/credits exhausted) - key is dead
490
+ # Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
491
491
  # for this process, rotate to next key
492
- if is_payment_required_error(e) and attempt < self.config.max_retries:
492
+ if is_dead_key_error(e) and attempt < self.config.max_retries:
493
493
  logger.warning(
494
- "402/PaymentRequired hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
494
+ "Dead key (401/402/403) hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
495
495
  model_id, get_key_suffix(key_usage.api_key),
496
496
  attempt + 1, self.config.max_retries + 1
497
497
  )
@@ -540,9 +540,9 @@ class AsyncGenericRotatingClient(BaseGenericRotatingClient[T]):
540
540
  final_tokens = chunk_tokens
541
541
  yield chunk
542
542
  except Exception as e:
543
- if is_payment_required_error(e):
543
+ if is_dead_key_error(e):
544
544
  logger.warning(
545
- "402/PaymentRequired hit during streaming for %s on key ...%s. Marking key dead.",
545
+ "Dead key (401/402/403) hit during streaming for %s on key ...%s. Marking key dead.",
546
546
  model_id, get_key_suffix(key_usage.api_key)
547
547
  )
548
548
  key_usage.mark_dead()
@@ -14,7 +14,7 @@ from ..config.constants import (
14
14
  from ..core.utils import (
15
15
  is_rate_limit_error,
16
16
  is_temporary_rate_limit_error,
17
- is_payment_required_error,
17
+ is_dead_key_error,
18
18
  get_key_suffix,
19
19
  )
20
20
  from ..core.backoff import ExponentialBackoff, BackoffConfig
@@ -132,9 +132,9 @@ class RotatingOpenAIClient(BaseRotatingClient):
132
132
  kwargs['stream_options'] = {"include_usage": True}
133
133
 
134
134
  for attempt in range(self.max_retries + 1):
135
- key_usage = self.manager.get_key(model_id, limits, self.estimated_tokens)
135
+ key_usage = self.manager.acquire_key(model_id, limits, self.estimated_tokens)
136
136
  if not key_usage:
137
- raise RuntimeError(f"No available keys for {model_id}")
137
+ raise RuntimeError(self.manager.unavailable_message(model_id))
138
138
 
139
139
  # Create backoff for temporary rate limits
140
140
  temp_backoff = ExponentialBackoff(BackoffConfig(
@@ -171,11 +171,11 @@ class RotatingOpenAIClient(BaseRotatingClient):
171
171
  time.sleep(delay)
172
172
  continue # Retry with SAME key
173
173
 
174
- # Payment required (quota/credits exhausted) - key is dead
174
+ # Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
175
175
  # for this process, rotate to next key
176
- if is_payment_required_error(e) and attempt < self.max_retries:
176
+ if is_dead_key_error(e) and attempt < self.max_retries:
177
177
  logger.warning(
178
- "402/PaymentRequired hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
178
+ "Dead key (401/402/403) hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
179
179
  model_id, get_key_suffix(key_usage.api_key), attempt + 1, self.max_retries + 1
180
180
  )
181
181
  key_usage.mark_dead()
@@ -217,9 +217,9 @@ class RotatingOpenAIClient(BaseRotatingClient):
217
217
  final_tokens = chunk.usage.total_tokens
218
218
  yield chunk
219
219
  except Exception as e:
220
- if is_payment_required_error(e):
220
+ if is_dead_key_error(e):
221
221
  logger.warning(
222
- "402/PaymentRequired hit during streaming for %s on key ...%s. Marking key dead.",
222
+ "Dead key (401/402/403) hit during streaming for %s on key ...%s. Marking key dead.",
223
223
  model_id, get_key_suffix(key_usage.api_key)
224
224
  )
225
225
  key_usage.mark_dead()
@@ -266,9 +266,9 @@ class RotatingAsyncOpenAIClient(BaseRotatingClient):
266
266
  kwargs['stream_options'] = {"include_usage": True}
267
267
 
268
268
  for attempt in range(self.max_retries + 1):
269
- key_usage = self.manager.get_key(model_id, limits, self.estimated_tokens)
269
+ key_usage = await self.manager.acquire_key_async(model_id, limits, self.estimated_tokens)
270
270
  if not key_usage:
271
- raise RuntimeError(f"No available keys for {model_id}")
271
+ raise RuntimeError(self.manager.unavailable_message(model_id))
272
272
 
273
273
  # Create backoff for temporary rate limits
274
274
  temp_backoff = ExponentialBackoff(BackoffConfig(
@@ -305,11 +305,11 @@ class RotatingAsyncOpenAIClient(BaseRotatingClient):
305
305
  await asyncio.sleep(delay)
306
306
  continue # Retry with SAME key
307
307
 
308
- # Payment required (quota/credits exhausted) - key is dead
308
+ # Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
309
309
  # for this process, rotate to next key
310
- if is_payment_required_error(e) and attempt < self.max_retries:
310
+ if is_dead_key_error(e) and attempt < self.max_retries:
311
311
  logger.warning(
312
- "402/PaymentRequired hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
312
+ "Dead key (401/402/403) hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
313
313
  model_id, get_key_suffix(key_usage.api_key), attempt + 1, self.max_retries + 1
314
314
  )
315
315
  key_usage.mark_dead()
@@ -351,9 +351,9 @@ class RotatingAsyncOpenAIClient(BaseRotatingClient):
351
351
  final_tokens = chunk.usage.total_tokens
352
352
  yield chunk
353
353
  except Exception as e:
354
- if is_payment_required_error(e):
354
+ if is_dead_key_error(e):
355
355
  logger.warning(
356
- "402/PaymentRequired hit during streaming for %s on key ...%s. Marking key dead.",
356
+ "Dead key (401/402/403) hit during streaming for %s on key ...%s. Marking key dead.",
357
357
  model_id, get_key_suffix(key_usage.api_key)
358
358
  )
359
359
  key_usage.mark_dead()
@@ -31,5 +31,12 @@ TEMP_RATE_LIMIT_MULTIPLIER = 2.0
31
31
  # Key rotation delay (seconds to wait after rotating to a new key)
32
32
  KEY_ROTATION_DELAY_SECONDS = 0.5
33
33
 
34
+ # How long acquire_key() polls for a live key to leave its rate window /
35
+ # cooldown before giving up. A single production key at its per-minute cap
36
+ # is the common case (Cohere, 2026-09-07): the right answer is to wait for
37
+ # the window, not to abort the caller's whole run.
38
+ KEY_WAIT_MAX_SECONDS = 120.0
39
+ KEY_WAIT_POLL_SECONDS = 1.0
40
+
34
41
  # Usage logging batch size
35
42
  USAGE_LOG_BATCH_SIZE = 50
@@ -14,6 +14,7 @@ from .utils import (
14
14
  get_key_suffix,
15
15
  is_rate_limit_error,
16
16
  is_auth_error,
17
+ is_dead_key_error,
17
18
  validate_api_key,
18
19
  )
19
20
  from .backoff import ExponentialBackoff, BackoffConfig
@@ -33,6 +34,7 @@ __all__ = [
33
34
  "get_key_suffix",
34
35
  "is_rate_limit_error",
35
36
  "is_auth_error",
37
+ "is_dead_key_error",
36
38
  "validate_api_key",
37
39
  # Backoff
38
40
  "ExponentialBackoff",
@@ -241,6 +241,29 @@ def is_auth_error(e: Exception) -> bool:
241
241
  return False
242
242
 
243
243
 
244
+ # A key that answers 401/402/403 is unusable for good (revoked, unpaid,
245
+ # or its project denied) — no cooldown will fix it, so rotate off it.
246
+ DEAD_KEY_STATUS_CODES = AUTH_STATUS_CODES | PAYMENT_REQUIRED_STATUS_CODES
247
+
248
+
249
+ def is_dead_key_error(e: Exception) -> bool:
250
+ """
251
+ Detect errors that mean THIS KEY is dead, not merely busy: HTTP 401/403
252
+ (invalid, revoked, or project-denied key — e.g. Gemini's
253
+ "Your project has been denied access") and 402 (credits exhausted).
254
+
255
+ A status_code, when present, is authoritative: 429s are never dead keys
256
+ whatever their message says. Without one, fall back to the string
257
+ heuristics of is_payment_required_error / is_auth_error.
258
+ """
259
+ status = getattr(e, "status_code", None)
260
+ if status in DEAD_KEY_STATUS_CODES:
261
+ return True
262
+ if status == 429:
263
+ return False
264
+ return is_payment_required_error(e) or is_auth_error(e)
265
+
266
+
244
267
  def validate_api_key(api_key: str) -> bool:
245
268
  """
246
269
  Basic validation of API key format.
@@ -15,7 +15,7 @@ from ..config.constants import (
15
15
  from ..core.utils import (
16
16
  is_rate_limit_error,
17
17
  is_temporary_rate_limit_error,
18
- is_payment_required_error,
18
+ is_dead_key_error,
19
19
  get_key_suffix,
20
20
  )
21
21
  from ..core.backoff import ExponentialBackoff, BackoffConfig
@@ -171,11 +171,11 @@ class RotatingCredentialsMixin:
171
171
  time.sleep(delay)
172
172
  continue # Retry with SAME key
173
173
 
174
- # Payment required (quota/credits exhausted) - key is dead
174
+ # Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
175
175
  # for this process, rotate to next key
176
- if is_payment_required_error(e) and attempt < limit:
176
+ if is_dead_key_error(e) and attempt < limit:
177
177
  self.logger.warning(
178
- "402 Hit on key %s (Sync) [%s]. Marking key dead and rotating (%d/%d).",
178
+ "Dead key (401/402/403) on key %s (Sync) [%s]. Marking key dead and rotating (%d/%d).",
179
179
  get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
180
180
  )
181
181
  key_usage.mark_dead()
@@ -228,11 +228,11 @@ class RotatingCredentialsMixin:
228
228
  await asyncio.sleep(delay)
229
229
  continue # Retry with SAME key
230
230
 
231
- # Payment required (quota/credits exhausted) - key is dead
231
+ # Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
232
232
  # for this process, rotate to next key
233
- if is_payment_required_error(e) and attempt < limit:
233
+ if is_dead_key_error(e) and attempt < limit:
234
234
  self.logger.warning(
235
- "402 Hit on key %s (Async) [%s]. Marking key dead and rotating (%d/%d).",
235
+ "Dead key (401/402/403) on key %s (Async) [%s]. Marking key dead and rotating (%d/%d).",
236
236
  get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
237
237
  )
238
238
  key_usage.mark_dead()
@@ -300,9 +300,9 @@ class RotatingCredentialsMixin:
300
300
  time.sleep(delay)
301
301
  continue
302
302
 
303
- if is_payment_required_error(e) and attempt < limit:
303
+ if is_dead_key_error(e) and attempt < limit:
304
304
  self.logger.warning(
305
- "402 Hit on key %s (Sync Stream) [%s]. Marking key dead and rotating (%d/%d).",
305
+ "Dead key (401/402/403) on key %s (Sync Stream) [%s]. Marking key dead and rotating (%d/%d).",
306
306
  get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
307
307
  )
308
308
  key_usage.mark_dead()
@@ -366,9 +366,9 @@ class RotatingCredentialsMixin:
366
366
  await asyncio.sleep(delay)
367
367
  continue
368
368
 
369
- if is_payment_required_error(e) and attempt < limit:
369
+ if is_dead_key_error(e) and attempt < limit:
370
370
  self.logger.warning(
371
- "402 Hit on key %s (Async Stream) [%s]. Marking key dead and rotating (%d/%d).",
371
+ "Dead key (401/402/403) on key %s (Async Stream) [%s]. Marking key dead and rotating (%d/%d).",
372
372
  get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
373
373
  )
374
374
  key_usage.mark_dead()
@@ -1,4 +1,5 @@
1
1
  import time
2
+ import asyncio
2
3
  import atexit
3
4
  import threading
4
5
  from threading import Lock, Event
@@ -17,6 +18,8 @@ from ..config.constants import (
17
18
  CLEANUP_INTERVAL_SECONDS,
18
19
  HISTORY_LOOKBACK_SECONDS,
19
20
  DEFAULT_COOLDOWN_SECONDS,
21
+ KEY_WAIT_MAX_SECONDS,
22
+ KEY_WAIT_POLL_SECONDS,
20
23
  )
21
24
  from ..core.utils import get_key_suffix, KeyEntry, normalize_key_entries
22
25
  from ..usage.usage_logger import AsyncUsageLogger
@@ -35,8 +38,10 @@ class RotatingKeyManager:
35
38
  cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS,
36
39
  limit_resolver: Optional[Callable[[str, Optional[str]], RateLimits]] = None,
37
40
  api_key_param: str = "api_key",
41
+ max_key_wait_seconds: float = KEY_WAIT_MAX_SECONDS,
38
42
  ):
39
43
  self.provider_name = provider_name
44
+ self.max_key_wait_seconds = max_key_wait_seconds
40
45
  self.logger = logger or default_logger
41
46
  self.strategy = strategy
42
47
  self.cooldown_seconds = cooldown_seconds
@@ -147,6 +152,64 @@ class RotatingKeyManager:
147
152
  return key
148
153
  return None
149
154
 
155
+ def all_keys_dead(self) -> bool:
156
+ """True when every key has been marked dead (401/402/403) - nothing to wait for."""
157
+ return bool(self.keys) and all(k.dead for k in self.keys)
158
+
159
+ def unavailable_message(self, model_id: str) -> str:
160
+ dead = sum(1 for k in self.keys if k.dead)
161
+ if self.all_keys_dead():
162
+ return f"No usable keys for {model_id}: all {len(self.keys)} keys are dead (401/402/403)"
163
+ return (
164
+ f"No available keys for {model_id}: {dead}/{len(self.keys)} dead, the rest stayed "
165
+ f"rate-limited or cooling down for {self.max_key_wait_seconds:.0f}s"
166
+ )
167
+
168
+ def acquire_key(
169
+ self,
170
+ model_id: str,
171
+ default_limits: RateLimits,
172
+ estimated_tokens: int = 1000,
173
+ max_wait_seconds: Optional[float] = None,
174
+ poll_seconds: float = KEY_WAIT_POLL_SECONDS,
175
+ ) -> Optional[KeyUsage]:
176
+ """
177
+ get_key, but WAIT while every live key is inside a rate window or a
178
+ cooldown instead of handing back None at once.
179
+
180
+ Returns None only when every key is dead or max_wait elapses - so a
181
+ caller with one production key at its per-minute cap rides the window
182
+ out rather than aborting a long run.
183
+ """
184
+ max_wait = self.max_key_wait_seconds if max_wait_seconds is None else max_wait_seconds
185
+ deadline = time.monotonic() + max_wait
186
+ while True:
187
+ key = self.get_key(model_id, default_limits, estimated_tokens)
188
+ if key is not None:
189
+ return key
190
+ if self.all_keys_dead() or time.monotonic() >= deadline:
191
+ return None
192
+ time.sleep(poll_seconds)
193
+
194
+ async def acquire_key_async(
195
+ self,
196
+ model_id: str,
197
+ default_limits: RateLimits,
198
+ estimated_tokens: int = 1000,
199
+ max_wait_seconds: Optional[float] = None,
200
+ poll_seconds: float = KEY_WAIT_POLL_SECONDS,
201
+ ) -> Optional[KeyUsage]:
202
+ """acquire_key for async callers (non-blocking sleep)."""
203
+ max_wait = self.max_key_wait_seconds if max_wait_seconds is None else max_wait_seconds
204
+ deadline = time.monotonic() + max_wait
205
+ while True:
206
+ key = self.get_key(model_id, default_limits, estimated_tokens)
207
+ if key is not None:
208
+ return key
209
+ if self.all_keys_dead() or time.monotonic() >= deadline:
210
+ return None
211
+ await asyncio.sleep(poll_seconds)
212
+
150
213
  def get_specific_key(self, identifier: Union[int, str], model_id: str, estimated_tokens: int = 1000) -> Optional[KeyUsage]:
151
214
  """
152
215
  Get a specific key by index or identifier.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keycycle
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A thread-safe key rotation and rate limiting manager for API keys.
5
5
  Author-email: William Jin <jinwilliam.jin@gmail.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -42,6 +42,8 @@ tests/test_api_key_pinning.py
42
42
  tests/test_api_key_retrieval.py
43
43
  tests/test_cerebras_direct.py
44
44
  tests/test_custom_key_limits.py
45
+ tests/test_dead_key_rotation.py
46
+ tests/test_key_wait.py
45
47
  tests/test_multi_client_wrapper.py
46
48
  tests/test_payment_required_rotation.py
47
49
  tests/test_rate_limit_logic.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keycycle"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  authors = [
9
9
  { name="William Jin", email="jinwilliam.jin@gmail.com" },
10
10
  ]
@@ -0,0 +1,124 @@
1
+ """
2
+ Unit tests for dead-key (HTTP 401/402/403) rotation.
3
+
4
+ Extends test_payment_required_rotation.py: 401/403 mean the key itself is
5
+ unusable (revoked, invalid, or its project denied — Gemini answers
6
+ "Your project has been denied access" with a 403), exactly like a 402.
7
+ Such a key must be marked dead and rotated off, never allowed to abort a
8
+ whole run while dozens of healthy keys sit idle (emails_gen pilot,
9
+ 2026-09-07).
10
+ """
11
+ import unittest
12
+ from unittest.mock import MagicMock, patch
13
+ import sys
14
+ import os
15
+
16
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
17
+
18
+ try:
19
+ from keycycle.core.utils import is_dead_key_error
20
+ from keycycle.config.dataclasses import KeyUsage, RateLimits
21
+ from keycycle.config.enums import RateLimitStrategy
22
+ from keycycle.key_rotation.rotation_manager import RotatingKeyManager
23
+ from keycycle.adapters.openai_adapter import RotatingOpenAIClient
24
+ except ImportError:
25
+ from keycycle.keycycle.core.utils import is_dead_key_error
26
+ from keycycle.keycycle.config.dataclasses import KeyUsage, RateLimits
27
+ from keycycle.keycycle.config.enums import RateLimitStrategy
28
+ from keycycle.keycycle.key_rotation.rotation_manager import RotatingKeyManager
29
+ from keycycle.keycycle.adapters.openai_adapter import RotatingOpenAIClient
30
+
31
+
32
+ class MockAPIError(Exception):
33
+ def __init__(self, message, status_code=None, body=None):
34
+ super().__init__(message)
35
+ self.status_code = status_code
36
+ self.body = body
37
+ self.response = body
38
+
39
+
40
+ class TestDeadKeyDetection(unittest.TestCase):
41
+ def test_403_project_denied_is_dead(self):
42
+ e = MockAPIError(
43
+ "Error code: 403 - [{'error': {'code': 403, 'message': 'Your project has "
44
+ "been denied access. Please contact support.', 'status': 'PERMISSION_DENIED'}}]",
45
+ status_code=403,
46
+ )
47
+ self.assertTrue(is_dead_key_error(e))
48
+
49
+ def test_401_invalid_key_is_dead(self):
50
+ self.assertTrue(is_dead_key_error(MockAPIError("Unauthorized", status_code=401)))
51
+
52
+ def test_402_still_dead(self):
53
+ self.assertTrue(is_dead_key_error(MockAPIError("Payment Required", status_code=402)))
54
+
55
+ def test_429_never_dead_even_with_scary_message(self):
56
+ # A rate limit whose body mentions "forbidden" is still just a rate limit.
57
+ e = MockAPIError("429 rate limit: forbidden to exceed quota", status_code=429)
58
+ self.assertFalse(is_dead_key_error(e))
59
+
60
+ def test_string_fallback_without_status_code(self):
61
+ self.assertTrue(is_dead_key_error(Exception("Error code: 403 - PERMISSION_DENIED")))
62
+ self.assertFalse(is_dead_key_error(Exception("Something unrelated exploded")))
63
+
64
+
65
+ class TestDeadKeyRotation(unittest.TestCase):
66
+ """A 403 on key A must mark A dead and let the request succeed on key B
67
+ (same fixture shape as TestOpenAIAdapter402Rotation)."""
68
+
69
+ def setUp(self):
70
+ self.api_keys = ["sk-key-one-AAAAAAAA", "sk-key-two-BBBBBBBB"]
71
+ mock_db = MagicMock()
72
+ mock_db.load_provider_history.return_value = []
73
+ self.manager = RotatingKeyManager(
74
+ api_keys=self.api_keys,
75
+ provider_name="test",
76
+ strategy=RateLimitStrategy.PER_MODEL,
77
+ db=mock_db,
78
+ )
79
+ limits = RateLimits(100, 6000, 10000, 1000000)
80
+ self.client = RotatingOpenAIClient(
81
+ manager=self.manager,
82
+ limit_resolver=lambda model_id, key_suffix: limits,
83
+ default_model="test-model",
84
+ max_retries=5,
85
+ provider="gemini",
86
+ )
87
+
88
+ def _factory(self, denied_suffixes):
89
+ def factory(api_key):
90
+ fake = MagicMock()
91
+ if api_key[-8:] in denied_suffixes:
92
+ fake.chat.completions.create.side_effect = MockAPIError(
93
+ "Your project has been denied access. Please contact support.",
94
+ status_code=403,
95
+ )
96
+ else:
97
+ response = MagicMock()
98
+ response.usage.total_tokens = 7
99
+ fake.chat.completions.create.return_value = response
100
+ return fake
101
+ return factory
102
+
103
+ def test_403_marks_key_dead_and_rotates(self):
104
+ with patch.object(
105
+ self.client, "_get_fresh_client", side_effect=self._factory({"AAAAAAAA"})
106
+ ), patch("keycycle.adapters.openai_adapter.time.sleep"):
107
+ result = self.client.chat.completions.create(model="test-model")
108
+
109
+ self.assertEqual(result.usage.total_tokens, 7)
110
+ dead = {k.api_key[-8:] for k in self.manager.keys if k.dead}
111
+ self.assertEqual(dead, {"AAAAAAAA"})
112
+
113
+ def test_all_403_raises_without_hanging(self):
114
+ with patch.object(
115
+ self.client, "_get_fresh_client",
116
+ side_effect=self._factory({"AAAAAAAA", "BBBBBBBB"}),
117
+ ), patch("keycycle.adapters.openai_adapter.time.sleep"):
118
+ with self.assertRaises(Exception):
119
+ self.client.chat.completions.create(model="test-model")
120
+ self.assertTrue(all(k.dead for k in self.manager.keys))
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -0,0 +1,78 @@
1
+ """
2
+ acquire_key: wait for a rate window / cooldown instead of failing at once.
3
+
4
+ One production key at its per-minute cap (Cohere, emails_gen pilot
5
+ 2026-09-07) used to surface as "No available keys" after the first
6
+ window filled, aborting a 2,000-document run 20 calls in.
7
+ """
8
+ import time
9
+ import unittest
10
+ from unittest.mock import MagicMock
11
+ import sys
12
+ import os
13
+
14
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
15
+
16
+ try:
17
+ from keycycle.config.dataclasses import RateLimits
18
+ from keycycle.config.enums import RateLimitStrategy
19
+ from keycycle.key_rotation.rotation_manager import RotatingKeyManager
20
+ except ImportError:
21
+ from keycycle.keycycle.config.dataclasses import RateLimits
22
+ from keycycle.keycycle.config.enums import RateLimitStrategy
23
+ from keycycle.keycycle.key_rotation.rotation_manager import RotatingKeyManager
24
+
25
+
26
+ def _manager(keys, cooldown_seconds=1):
27
+ db = MagicMock()
28
+ db.load_provider_history.return_value = []
29
+ return RotatingKeyManager(
30
+ api_keys=keys,
31
+ provider_name="test",
32
+ strategy=RateLimitStrategy.PER_MODEL,
33
+ db=db,
34
+ cooldown_seconds=cooldown_seconds,
35
+ )
36
+
37
+
38
+ class TestAcquireKey(unittest.TestCase):
39
+ def setUp(self):
40
+ self.limits = RateLimits(100, 6000, 10000, 1000000)
41
+
42
+ def test_returns_immediately_when_a_key_is_free(self):
43
+ manager = _manager(["sk-only-key-AAAAAAAA"])
44
+ started = time.monotonic()
45
+ key = manager.acquire_key("m", self.limits, estimated_tokens=10)
46
+ self.assertIsNotNone(key)
47
+ self.assertLess(time.monotonic() - started, 0.5)
48
+
49
+ def test_waits_out_a_cooldown_then_returns_the_key(self):
50
+ manager = _manager(["sk-only-key-AAAAAAAA"], cooldown_seconds=1)
51
+ manager.keys[0].trigger_cooldown()
52
+ self.assertIsNone(manager.get_key("m", self.limits, 10)) # the old behavior
53
+
54
+ started = time.monotonic()
55
+ key = manager.acquire_key("m", self.limits, 10, max_wait_seconds=5, poll_seconds=0.1)
56
+ self.assertIsNotNone(key)
57
+ self.assertGreaterEqual(time.monotonic() - started, 0.5)
58
+
59
+ def test_all_dead_returns_none_without_waiting(self):
60
+ manager = _manager(["sk-a-AAAAAAAA", "sk-b-BBBBBBBB"])
61
+ for k in manager.keys:
62
+ k.mark_dead()
63
+ started = time.monotonic()
64
+ self.assertIsNone(manager.acquire_key("m", self.limits, 10, max_wait_seconds=5))
65
+ self.assertLess(time.monotonic() - started, 0.5)
66
+ self.assertIn("all 2 keys are dead", manager.unavailable_message("m"))
67
+
68
+ def test_gives_up_after_max_wait(self):
69
+ manager = _manager(["sk-only-key-AAAAAAAA"], cooldown_seconds=60)
70
+ manager.keys[0].trigger_cooldown()
71
+ started = time.monotonic()
72
+ self.assertIsNone(manager.acquire_key("m", self.limits, 10, max_wait_seconds=0.3, poll_seconds=0.1))
73
+ self.assertLess(time.monotonic() - started, 2)
74
+ self.assertIn("rate-limited or cooling down", manager.unavailable_message("m"))
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes