Skip to content

Commit a451cff

Browse files
sweetmantechclaude
andcommitted
fix: skip expired snapshots and fallback to fresh sandbox on failure
Accounts with expired Vercel snapshots caused Sandbox.create() to throw "Status code 400 is not ok" with no recovery path. Changes: - Extract getValidSnapshotId to its own lib file (SRP) - Both processCreateSandbox and createSandboxFromSnapshot use it - Add fallback: if snapshot creation fails, create a fresh sandbox - DRY: processCreateSandbox uses createSandboxWithFallback helper so createSandbox({}) is only called once - Add tests for getValidSnapshotId (5 tests) - Add snapshot expiry and fallback tests to existing test files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3c10786 commit a451cff

File tree

6 files changed

+264
-31
lines changed

6 files changed

+264
-31
lines changed

lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@ import type { Sandbox } from "@vercel/sandbox";
33

44
import { createSandboxFromSnapshot } from "../createSandboxFromSnapshot";
55

6-
const mockSelectAccountSnapshots = vi.fn();
6+
const mockGetValidSnapshotId = vi.fn();
77
const mockInsertAccountSandbox = vi.fn();
88
const mockCreateSandbox = vi.fn();
99

1010
vi.mock("@/lib/sandbox/createSandbox", () => ({
1111
createSandbox: (...args: unknown[]) => mockCreateSandbox(...args),
1212
}));
1313

14-
vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
15-
selectAccountSnapshots: (...args: unknown[]) => mockSelectAccountSnapshots(...args),
14+
vi.mock("@/lib/sandbox/getValidSnapshotId", () => ({
15+
getValidSnapshotId: (...args: unknown[]) => mockGetValidSnapshotId(...args),
1616
}));
1717

