diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0adfa..f4cdc42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Device login keeps polling through transient network failures and unreadable token responses until + the device code expires, instead of aborting on the first blip. Terminal OAuth errors still stop + the flow immediately. + ## [0.4.0] - 2026-10-21 ### Added diff --git a/src/auth.ts b/src/auth.ts index 96289e0..0b90ffe 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -160,9 +160,12 @@ export async function pollToken(deviceCode: string, intervalS: number, expiresIn let interval = Math.max(MIN_INTERVAL_S, Number(intervalS) || 5) const deadline = Date.now() + Number(expiresInS || 600) * 1000 let slowDowns = 0 + let transportFailures = 0 while (Date.now() < deadline) { - // The token endpoint answers HTTP errors (e.g. 400 authorization_pending) - // as part of the normal flow: never throw here, classify instead. + // The token endpoint answers OAuth errors (e.g. 400 authorization_pending) as part of the + // normal flow. A rejected fetch, an unparseable body, or a body carrying neither a token nor an + // OAuth error is a transport/proxy blip, not a verdict: count it and keep polling until the + // deadline. Only an explicit, terminal OAuth error aborts the device login. let body: TokenResponse | null = null try { const res = await fetch(TOKEN_URL, { @@ -183,18 +186,23 @@ export async function pollToken(deviceCode: string, intervalS: number, expiresIn } catch { body = null } - if (body && !body.error) { - if (typeof body.access_token !== "string" || !body.access_token) { - throw new Error("token response has no access_token") - } + + if (body && typeof body.access_token === "string" && body.access_token) { return body.access_token } + if (body?.error === "slow_down") { slowDowns += 1 interval = Math.max(MIN_INTERVAL_S, interval + SLOW_DOWN_STEP_S) - } else if (body?.error !== "authorization_pending") { - throw new Error(`login failed: ${body?.error || "unreadable token response"}`) + } else if (body?.error === "authorization_pending") { + // Expected while the user completes approval: keep polling at the current interval. + } else if (typeof body?.error === "string" && body.error) { + throw new Error(`login failed: ${body.error}`) + } else { + // Unreadable response: no body, no token, and no OAuth error to classify. + transportFailures += 1 } + const remaining = deadline - Date.now() if (remaining <= 0) break const { promise, resolve } = Promise.withResolvers() @@ -202,7 +210,11 @@ export async function pollToken(deviceCode: string, intervalS: number, expiresIn await promise } throw new Error( - slowDowns ? "login timed out after slow_down responses" : "login timed out waiting for approval", + slowDowns + ? "login timed out after slow_down responses" + : transportFailures > 0 + ? `login timed out waiting for approval after ${transportFailures} network error${transportFailures === 1 ? "" : "s"}` + : "login timed out waiting for approval", ) } diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 678a24a..406301f 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -91,6 +91,72 @@ test("poll rejects terminal errors", async () => { await assert.rejects(pollToken("dc", 0, 600), /access_denied/) }) +test("poll survives transient transport failures", async () => { + const original = globalThis.fetch + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + if (calls <= 2) { + throw new TypeError("fetch failed") + } + return calls === 3 + ? new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }) + : new Response(JSON.stringify({ access_token: "dca-after-blip" })) + }) as typeof fetch + try { + assert.equal(await pollToken("dc", 1, 600), "dca-after-blip") + assert.equal(calls, 4) + } finally { + globalThis.fetch = original + } +}) + +test("poll keeps polling after unparseable token responses", async () => { + const original = globalThis.fetch + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + return calls === 1 + ? new Response("bad gateway", { status: 502 }) + : new Response(JSON.stringify({ access_token: "dca-after-html" })) + }) as typeof fetch + try { + assert.equal(await pollToken("dc", 1, 600), "dca-after-html") + assert.equal(calls, 2) + } finally { + globalThis.fetch = original + } +}) + +test("poll still aborts on terminal OAuth errors after a transport blip", async () => { + const original = globalThis.fetch + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + return calls === 1 + ? Promise.reject(new TypeError("fetch failed")) + : new Response(JSON.stringify({ error: "access_denied" }), { status: 400 }) + }) as typeof fetch + try { + await assert.rejects(pollToken("dc", 0, 600), /access_denied/) + assert.equal(calls, 2) + } finally { + globalThis.fetch = original + } +}) + +test("poll reports transport failures when it times out", async () => { + const original = globalThis.fetch + globalThis.fetch = (async () => { + throw new TypeError("fetch failed") + }) as typeof fetch + try { + await assert.rejects(pollToken("dc", 1, 1), /1 network error/) + } finally { + globalThis.fetch = original + } +}) + test("mint validates subscription", async () => { stubFetch(() => ({ status: 200, body: KEY_OK })) const minted = await mintKey("dca-x")