{"record":{"id":"f133f1c05e33f823","repo":"HKUDS/DeepTutor","slug":"keypool-requires-at-least-one-non-empty-key","errorCode":null,"errorMessage":"KeyPool requires at least one non-empty key","messagePattern":"KeyPool requires at least one non-empty key","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"critical","filePath":"deeptutor/services/keypool.py","lineNumber":15,"sourceCode":"\"\"\"Thread-safe round-robin API key rotation with rate-limit cooldowns.\"\"\"\n\nfrom __future__ import annotations\n\nfrom threading import Lock\nfrom time import monotonic\n\n\nclass KeyPool:\n    \"\"\"Rotate keys and cool a key after two HTTP 429 responses.\"\"\"\n\n    def __init__(self, keys: list[str], cooldown_s: int = 60) -> None:\n        self._keys = [str(key).strip() for key in keys if str(key).strip()]\n        if not self._keys:\n            raise ValueError(\"KeyPool requires at least one non-empty key\")\n        self._cooldown_s = max(0, cooldown_s)\n        self._next_index = 0\n        self._strikes = {key: 0 for key in self._keys}\n        self._cooldown_until = {key: 0.0 for key in self._keys}\n        self._lock = Lock()\n\n    def next(self) -> str:\n        \"\"\"Return the next key, preferring one that is not cooling down.\n\n        When every key is cooling we still return the one that recovers\n        soonest instead of refusing to serve. The pool spreads load across\n        keys; it is not a circuit breaker. Raising here would convert a\n        retryable provider 429 into an unretryable application error for the\n        whole cooldown window — and for the common single-key setup that means\n        every LLM and embedding call failing for a full minute, which is\n        strictly worse than letting the provider's own 429 surface and be\n        retried. The caller (``_KeyRotatingCompletions.create``) already marks\n        the strike and re-raises the real 429 on its second attempt.","sourceCodeStart":1,"sourceCodeEnd":33,"githubUrl":"https://github.com/HKUDS/DeepTutor/blob/3e82f130422a813cdd73c10b21a44e9325f5821a/deeptutor/services/keypool.py#L1-L33","documentation":"KeyPool is the key-rotation helper that cools a provider key after two HTTP 429 responses. Its constructor strips whitespace from every supplied key and refuses to build a pool from an empty result set, because rotation logic has nothing to rotate without at least one usable key. This is a fail-fast configuration guard, not a runtime failure.","triggerScenarios":"Instantiating KeyPool(keys=[]) , with a list of empty/whitespace strings like [' ', ''], or with a list produced by splitting an unset env var (e.g. (os.getenv('KEYS') or '').split(',')) where every element strips to ''.","commonSituations":"Env var name typo so the keys variable is empty; comma-separated key string with trailing commas producing blank entries; a secrets file loaded but parsed into empty strings in CI/test environments; passing keys before they were read from settings.","solutions":["Check the input before construction: log len of the raw keys list and each key's stripped length.","Fix the source of the key list (env var name, settings file, secret manager path).","Filter blanks explicitly and raise a clearer domain error if nothing remains: keys = [k.strip() for k in raw if k.strip()].","Add a startup settings validation step so the app fails with a readable message before KeyPool is built."],"exampleFix":"// before\npool = KeyPool(os.getenv(\"PROVIDER_KEYS\", \"\").split(\",\"))\n\n# after\nraw = [k for k in os.getenv(\"PROVIDER_KEYS\", \"\").split(\",\") if k.strip()]\nif not raw:\n    raise RuntimeError(\"PROVIDER_KEYS env var is empty or unset\")\npool = KeyPool(raw)","handlingStrategy":"validation","validationCode":"raw = os.getenv(\"PROVIDER_KEYS\", \"\").split(\",\")\nkeys = [k.strip() for k in raw if k.strip()]\nif not keys:\n    raise RuntimeError(\"PROVIDER_KEYS must contain at least one non-empty key\")\npool = KeyPool(keys)","typeGuard":"def is_valid_key_list(keys: object) -> bool:\n    return isinstance(keys, list) and bool(keys) and all(\n        isinstance(k, str) and k.strip() for k in keys\n    )","tryCatchPattern":"try:\n    pool = KeyPool(keys)\nexcept ValueError as e:\n    if \"non-empty key\" in str(e):\n        raise SystemExit(\"Configure PROVIDER_KEYS before starting\") from e\n    raise","preventionTips":["Validate key lists at startup, not lazily on first request.","Log the count (never the values) of loaded keys during boot.","Fail fast in CI with a config smoke test that constructs KeyPool."],"tags":["keypool","configuration","validation","rate-limiting"],"backgroundTag":"missing-configuration-value","analyzedSha":"3e82f130422a813cdd73c10b21a44e9325f5821a","analyzedAt":"2026-08-27T06:57:25.364Z","schemaVersion":2},"datasetVersion":"2026-08-27T08:17:20.692Z"}