1818
vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({
@@ -44,9 +44,7 @@ describe("createSandboxFromSnapshot", () => {
4444
});
4545

4646
it("creates from snapshot when available", async () => {
47-
mockSelectAccountSnapshots.mockResolvedValue([
48-
{ snapshot_id: "snap_abc", account_id: "acc_1" },
49-
]);
47+
mockGetValidSnapshotId.mockResolvedValue("snap_abc");
5048

5149
await createSandboxFromSnapshot("acc_1");
5250

@@ -56,15 +54,15 @@ describe("createSandboxFromSnapshot", () => {
5654
});
5755

5856
it("creates fresh sandbox when no snapshot exists", async () => {
59-
mockSelectAccountSnapshots.mockResolvedValue([]);
57+
mockGetValidSnapshotId.mockResolvedValue(undefined);
6058

6159
await createSandboxFromSnapshot("acc_1");
6260

6361
expect(mockCreateSandbox).toHaveBeenCalledWith({});
6462
});
6563

6664
it("inserts account_sandbox record", async () => {
67-
mockSelectAccountSnapshots.mockResolvedValue([]);
65+
mockGetValidSnapshotId.mockResolvedValue(undefined);
6866

6967
await createSandboxFromSnapshot("acc_1");
7068

@@ -75,20 +73,54 @@ describe("createSandboxFromSnapshot", () => {
7573
});
7674

7775
it("returns { sandbox, fromSnapshot: true } when snapshot exists", async () => {
78-
mockSelectAccountSnapshots.mockResolvedValue([
79-
{ snapshot_id: "snap_abc", account_id: "acc_1" },
80-
]);
76+
mockGetValidSnapshotId.mockResolvedValue("snap_abc");
8177

8278
const result = await createSandboxFromSnapshot("acc_1");
8379

8480
expect(result).toEqual({ sandbox: mockSandbox, fromSnapshot: true });
8581
});
8682

8783
it("returns { sandbox, fromSnapshot: false } when no snapshot", async () => {
88-
mockSelectAccountSnapshots.mockResolvedValue([]);
84+
mockGetValidSnapshotId.mockResolvedValue(undefined);
8985

9086
const result = await createSandboxFromSnapshot("acc_1");
9187

9288
expect(result).toEqual({ sandbox: mockSandbox, fromSnapshot: false });
9389
});
90+
91+
it("skips expired snapshot (getValidSnapshotId returns undefined)", async () => {
92+
mockGetValidSnapshotId.mockResolvedValue(undefined);
93+
94+
const result = await createSandboxFromSnapshot("acc_1");
95+
96+
expect(mockCreateSandbox).toHaveBeenCalledWith({});
97+
expect(result).toEqual({ sandbox: mockSandbox, fromSnapshot: false });
98+
});
99+
100+
it("falls back to fresh sandbox when snapshot creation fails", async () => {
101+
mockGetValidSnapshotId.mockResolvedValue("snap_bad");
102+
103+
const freshSandbox = {
104+
sandboxId: "sbx_fresh",
105+
status: "running",
106+
runCommand: vi.fn(),
107+
} as unknown as Sandbox;
108+
109+
mockCreateSandbox
110+
.mockRejectedValueOnce(new Error("Status code 400 is not ok"))
111+
.mockResolvedValueOnce({
112+
sandbox: freshSandbox,
113+
response: {
114+
sandboxId: "sbx_fresh",
115+
sandboxStatus: "running",
116+
timeout: 1800000,
117+
createdAt: "2024-01-01T00:00:00.000Z",
118+
},
119+
});
120+
121+
const result = await createSandboxFromSnapshot("acc_1");
122+
123+
expect(mockCreateSandbox).toHaveBeenCalledTimes(2);
124+
expect(result).toEqual({ sandbox: freshSandbox, fromSnapshot: false });
125+
});
94126
});

lib/sandbox/__tests__/createSandboxPostHandler.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,97 @@ describe("createSandboxPostHandler", () => {
361361
expect(triggerPromptSandbox).not.toHaveBeenCalled();
362362
});
363363

364+
it("skips expired snapshot and creates fresh sandbox", async () => {
365+
const pastDate = new Date(Date.now() - 86400000).toISOString();
366+
vi.mocked(validateSandboxBody).mockResolvedValue({
367+
accountId: "acc_123",
368+
orgId: null,
369+
authToken: "token",
370+
});
371+
vi.mocked(selectAccountSnapshots).mockResolvedValue([
372+
{
373+
id: "snap_record_123",
374+
account_id: "acc_123",
375+
snapshot_id: "snap_expired",
376+
created_at: "2024-01-01T00:00:00.000Z",
377+
expires_at: pastDate,
378+
},
379+
]);
380+
vi.mocked(createSandbox).mockResolvedValue({
381+
sandbox: {} as never,
382+
response: {
383+
sandboxId: "sbx_fresh",
384+
sandboxStatus: "running",
385+
timeout: 600000,
386+
createdAt: "2024-01-01T00:00:00.000Z",
387+
},
388+
});
389+
vi.mocked(insertAccountSandbox).mockResolvedValue({
390+
data: {
391+
id: "record_123",
392+
account_id: "acc_123",
393+
sandbox_id: "sbx_fresh",
394+
created_at: "2024-01-01T00:00:00.000Z",
395+
},
396+
error: null,
397+
});
398+
399+
const request = createMockRequest();
400+
const response = await createSandboxPostHandler(request);
401+
402+
expect(response.status).toBe(200);
403+
expect(createSandbox).toHaveBeenCalledWith({});
404+
expect(createSandbox).not.toHaveBeenCalledWith(
405+
expect.objectContaining({ source: expect.anything() }),
406+
);
407+
});
408+
409+
it("falls back to fresh sandbox when snapshot creation fails", async () => {
410+
const futureDate = new Date(Date.now() + 86400000).toISOString();
411+
vi.mocked(validateSandboxBody).mockResolvedValue({
412+
accountId: "acc_123",
413+
orgId: null,
414+
authToken: "token",
415+
});
416+
vi.mocked(selectAccountSnapshots).mockResolvedValue([
417+
{
418+
id: "snap_record_123",
419+
account_id: "acc_123",
420+
snapshot_id: "snap_bad",
421+
created_at: "2024-01-01T00:00:00.000Z",
422+
expires_at: futureDate,
423+
},
424+
]);
425+
vi.mocked(createSandbox)
426+
.mockRejectedValueOnce(new Error("Status code 400 is not ok"))
427+
.mockResolvedValueOnce({
428+
sandbox: {} as never,
429+
response: {
430+
sandboxId: "sbx_fallback",
431+
sandboxStatus: "running",
432+
timeout: 600000,
433+
createdAt: "2024-01-01T00:00:00.000Z",
434+
},
435+
});
436+
vi.mocked(insertAccountSandbox).mockResolvedValue({
437+
data: {
438+
id: "record_123",
439+
account_id: "acc_123",
440+
sandbox_id: "sbx_fallback",
441+
created_at: "2024-01-01T00:00:00.000Z",
442+
},
443+
error: null,
444+
});
445+
446+
const request = createMockRequest();
447+
const response = await createSandboxPostHandler(request);
448+
449+
expect(response.status).toBe(200);
450+
expect(createSandbox).toHaveBeenCalledTimes(2);
451+
const json = await response.json();
452+
expect(json.sandboxes[0].sandboxId).toBe("sbx_fallback");
453+
});
454+
364455
it("returns 200 without runId when triggerPromptSandbox throws", async () => {
365456
vi.mocked(validateSandboxBody).mockResolvedValue({
366457
accountId: "acc_123",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
import { getValidSnapshotId } from "../getValidSnapshotId";
4+
5+
const mockSelectAccountSnapshots = vi.fn();
6+
7+
vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
8+
selectAccountSnapshots: (...args: unknown[]) => mockSelectAccountSnapshots(...args),
9+
}));
10+
11+
describe("getValidSnapshotId", () => {
12+
beforeEach(() => {
13+
vi.clearAllMocks();
14+
});
15+
16+
it("returns snapshot_id when snapshot exists and is not expired", async () => {
17+
const futureDate = new Date(Date.now() + 86400000).toISOString();
18+
mockSelectAccountSnapshots.mockResolvedValue([
19+
{ snapshot_id: "snap_abc", account_id: "acc_1", expires_at: futureDate },
20+
]);
21+
22+
const result = await getValidSnapshotId("acc_1");
23+
24+
expect(result).toBe("snap_abc");
25+
});
26+
27+
it("returns snapshot_id when snapshot has no expires_at", async () => {
28+
mockSelectAccountSnapshots.mockResolvedValue([
29+
{ snapshot_id: "snap_abc", account_id: "acc_1", expires_at: null },
30+
]);
31+
32+
const result = await getValidSnapshotId("acc_1");
33+
34+
expect(result).toBe("snap_abc");
35+
});
36+
37+
it("returns undefined when snapshot is expired", async () => {
38+
const pastDate = new Date(Date.now() - 86400000).toISOString();
39+
mockSelectAccountSnapshots.mockResolvedValue([
40+
{ snapshot_id: "snap_expired", account_id: "acc_1", expires_at: pastDate },
41+
]);
42+
43+
const result = await getValidSnapshotId("acc_1");
44+
45+
expect(result).toBeUndefined();
46+
});
47+
48+
it("returns undefined when no snapshots exist", async () => {
49+
mockSelectAccountSnapshots.mockResolvedValue([]);
50+
51+
const result = await getValidSnapshotId("acc_1");
52+
53+
expect(result).toBeUndefined();
54+
});
55+
56+
it("returns undefined when snapshot has no snapshot_id", async () => {
57+
mockSelectAccountSnapshots.mockResolvedValue([
58+
{ snapshot_id: null, account_id: "acc_1", expires_at: null },
59+
]);
60+
61+
const result = await getValidSnapshotId("acc_1");
62+
63+
expect(result).toBeUndefined();
64+
});
65+
});
Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Sandbox } from "@vercel/sandbox";
22
import { createSandbox } from "@/lib/sandbox/createSandbox";
3-
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";
3+
import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId";
44
import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox";
55

66
export interface CreateSandboxFromSnapshotResult {
@@ -9,26 +9,40 @@ export interface CreateSandboxFromSnapshotResult {
99
}
1010

1111
/**
12-
* Creates a new sandbox from the account's latest snapshot (or fresh if none)
13-
* and records it in the database.
12+
* Creates a new sandbox from the account's latest valid snapshot,
13+
* falling back to a fresh sandbox if the snapshot is expired or fails.
1414
*
1515
* @param accountId - The account ID to create a sandbox for
1616
* @returns The created Sandbox instance and whether it was created from a snapshot
1717
*/
1818
export async function createSandboxFromSnapshot(
1919
accountId: string,
2020
): Promise<CreateSandboxFromSnapshotResult> {
21-
const snapshots = await selectAccountSnapshots(accountId);
22-
const snapshotId = snapshots[0]?.snapshot_id;
21+
const snapshotId = await getValidSnapshotId(accountId);
2322

24-
const { sandbox, response } = await createSandbox(
25-
snapshotId ? { source: { type: "snapshot", snapshotId } } : {},
26-
);
23+
let sandbox: Sandbox;
24+
let fromSnapshot = false;
25+
26+
if (snapshotId) {
27+
try {
28+
const result = await createSandbox({
29+
source: { type: "snapshot", snapshotId },
30+
});
31+
sandbox = result.sandbox;
32+
fromSnapshot = true;
33+
} catch {
34+
const result = await createSandbox({});
35+
sandbox = result.sandbox;
36+
}
37+
} else {
38+
const result = await createSandbox({});
39+
sandbox = result.sandbox;
40+
}
2741

2842
await insertAccountSandbox({
2943
account_id: accountId,
30-
sandbox_id: response.sandboxId,
44+
sandbox_id: sandbox.sandboxId,
3145
});
3246

33-
return { sandbox, fromSnapshot: !!snapshotId };
47+
return { sandbox, fromSnapshot };
3448
}

lib/sandbox/getValidSnapshotId.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";
2+
3+
/**
4+
* Returns a valid (non-expired) snapshot ID for the account, or undefined.
5+
*
6+
* @param accountId - The account to look up
7+
* @returns The snapshot ID if it exists and has not expired
8+
*/
9+
export async function getValidSnapshotId(accountId: string): Promise<string | undefined> {
10+
const accountSnapshots = await selectAccountSnapshots(accountId);
11+
const snapshot = accountSnapshots[0];
12+
if (!snapshot?.snapshot_id) return undefined;
13+
14+
if (snapshot.expires_at && new Date(snapshot.expires_at) < new Date()) {
15+
return undefined;
16+
}
17+
18+
return snapshot.snapshot_id;
19+
}

lib/sandbox/processCreateSandbox.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createSandbox, type SandboxCreatedResponse } from "@/lib/sandbox/createSandbox";
2+
import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId";
23
import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox";
3-
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";
44
import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox";
55

66
type ProcessCreateSandboxInput = {
@@ -9,6 +9,24 @@ type ProcessCreateSandboxInput = {
99
};
1010
type ProcessCreateSandboxResult = SandboxCreatedResponse & { runId?: string };
1111

12+
/**
13+
* Attempts to create a sandbox from the given snapshot, falling back to a fresh sandbox on failure.
14+
* If no snapshotId is provided, creates a fresh sandbox directly.
15+
*
16+
* @param snapshotId - Optional snapshot ID to restore from
17+
* @returns The sandbox creation response
18+
*/
19+
async function createSandboxWithFallback(snapshotId: string | undefined) {
20+
if (snapshotId) {
21+
try {
22+
return (await createSandbox({ source: { type: "snapshot", snapshotId } })).response;
23+
} catch {
24+
// Snapshot invalid or expired on Vercel's side — fall through to fresh
25+
}
26+
}
27+
return (await createSandbox({})).response;
28+
}
29+
1230
/**
1331
* Shared domain logic for creating a sandbox and optionally running a prompt.
1432
* Used by both POST /api/sandboxes handler and the prompt_sandbox MCP tool.
@@ -21,14 +39,8 @@ export async function processCreateSandbox(
2139
): Promise<ProcessCreateSandboxResult> {
2240
const { accountId, prompt } = input;
2341

24-
// Get account's most recent snapshot if available
25-
const accountSnapshots = await selectAccountSnapshots(accountId);
26-
const snapshotId = accountSnapshots[0]?.snapshot_id;
27-
28-
// Create sandbox (from snapshot if valid, otherwise fresh)
29-
const { response: result } = await createSandbox(
30-
snapshotId ? { source: { type: "snapshot", snapshotId } } : {},
31-
);
42+
const snapshotId = await getValidSnapshotId(accountId);
43+
const result = await createSandboxWithFallback(snapshotId);
3244

3345
await insertAccountSandbox({
3446
account_id: accountId,

0 commit comments

Comments
 (0)