Skip to content

[Fix] ChatGPT device-code dialog waits forever on codes that can never succeed - #1025

Merged
daniel-lxs merged 2 commits into
developfrom
claude/sweet-joliot-20e8d3
Aug 3, 2026
Merged

[Fix] ChatGPT device-code dialog waits forever on codes that can never succeed#1025
daniel-lxs merged 2 commits into
developfrom
claude/sweet-joliot-20e8d3

Conversation

@daniel-lxs

Copy link
Copy Markdown
Member

Problem

The ChatGPT subscription connect dialog could wait forever on a device code that would never succeed, with no way for an admin to tell an expired code apart from a policy refusal.

Two separate causes:

  1. No expiry. startChatGptDeviceAuth returned the poll interval but dropped the issuer's expiry, and the dialog had no expiry timer. Its while (pollingRef.current && deviceAuth) loop polled a dead code indefinitely behind a "Waiting for authorization…" spinner.
  2. Terminal refusals looked identical to pending. pollChatGptDeviceAuth mapped both HTTP 403 and 404 to pending, so a code the issuer no longer recognizes, and a refusal that no amount of waiting resolves, both rendered as the same spinner as "the user hasn't typed the code in yet".

The polling loop was also invoked as void poll() with no .catch() while awaiting mutateAsync, so the awaited promise rejected unobserved.

What the endpoints actually return

I probed the live endpoints before changing the classification (unauthenticated, no credentials involved):

  • deviceauth/usercode already returns expires_at, an ISO timestamp ~15 minutes out. We were discarding it.
  • deviceauth/token returns HTTP 403 with error.code: "deviceauth_authorization_pending" as the normal pending response, on every poll before the user enters the code.
  • An unknown or aged-out code returns HTTP 404 with error.code: "deviceauth_not_found".

So 403 could not simply be made terminal: that is the pending path, and treating it as terminal would break every connect. 404 was the branch silently mapped to pending forever.

Fix

  • startChatGptDeviceAuth returns expiresInMs, derived from the issuer's expires_at. It is relative rather than absolute so clock skew between server and browser cannot expire a code early or late, and it falls back to 900s when the field is absent or unparseable.
  • Poll classification keys on the structured error.code, never on message wording: the pending code stays pending, deviceauth_not_found becomes failed/expired, and any other explicit code on 403/404 becomes failed/blocked. An unrecognized body still falls back to pending, preserving prior behavior for response shapes not seen here, now bounded by the expiry deadline so it cannot spin forever.
  • HTTP 429 backs off instead of failing.
  • The dialog stops at the deadline with a restart prompt, renders dedicated copy for a blocked deployment (workspace policy blocks the app, contact an admin, waiting will not clear it), honors the backoff interval, and drops the dead code from the UI on any terminal state instead of leaving it next to a spinner.
  • Poll results now drive the loop directly instead of being split across onSuccess/onError. That split was what let the awaited promise reject unobserved; a single code path now owns both stopping the loop and reporting the outcome, and it has the missing .catch().

Testing

Unit: 23 db tests and new dialog tests covering expiry, blocked, expired, rate-limit backoff and the rejected-poll path. Full suites green (db 45 files / 423 tests, web 359 files / 2641 tests).

Live smoke against the real endpoints, using the shipped functions:

Check Result
Issuer expiry parsed expiresInMs: 900229 (15.00 min)
Live un-authorized poll pending, no regression on the normal path
Unknown code poll failed / reason: expired

End-to-end in the browser against a real device code:

Scenario Result
Real 15-minute expiry, nothing mocked Last poll at 22:46:58, expiry ~22:47:00, loop stopped, expired message and Restart shown, zero polls in the following 7 minutes
Shortened window Stops at the deadline, no further polls
Blocked Error plus org-policy guidance, stopped after exactly one poll, code removed, Restart offered
Restart after blocked Fresh code issued, polling resumes
Rejected poll Error surfaced, spinner gone, polling stopped, no unhandled rejection in console

Note for reviewers

The blocked branch is verified for behavior but not against a genuinely policy-blocked account, since I have no way to produce one. It treats any non-pending 403 code as blocked, so it covers that case whatever the specific code turns out to be.

🤖 Generated with Claude Code

The device-code endpoint returns an expires_at roughly fifteen minutes
out, but startChatGptDeviceAuth dropped it and the connect dialog had no
expiry timer, so an aged-out code kept polling behind a "Waiting for
authorization" spinner forever. Return the expiry alongside the interval
and stop the loop at the deadline with a restart prompt.

Polling also mapped every 403 and 404 to pending. Only 403 with the
structured code deviceauth_authorization_pending means the user has not
entered the code yet; 404 (deviceauth_not_found) means the issuer no
longer knows the code, and a 403 carrying any other code is a refusal
waiting cannot resolve, most often an org policy blocking the OAuth app.
Classify on error.code rather than the bare status, and give the blocked
and expired cases their own copy. An unrecognized error body still falls
back to pending, now bounded by the expiry deadline.

Rate-limited polls back off instead of failing, and the poll loop gets
the .catch() it was missing so a rejected request surfaces in the dialog
instead of rejecting unobserved.

🤖 Generated with Claude Code
@roomote-community

roomote-community Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

1 issue outstanding. See task

  • apps/web/src/components/settings/ChatGptConnectDialog.tsx:146 The expiry deadline is only checked before and after await pollMutation.mutateAsync(...). A poll that remains in flight past the deadline never reaches either check, so the dialog keeps waiting without offering Restart, which preserves the indefinite-wait failure this PR is meant to prevent. Add an independent deadline timer (and ignore or abort the late request) so expiry wins even when the poll hangs.

Reviewed 240980a

The 403/404 handling was four sequential returns whose correctness
depended on the unrecognized-code fallback sitting between the two
specific checks. Move it to `classifyDeviceAuthRefusal`, a pure switch
keyed only on the structured error code, so the mapping reads as a table
and can be tested directly instead of through a fetch mock.

The HTTP status is deliberately absent from the signature: the issuer
reuses 403 for both pending and terminal refusals, so it carries no
information the code does not already give.

🤖 Generated with Claude Code
@daniel-lxs
daniel-lxs merged commit 542a639 into develop Aug 3, 2026
18 checks passed
@daniel-lxs
daniel-lxs deleted the claude/sweet-joliot-20e8d3 branch August 3, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant