fix(ai): honor server-provided slow_down interval in device-code polling

GitHub's device flow documents slow_down as a rate limit: a poll that
arrives inside the throttle window is answered with slow_down instead of
the token, and the response's interval field reports the new required
minimum ("adds 5 seconds to the last interval"). A client that only
tracks its own +5s increment can stay behind the server's ratcheting
requirement when its timers fire early - common with WSL/VM clock drift
(microsoft/WSL#10006) - so every subsequent poll keeps hitting the rate
limit and login appears to hang forever even after the browser reports
the device as authorized.

Adopt the server-provided interval when a slow_down poll result carries
one, falling back to the RFC 8628 section 3.5 +5s increment otherwise.
GitHub Copilot passes the interval field through.

This restores part of the #1994 mitigations that were lost in the #4788
device-code refactor.

refs #6187
This commit is contained in:
Vegard Stikbakke
2026-07-03 22:21:25 +02:00
parent 23d1462611
commit 8133c94db9
5 changed files with 79 additions and 8 deletions
@@ -61,6 +61,67 @@ describe("OAuth device-code polling", () => {
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]);
});
it("increases the interval by 5 seconds after slow_down without a server interval", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const startTime = Date.now();
const pollTimes: number[] = [];
const results = [{ status: "slow_down" as const }, { status: "complete" as const, value: "token" }];
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 900,
poll: async () => {
pollTimes.push(Date.now());
const result = results.shift();
if (!result) throw new Error("Unexpected extra poll");
return result;
},
});
await vi.advanceTimersByTimeAsync(0);
expect(pollTimes).toEqual([startTime]);
await vi.advanceTimersByTimeAsync(6999);
expect(pollTimes).toEqual([startTime]);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([startTime, startTime + 7000]);
});
it("honors a server-provided slow_down interval", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const startTime = Date.now();
const pollTimes: number[] = [];
const results = [
{ status: "slow_down" as const, intervalSeconds: 30 },
{ status: "complete" as const, value: "token" },
];
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 900,
poll: async () => {
pollTimes.push(Date.now());
const result = results.shift();
if (!result) throw new Error("Unexpected extra poll");
return result;
},
});
await vi.advanceTimersByTimeAsync(0);
expect(pollTimes).toEqual([startTime]);
await vi.advanceTimersByTimeAsync(29999);
expect(pollTimes).toEqual([startTime]);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([startTime, startTime + 30000]);
});
it("cancels an in-flight wait", async () => {
vi.useFakeTimers();
const controller = new AbortController();