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:
@@ -5,6 +5,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed GitHub Copilot device-code login polling to wait before the first token poll, avoiding incorrect device-code failures for some users after browser authorization ([#6187](https://github.com/earendil-works/pi/issues/6187)).
|
- Fixed GitHub Copilot device-code login polling to wait before the first token poll, avoiding incorrect device-code failures for some users after browser authorization ([#6187](https://github.com/earendil-works/pi/issues/6187)).
|
||||||
|
- Fixed OAuth device-code polling to honor the server-provided `slow_down` interval instead of only applying the RFC 8628 5-second increment, so GitHub Copilot login recovers instead of appearing to hang when polls arrive early (e.g. WSL/VM clock drift) ([#6187](https://github.com/earendil-works/pi/issues/6187)).
|
||||||
- Fixed OpenAI Codex user-agent construction to synchronously load Node OS metadata, avoiding a startup race that could report `pi (browser)` in Node/Bun.
|
- Fixed OpenAI Codex user-agent construction to synchronously load Node OS metadata, avoiding a startup race that could report `pi (browser)` in Node/Bun.
|
||||||
- Fixed Fireworks GLM 5.2 Fast to use the OpenAI-compatible endpoint and `thinkingLevelMap`, aligning it with GLM 5.2 ([#6195](https://github.com/earendil-works/pi/issues/6195)).
|
- Fixed Fireworks GLM 5.2 Fast to use the OpenAI-compatible endpoint and `thinkingLevelMap`, aligning it with GLM 5.2 ([#6195](https://github.com/earendil-works/pi/issues/6195)).
|
||||||
- Fixed Amazon Bedrock prompt-cache points for Claude Fable 5 and Claude Sonnet 5 ([#6235](https://github.com/earendil-works/pi/issues/6235)).
|
- Fixed Amazon Bedrock prompt-cache points for Claude Fable 5 and Claude Sonnet 5 ([#6235](https://github.com/earendil-works/pi/issues/6235)).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
|
|||||||
|
|
||||||
type OAuthDeviceCodeIncompletePollResult =
|
type OAuthDeviceCodeIncompletePollResult =
|
||||||
| { status: "pending" }
|
| { status: "pending" }
|
||||||
| { status: "slow_down" }
|
| { status: "slow_down"; intervalSeconds?: number }
|
||||||
| { status: "failed"; message: string };
|
| { status: "failed"; message: string };
|
||||||
|
|
||||||
export type OAuthDeviceCodePollResult<T> = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T };
|
export type OAuthDeviceCodePollResult<T> = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T };
|
||||||
@@ -75,8 +75,15 @@ export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodePollOpt
|
|||||||
}
|
}
|
||||||
if (result.status === "slow_down") {
|
if (result.status === "slow_down") {
|
||||||
slowDownResponses += 1;
|
slowDownResponses += 1;
|
||||||
// RFC 8628 section 3.5: apply this increase to this and all subsequent requests.
|
// Use the server-provided interval when given (GitHub reports the new required minimum
|
||||||
intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
// in `interval`); trusting only a client-tracked value risks polling early forever under
|
||||||
|
// WSL/VM clock drift. Otherwise apply RFC 8628 section 3.5: increase by 5 seconds.
|
||||||
|
intervalMs =
|
||||||
|
typeof result.intervalSeconds === "number" &&
|
||||||
|
Number.isFinite(result.intervalSeconds) &&
|
||||||
|
result.intervalSeconds > 0
|
||||||
|
? Math.max(MINIMUM_INTERVAL_MS, Math.floor(result.intervalSeconds * 1000))
|
||||||
|
: Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingMs = deadline - Date.now();
|
const remainingMs = deadline - Date.now();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type DeviceTokenSuccessResponse = {
|
|||||||
type DeviceTokenErrorResponse = {
|
type DeviceTokenErrorResponse = {
|
||||||
error: string;
|
error: string;
|
||||||
error_description?: string;
|
error_description?: string;
|
||||||
|
interval?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeDomain(input: string): string | null {
|
export function normalizeDomain(input: string): string | null {
|
||||||
@@ -229,13 +230,13 @@ async function pollForGitHubAccessToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
||||||
const { error, error_description: description } = raw as DeviceTokenErrorResponse;
|
const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse;
|
||||||
if (error === "authorization_pending") {
|
if (error === "authorization_pending") {
|
||||||
return { status: "pending" };
|
return { status: "pending" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error === "slow_down") {
|
if (error === "slow_down") {
|
||||||
return { status: "slow_down" };
|
return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptionSuffix = description ? `: ${description}` : "";
|
const descriptionSuffix = description ? `: ${description}` : "";
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
|||||||
const accessTokenPollTimes: number[] = [];
|
const accessTokenPollTimes: number[] = [];
|
||||||
const accessTokenResponses = [
|
const accessTokenResponses = [
|
||||||
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
|
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
|
||||||
jsonResponse({ error: "slow_down", error_description: "slow down" }),
|
jsonResponse({ error: "slow_down", error_description: "slow down", interval: 7 }),
|
||||||
jsonResponse({ access_token: "ghu_refresh_token" }),
|
jsonResponse({ access_token: "ghu_refresh_token" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -329,7 +329,8 @@ describe("GitHub Copilot OAuth device flow", () => {
|
|||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
expect(accessTokenPollTimes).toHaveLength(2);
|
expect(accessTokenPollTimes).toHaveLength(2);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(9999);
|
// slow_down carried a server-provided interval of 7 seconds.
|
||||||
|
await vi.advanceTimersByTimeAsync(6999);
|
||||||
expect(accessTokenPollTimes).toHaveLength(2);
|
expect(accessTokenPollTimes).toHaveLength(2);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
@@ -338,7 +339,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
|||||||
expect(accessTokenPollTimes).toEqual([
|
expect(accessTokenPollTimes).toEqual([
|
||||||
startTime.getTime() + 5000,
|
startTime.getTime() + 5000,
|
||||||
startTime.getTime() + 10000,
|
startTime.getTime() + 10000,
|
||||||
startTime.getTime() + 20000,
|
startTime.getTime() + 17000,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,67 @@ describe("OAuth device-code polling", () => {
|
|||||||
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]);
|
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 () => {
|
it("cancels an in-flight wait", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
Reference in New Issue
Block a user