[Fix] ChatGPT device-code dialog waits forever on codes that can never succeed - #1025
Merged
Conversation
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
Contributor
|
1 issue outstanding. See task
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
startChatGptDeviceAuthreturned the poll interval but dropped the issuer's expiry, and the dialog had no expiry timer. Itswhile (pollingRef.current && deviceAuth)loop polled a dead code indefinitely behind a "Waiting for authorization…" spinner.pollChatGptDeviceAuthmapped both HTTP 403 and 404 topending, 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 awaitingmutateAsync, 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/usercodealready returnsexpires_at, an ISO timestamp ~15 minutes out. We were discarding it.deviceauth/tokenreturns HTTP 403 witherror.code: "deviceauth_authorization_pending"as the normal pending response, on every poll before the user enters the code.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
pendingforever.Fix
startChatGptDeviceAuthreturnsexpiresInMs, derived from the issuer'sexpires_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.error.code, never on message wording: the pending code stayspending,deviceauth_not_foundbecomesfailed/expired, and any other explicit code on 403/404 becomesfailed/blocked. An unrecognized body still falls back topending, preserving prior behavior for response shapes not seen here, now bounded by the expiry deadline so it cannot spin forever.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:
expiresInMs: 900229(15.00 min)pending, no regression on the normal pathfailed/reason: expiredEnd-to-end in the browser against a real device code:
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