Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-auth-retry.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 23 additions & 1 deletion packages/sandbox/src/args/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
[
Expand Down
11 changes: 7 additions & 4 deletions packages/sandbox/src/args/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
23 changes: 15 additions & 8 deletions packages/sandbox/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,23 @@ 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";

/**
* A {@link Sandbox} wrapper that adds user-agent headers and error handling.
*/
export const sandboxClient: Pick<typeof Sandbox, "get" | "list" | "create"> = {
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),
),
};
Expand All @@ -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 }),
),
};

Expand All @@ -53,9 +60,9 @@ const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => {
return fetch(input, { ...init, headers });
};

async function withErrorHandling<T>(promise: Promise<T>) {
async function withErrorHandling<T>(factory: () => Promise<T>): Promise<T> {
try {
return await promise;
return await withFreshAuthRetry(factory);
} catch (error) {
if (error instanceof APIError) {
return await handleApiError(error);
Expand Down
128 changes: 128 additions & 0 deletions packages/sandbox/src/util/fresh-auth-retry.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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<T>(promise: Promise<T>): Promise<T> {
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);
});
});
46 changes: 46 additions & 0 deletions packages/sandbox/src/util/fresh-auth-retry.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
factory: () => Promise<T>,
): Promise<T> {
return retry<T>(
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;
}
1 change: 1 addition & 0 deletions packages/vercel-sandbox/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading