{"record":{"id":"fcbe7637d042aa4e","repo":"janhq/jan","slug":"api-key-rotation-exhausted","errorCode":null,"errorMessage":"API key rotation exhausted","messagePattern":"API key rotation exhausted","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web-app/src/lib/model-factory.ts","lineNumber":778,"sourceCode":"  ): Promise<Response> => {\n    for (let i = 0; i < apiKeys.length; i++) {\n      const key = apiKeys[i]!\n      const nextHeaders = new Headers(init?.headers as HeadersInit | undefined)\n      if (headerMode === 'authorization-bearer') {\n        nextHeaders.set('Authorization', `Bearer ${key}`)\n      } else if (headerMode === 'x-goog-api-key') {\n        nextHeaders.set('x-goog-api-key', key)\n      } else {\n        nextHeaders.set('x-api-key', key)\n      }\n      const res = await inner(input, { ...init, headers: nextHeaders })\n      if ([401, 403, 429].includes(res.status) && i < apiKeys.length - 1) {\n        res.body?.cancel().catch(() => {})\n        continue\n      }\n      return res\n    }\n    throw new Error('API key rotation exhausted')\n  }\n}\n\n// An empty apiKey still puts an empty auth header on the wire, which upstreams\n// answer with misleading 401s (e.g. Anthropic's \"x-api-key header is\n// required\"). Fail here with an actionable message instead.\nfunction requireRemoteApiKey(\n  provider: ProviderObject,\n  keyChain: string[]\n): string {\n  const key = keyChain[0] ?? provider.api_key?.trim()\n  if (!key) {\n    throw new Error(\n      `No API key configured for ${provider.provider}. Add one in Settings > Model Providers.`\n    )\n  }\n  return key\n}","sourceCodeStart":760,"sourceCodeEnd":796,"githubUrl":"https://github.com/janhq/jan/blob/fad3f12a147d138388a66f0d92a02b2675f65294/web-app/src/lib/model-factory.ts#L760-L796","documentation":"Thrown by createApiKeyRotatingFetch after the fetch wrapper cycled through every entry in the apiKeys rotation chain. The loop only continues on 401/403/429 (auth/rate-limit) responses while another key remains; reaching this throw means each key was attempted and each was rejected with one of those statuses. It is the terminal guard at the bottom of the rotation for-loop (model-factory.ts:778).","triggerScenarios":"A provider is configured with multiple API keys (keyChain length > 1) and every key returns 401 (invalid), 403 (forbidden), or 429 (rate-limited) on the chat-completion request. The inner fetch runs once per key, cancels the body on those statuses, and continues until i exhausts apiKeys.length.","commonSituations":"All rotated keys were revoked or expired at once; the user pasted the same dead key into every slot; the provider hit a platform-wide rate limit so even healthy keys 429; a corporate proxy strips auth headers so every key looks invalid.","solutions":["Open Settings > Model Providers and replace at least one API key with a freshly issued, verified key.","If 429s dominate, reduce concurrency / add backoff before retrying — rotation is not a substitute for rate-limit headroom.","Confirm the keys are valid with a direct curl to the provider's /models or /chat/completions endpoint.","Check provider.api_key and the keyChain source (keyring) are not all empty strings, which rotate but never authenticate."],"exampleFix":"// before: rotate silently, surface only 'exhausted'\n// after: surface the last response body so the user sees the provider's reason\nconst res = await inner(input, { ...init, headers: nextHeaders })\nif ([401, 403, 429].includes(res.status) && i < apiKeys.length - 1) {\n  res.body?.cancel().catch(() => {})\n  continue\n}\nreturn res\n// (loop fallthrough now impossible; keep the throw as a defensive invariant)","handlingStrategy":"validation","validationCode":"function pickFirstValidKey(apiKeys: string[]): string | undefined {\n  return apiKeys.find(k => typeof k === 'string' && k.trim().length > 0)\n}\n// before constructing createApiKeyRotatingFetch:\nif (!pickFirstValidKey(apiKeys)) {\n  throw new Error('No valid API key available for rotation')\n}","typeGuard":"function hasUsableKeyChain(keys: unknown): keys is string[] {\n  return Array.isArray(keys) && keys.some(k => typeof k === 'string' && k.trim().length > 0)\n}","tryCatchPattern":"try {\n  return await createApiKeyRotatingFetch(fetch, apiKeys, params, headerMode)(url, init)\n} catch (e) {\n  if (e instanceof Error && e.message === 'API key rotation exhausted') {\n    // surface a settings deep-link; do not retry with the same keys\n    throw new Error('All API keys were rejected. Update them in Settings > Model Providers.')\n  }\n  throw e\n}","preventionTips":["Validate that at least one key is non-empty before entering the rotation path.","Cache a health flag per key so known-bad keys are skipped on subsequent rotations.","Surface the last failing status (401 vs 403 vs 429) to the user instead of a generic 'exhausted'.","Treat 429 with backoff, not key rotation — rotation does not help a rate limit."],"tags":["api-key","auth","rate-limit","rotation","network","typescript"],"backgroundTag":null,"analyzedSha":"fad3f12a147d138388a66f0d92a02b2675f65294","analyzedAt":"2026-08-12T20:33:47.516Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}