diff --git a/.changeset/fresh-auth-retry.md b/.changeset/fresh-auth-retry.md new file mode 100644 index 00000000..fcecfa5d --- /dev/null +++ b/.changeset/fresh-auth-retry.md @@ -0,0 +1,5 @@ +--- +"sandbox": patch +--- + +Fix transient 401 on the first sandbox command after auto-login by retrying the command when the token was just obtained, to absorb cross-region auth token replication lag. diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index d343f8b4..0c9de1f9 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -39,10 +39,12 @@ "license": "Apache-2.0", "dependencies": { "@vercel/sandbox": "workspace:*", + "async-retry": "1.3.3", "debug": "^4.4.1", "zod": "^4.1.1" }, "devDependencies": { + "@types/async-retry": "1.4.9", "@types/debug": "^4.1.12", "@types/ms": "^2.1.0", "@types/node": "^22.15.12", diff --git a/packages/sandbox/src/args/auth.ts b/packages/sandbox/src/args/auth.ts index e9637df4..6967b5e2 100644 --- a/packages/sandbox/src/args/auth.ts +++ b/packages/sandbox/src/args/auth.ts @@ -11,6 +11,26 @@ import { const debug = createDebugger("sandbox:args:auth"); +/** + * Timestamp (ms epoch) of the most recent auto-login. Used to identify + * tokens so that the first 401/403 against a freshly-issued token can be + * retried instead of surfaced to the user. + */ +let freshTokenAcquiredAt: number | undefined; + +const FRESH_TOKEN_WINDOW_MS = 10_000; + +export function isTokenFresh(): boolean { + return ( + freshTokenAcquiredAt !== undefined && + Date.now() - freshTokenAcquiredAt < FRESH_TOKEN_WINDOW_MS + ); +} + +function markTokenAsFresh(): void { + freshTokenAcquiredAt = Date.now(); +} + export const token = cmd.option({ long: "token", description: @@ -58,7 +78,9 @@ export const token = cmd.option({ // Try again after login try { - return await getVercelToken(); + const refreshed = await getVercelToken(); + markTokenAsFresh(); + return refreshed; } catch (retryError) { throw new Error( [ diff --git a/packages/sandbox/src/args/scope.ts b/packages/sandbox/src/args/scope.ts index b544af70..83543b63 100644 --- a/packages/sandbox/src/args/scope.ts +++ b/packages/sandbox/src/args/scope.ts @@ -2,6 +2,7 @@ import { token } from "./auth"; import * as cmd from "cmd-ts"; import type { ArgParser } from "cmd-ts/dist/esm/argparser"; import { inferScope } from "../util/infer-scope"; +import { withFreshAuthRetry } from "../util/fresh-auth-retry"; import type { ProvidesHelp } from "cmd-ts/dist/esm/helpdoc"; import chalk from "chalk"; @@ -101,10 +102,12 @@ export const scope: ArgParser<{ typeof teamId.value === "undefined" ) { try { - const scope = await inferScope({ - token: t.value, - team: teamId.value, - }); + const scope = await withFreshAuthRetry(() => + inferScope({ + token: t.value, + team: teamId.value, + }), + ); projectId.value ??= scope.project; teamId.value ??= scope.owner; projectSlug = scope.projectSlug; diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts index 5ee9eff8..17205ec0 100644 --- a/packages/sandbox/src/client.ts +++ b/packages/sandbox/src/client.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import Path from "node:path"; import { writeFile } from "node:fs/promises"; import { StyledError } from "./error"; +import { withFreshAuthRetry } from "./util/fresh-auth-retry"; import { z } from "zod"; /** @@ -12,11 +13,15 @@ import { z } from "zod"; */ export const sandboxClient: Pick = { get: (params) => - withErrorHandling(Sandbox.get({ fetch: fetchWithUserAgent, resume: false, ...params })), + withErrorHandling(() => + Sandbox.get({ fetch: fetchWithUserAgent, resume: false, ...params }), + ), create: (params) => - withErrorHandling(Sandbox.create({ fetch: fetchWithUserAgent, ...params })), + withErrorHandling(() => + Sandbox.create({ fetch: fetchWithUserAgent, ...params }), + ), list: (params) => - withErrorHandling( + withErrorHandling(() => Sandbox.list({ fetch: fetchWithUserAgent, ...params } as typeof params), ), }; @@ -26,11 +31,13 @@ export const snapshotClient: Pick< "get" | "list" | "fromSandbox" > = { list: (params) => - withErrorHandling(Snapshot.list({ fetch: fetchWithUserAgent, ...params })), - get: (params) => withErrorHandling(Snapshot.get({ ...params })), + withErrorHandling(() => + Snapshot.list({ fetch: fetchWithUserAgent, ...params }), + ), + get: (params) => withErrorHandling(() => Snapshot.get({ ...params })), fromSandbox: (name, opts) => withErrorHandling( - Snapshot.fromSandbox(name, { fetch: fetchWithUserAgent, ...opts }), + () => Snapshot.fromSandbox(name, { fetch: fetchWithUserAgent, ...opts }), ), }; @@ -53,9 +60,9 @@ const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => { return fetch(input, { ...init, headers }); }; -async function withErrorHandling(promise: Promise) { +async function withErrorHandling(factory: () => Promise): Promise { try { - return await promise; + return await withFreshAuthRetry(factory); } catch (error) { if (error instanceof APIError) { return await handleApiError(error); diff --git a/packages/sandbox/src/util/fresh-auth-retry.test.ts b/packages/sandbox/src/util/fresh-auth-retry.test.ts new file mode 100644 index 00000000..e1498233 --- /dev/null +++ b/packages/sandbox/src/util/fresh-auth-retry.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { APIError } from "@vercel/sandbox"; +import { NotOk } from "@vercel/sandbox/dist/auth/index.js"; + +const isTokenFreshMock = vi.fn<() => boolean>(); + +vi.mock("../args/auth", () => ({ + isTokenFresh: () => isTokenFreshMock(), +})); + +const { withFreshAuthRetry } = await import("./fresh-auth-retry"); + +function makeApiError(status: number): APIError { + return new APIError( + new Response("", { status, statusText: "Unauthorized" }), + ); +} + +function makeNotOk(statusCode: number): NotOk { + return new NotOk({ statusCode, responseText: "no" }); +} + +/** + * Await the result of a withFreshAuthRetry call that performs backoff via + * async-retry's setTimeout. With vi.useFakeTimers active those timers don't + * fire on their own; runAllTimersAsync drains them in parallel with the + * awaiting test. + */ +async function awaitWithTimers(promise: Promise): Promise { + const settled = promise.then( + (value) => ({ ok: true as const, value }), + (error) => ({ ok: false as const, error }), + ); + await vi.runAllTimersAsync(); + const result = await settled; + if (result.ok) return result.value; + throw result.error; +} + +describe("withFreshAuthRetry", () => { + beforeEach(() => { + isTokenFreshMock.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the value on first success without retrying", async () => { + isTokenFreshMock.mockReturnValue(true); + const factory = vi.fn().mockResolvedValue("ok"); + await expect(awaitWithTimers(withFreshAuthRetry(factory))).resolves.toBe( + "ok", + ); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("retries on 401 when token is fresh and eventually succeeds", async () => { + isTokenFreshMock.mockReturnValue(true); + const factory = vi + .fn() + .mockRejectedValueOnce(makeApiError(401)) + .mockRejectedValueOnce(makeApiError(401)) + .mockResolvedValue("ok"); + await expect(awaitWithTimers(withFreshAuthRetry(factory))).resolves.toBe( + "ok", + ); + expect(factory).toHaveBeenCalledTimes(3); + }); + + it("retries on NotOk 403 from the vercel-sandbox auth layer", async () => { + isTokenFreshMock.mockReturnValue(true); + const factory = vi + .fn() + .mockRejectedValueOnce(makeNotOk(403)) + .mockResolvedValue("scope"); + await expect(awaitWithTimers(withFreshAuthRetry(factory))).resolves.toBe( + "scope", + ); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("throws the last 401 once retries are exhausted", async () => { + isTokenFreshMock.mockReturnValue(true); + const final = makeApiError(401); + const factory = vi + .fn() + .mockRejectedValueOnce(makeApiError(401)) + .mockRejectedValueOnce(makeApiError(401)) + .mockRejectedValueOnce(makeApiError(401)) + .mockRejectedValue(final); + await expect(awaitWithTimers(withFreshAuthRetry(factory))).rejects.toBe( + final, + ); + expect(factory).toHaveBeenCalledTimes(4); + }); + + it("does not retry auth errors when the token is not fresh", async () => { + isTokenFreshMock.mockReturnValue(false); + const err = makeApiError(401); + const factory = vi.fn().mockRejectedValue(err); + await expect(awaitWithTimers(withFreshAuthRetry(factory))).rejects.toBe( + err, + ); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("does not retry non-auth APIError statuses even when token is fresh", async () => { + isTokenFreshMock.mockReturnValue(true); + const err = makeApiError(500); + const factory = vi.fn().mockRejectedValue(err); + await expect(awaitWithTimers(withFreshAuthRetry(factory))).rejects.toBe( + err, + ); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("does not retry generic errors even when token is fresh", async () => { + isTokenFreshMock.mockReturnValue(true); + const err = new Error("boom"); + const factory = vi.fn().mockRejectedValue(err); + await expect(awaitWithTimers(withFreshAuthRetry(factory))).rejects.toBe( + err, + ); + expect(factory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sandbox/src/util/fresh-auth-retry.ts b/packages/sandbox/src/util/fresh-auth-retry.ts new file mode 100644 index 00000000..34835c29 --- /dev/null +++ b/packages/sandbox/src/util/fresh-auth-retry.ts @@ -0,0 +1,46 @@ +import retry from "async-retry"; +import { APIError } from "@vercel/sandbox"; +import { NotOk } from "@vercel/sandbox/dist/auth/index.js"; +import createDebugger from "debug"; +import { isTokenFresh } from "../args/auth"; + +const debug = createDebugger("sandbox:fresh-auth-retry"); + +/** + * Run an async operation, transparently retrying on 401/403 when the auth + * token was just acquired via auto-login. + */ +export async function withFreshAuthRetry( + factory: () => Promise, +): Promise { + return retry( + async (bail, attempt) => { + try { + return await factory(); + } catch (error) { + const status = getAuthFailureStatus(error); + if (status !== undefined && isTokenFresh()) { + debug(`fresh-auth retry attempt ${attempt} (status ${status})`); + throw error; + } + bail(error as Error); + return undefined as never; + } + }, + { retries: 3, minTimeout: 250, factor: 2, maxRetryTime: 3_000 }, + ); +} + +/** + * Returns the HTTP status if the error represents a 401 or 403. + * Returns undefined for any other error. + */ +function getAuthFailureStatus(error: unknown): number | undefined { + let status: number | undefined; + if (error instanceof APIError) { + status = error.response.status; + } else if (error instanceof NotOk) { + status = error.response.statusCode; + } + return status === 401 || status === 403 ? status : undefined; +} diff --git a/packages/vercel-sandbox/src/auth/index.ts b/packages/vercel-sandbox/src/auth/index.ts index c155d378..d11ea223 100644 --- a/packages/vercel-sandbox/src/auth/index.ts +++ b/packages/vercel-sandbox/src/auth/index.ts @@ -7,3 +7,4 @@ export * from "./oauth.js"; export type * from "./oauth.js"; export { pollForToken } from "./poll-for-token.js"; export { inferScope } from "./project.js"; +export { NotOk } from "./error.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d1303b9..74d32530 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: '@vercel/sandbox': specifier: workspace:* version: link:../vercel-sandbox + async-retry: + specifier: 1.3.3 + version: 1.3.3 debug: specifier: ^4.4.1 version: 4.4.3(supports-color@8.1.1) @@ -327,6 +330,9 @@ importers: specifier: ^4.1.1 version: 4.1.5 devDependencies: + '@types/async-retry': + specifier: 1.4.9 + version: 1.4.9 '@types/debug': specifier: ^4.1.12 version: 4.1.12