{"record":{"id":"7ec94fa42486fea3","repo":"langgenius/dify","slug":"server-5xx","errorCode":"server_5xx","errorMessage":"device-flow poll unavailable after retries","messagePattern":"device-flow poll unavailable after retries","errorType":"error_code","errorClass":"BaseError","httpStatus":null,"severity":"error","filePath":"cli/src/commands/auth/login/device-flow.ts","lineNumber":59,"sourceCode":"    if (opts.clock.isCancelled()) throw expired()\n    const result = await pollWithRetry(api, req, opts.clock)\n    switch (result.status) {\n      case 'approved':\n        return result.success\n      case 'pending':\n        break\n      case 'slow_down':\n        interval = Math.min(interval * 2, MAX_INTERVAL_MS)\n        break\n      case 'expired':\n        throw expired()\n      case 'denied':\n        throw new BaseError({\n          code: ErrorCode.AccessDenied,\n          message: 'authorization denied',\n        })\n      case 'retry_5xx':\n        throw new BaseError({\n          code: ErrorCode.Server5xx,\n          message: 'device-flow poll unavailable after retries',\n        })\n    }\n    await opts.clock.sleepMs(interval)\n    if (opts.clock.isCancelled()) throw expired()\n  }\n}\n\nasync function pollWithRetry(\n  api: DeviceFlowApiSubset,\n  req: PollRequest,\n  clock: Clock,\n): Promise<PollResult> {\n  let backoff = POLL_RETRY_INITIAL_MS\n  for (let attempt = 1; attempt <= POLL_RETRY_ATTEMPTS; attempt++) {\n    const result = await api.pollOnce(req)\n    if (result.status !== 'retry_5xx') return result","sourceCodeStart":41,"sourceCodeEnd":77,"githubUrl":"https://github.com/langgenius/dify/blob/ef8544b173fd6cd7a8e71df2cab576e52bebbfbc/cli/src/commands/auth/login/device-flow.ts#L41-L77","documentation":"Raised by DatasourceOAuthCallback.get when a context_id is present but OAuthProxyService.use_proxy_context(context_id) returns None — meaning the stored proxy context does not exist or has expired. use_proxy_context both looks up and consumes/invalidates the context, so this fires on second use, expiry, or unknown IDs. Maps to HTTP 403 via werkzeug Forbidden.","triggerScenarios":"User reloads or reopens the callback URL after the context was already consumed; the context TTL elapsed between authorization and callback; the context was never persisted (e.g. Redis flushed); context_id was tampered with.","commonSituations":"Double callback (provider retries, or user refreshes the callback page); short context TTL combined with a long OAuth provider user-interaction; shared cache (Redis) eviction; load balancer routing the callback to an instance without access to the context store.","solutions":["Restart the OAuth flow from get-authorization-url to mint a fresh context_id.","Ensure the context store (e.g. Redis) is shared across all API instances and not being flushed.","Increase OAuthProxyService.__MAX_AGE__ if users routinely exceed the TTL during provider authorization.","Make the callback handler idempotent on the client side so a duplicate redirect does not re-trigger the consumed context."],"exampleFix":"// before — client retries the callback on transient failure\nfetch(callbackUrl);  // second hit -> Invalid context_id\n// after — on 403 Invalid context_id, restart the flow\nconst res = await fetch(callbackUrl);\nif (res.status === 403) { window.location = `/console/api/oauth/plugin/${providerId}/datasource/get-authorization-url`; }","handlingStrategy":"retry","validationCode":"// cannot validate server-side context from the client; instead detect 403 and restart\n// pre-check: ensure the context was created recently\nconst startedAt = Number(sessionStorage.getItem('oauth_started_at') || 0);\nconst stale = Date.now() - startedAt > OAuthProxy_MAX_AGE_MS;\nif (stale) { restartFlow(); }","typeGuard":"interface ProxyContext { user_id: string; tenant_id: string; }\nfunction isValidProxyContext(c: unknown): c is ProxyContext {\n  return typeof c === 'object' && c !== null\n    && typeof (c as ProxyContext).user_id === 'string'\n    && typeof (c as ProxyContext).tenant_id === 'string';\n}","tryCatchPattern":"try {\n  await fetch(callbackUrl);\n} catch (e) {\n  if (e.response?.status === 403 && /Invalid context_id/i.test(e.response.data?.message || '')) {\n    // context consumed or expired — restart exactly once\n    window.location = `/console/api/oauth/plugin/${providerId}/datasource/get-authorization-url`;\n    return;\n  }\n  throw e;\n}","preventionTips":["Make the callback handling single-use: do not refresh or retry the callback URL.","Ensure the context store (Redis) is shared across API instances and not flushed mid-flow.","Tune OAuthProxyService.__MAX_AGE__ to exceed the provider's longest user-interaction time."],"tags":["oauth","callback","context","forbidden","authentication","ttl","console"],"backgroundTag":null,"analyzedSha":"ef8544b173fd6cd7a8e71df2cab576e52bebbfbc","analyzedAt":"2026-08-12T05:15:17.394Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}