fix(apps): pace device-flow polls in core — slow_down made auth spin forever

GitHub rate-limits the device-code token endpoint: a poll arriving even
slightly under the flow interval returns slow_down and permanently raises
the required interval. The renderer polled on a fixed 5s timer (exactly
the limit), so one jittery request tripped slow_down and every poll after
it was 'too fast' — GitHub answered slow_down forever and the dialog sat
on 'Waiting for authorization' even after the user approved.

Core now tracks lastTokenPollAt and skips the request until the flow's
current interval (base +1s margin) has elapsed, so slow_down bumps take
effect and the flow recovers. The renderer timer is just a heartbeat (2s).
This commit is contained in:
Gagan 2026-07-06 21:07:13 +05:30
parent 5a7fd1506c
commit b55417e97c
2 changed files with 21 additions and 2 deletions

View file

@ -62,7 +62,9 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: {
setError(e instanceof Error ? e.message : String(e))
setPhase('auth')
})
}, 5000)
// Heartbeat only — core paces the actual GitHub requests to the
// flow's required interval (and skips the request when it's too soon).
}, 2000)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}

View file

@ -35,6 +35,8 @@ type PendingFlow = {
deviceCode: string;
intervalMs: number;
expiresAt: number;
/** Last time we actually hit GitHub's token endpoint (pacing). */
lastTokenPollAt?: number;
/** Token already issued but the identity fetch failed — retry that step. */
issuedToken?: string;
identityAttempts?: number;
@ -71,7 +73,10 @@ export async function startDeviceFlow(): Promise<{ userCode: string; verificatio
};
pending = {
deviceCode: body.device_code,
intervalMs: (body.interval || 5) * 1000,
// +1s safety margin: GitHub measures arrival spacing, and a request
// that lands even slightly early gets `slow_down`, which RAISES the
// required interval for the rest of the flow.
intervalMs: ((body.interval || 5) + 1) * 1000,
expiresAt: Date.now() + body.expires_in * 1000,
};
return { userCode: body.user_code, verificationUri: body.verification_uri, expiresIn: body.expires_in };
@ -93,6 +98,18 @@ export async function pollDeviceFlow(): Promise<PollResult> {
let accessToken = pending.issuedToken;
if (!accessToken) {
// Pacing lives HERE, not in the renderer: the renderer's timer is just
// a heartbeat. GitHub rate-limits the token endpoint per device code —
// polling faster than the flow's interval returns `slow_down` and
// permanently raises the required interval, and a caller that keeps
// its own fixed cadence then gets `slow_down` on EVERY poll (looks
// "pending" forever, even after the user authorized). Skip the request
// entirely until the current interval has elapsed.
const now = Date.now();
if (pending.lastTokenPollAt !== undefined && now - pending.lastTokenPollAt < pending.intervalMs) {
return { status: 'pending' };
}
pending.lastTokenPollAt = now;
const res = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { ...GH_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },