From ba3f98a7586b13dcde848a18db6fcde1254b697a Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:54:36 +0000 Subject: [PATCH 1/2] fix(eve): recover expired session sandboxes Signed-off-by: Colton Padden Co-Authored-By: Colton Padden Signed-off-by: Colton Padden --- .changeset/bright-sandboxes-recover.md | 5 + .../sandbox/bindings/vercel-errors.ts | 15 +++ .../sandbox/bindings/vercel-lookup.ts | 4 +- .../execution/sandbox/bindings/vercel.test.ts | 93 ++++++++++++++++++- .../src/execution/sandbox/bindings/vercel.ts | 68 ++++++++++---- 5 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 .changeset/bright-sandboxes-recover.md diff --git a/.changeset/bright-sandboxes-recover.md b/.changeset/bright-sandboxes-recover.md new file mode 100644 index 0000000000..dc397ebde7 --- /dev/null +++ b/.changeset/bright-sandboxes-recover.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Recreate persisted Vercel session sandboxes when their snapshots have expired instead of returning an unusable sandbox handle. diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-errors.ts b/packages/eve/src/execution/sandbox/bindings/vercel-errors.ts index 14cc7f7e09..1cafc493d1 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel-errors.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel-errors.ts @@ -12,6 +12,21 @@ export function isVercelSnapshotUnavailableError(error: unknown): boolean { return false; } +export function isVercelSnapshotNotFoundError(error: unknown): boolean { + for (const candidate of walkErrorChain(error)) { + const status = + (candidate as { response?: { status?: number } }).response?.status ?? + (candidate as { status?: number }).status ?? + (candidate as { statusCode?: number }).statusCode; + const code = (candidate as { json?: { error?: { code?: unknown } } }).json?.error?.code; + if (status === 410 && code === "snapshot_not_found") { + return true; + } + } + + return false; +} + export function isVercelSandboxMissingError(error: unknown): boolean { for (const candidate of walkErrorChain(error)) { const status = diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-lookup.ts b/packages/eve/src/execution/sandbox/bindings/vercel-lookup.ts index 98fbf8a096..be413ab3c7 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel-lookup.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel-lookup.ts @@ -11,6 +11,7 @@ import type { export async function getNamedVercelSandbox(input: { readonly createOptions: VercelCreateOptions; + readonly resume?: boolean; readonly sandboxModule: VercelModule; readonly sandboxName: string; }): Promise { @@ -32,12 +33,13 @@ export async function getNamedVercelSandbox(input: { async function getVercelSandboxGetOptions(input: { readonly createOptions: VercelCreateOptions; + readonly resume?: boolean; readonly sandboxName: string; }): Promise { const baseOptions = { fetch: getVercelSandboxFetch(input.createOptions), name: input.sandboxName, - resume: false, + resume: input.resume ?? false, }; try { diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts index cab2e0fe45..4430f9f5ab 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts @@ -645,7 +645,7 @@ describe("createVercelSandbox", () => { expect(get).toHaveBeenCalledWith({ fetch: expect.any(Function), name: "session-key", - resume: false, + resume: true, }); expect(create).toHaveBeenCalledTimes(1); expect(create.mock.calls[0]?.[0]).toMatchObject({ @@ -1067,7 +1067,7 @@ describe("createVercelSandbox", () => { expect(sandboxModule.Sandbox.get).toHaveBeenCalledWith({ fetch: expect.any(Function), name: "persisted-sandbox-name", - resume: false, + resume: true, }); expect(handle.session).toBeDefined(); @@ -1075,6 +1075,93 @@ describe("createVercelSandbox", () => { expect(state.metadata).toEqual({ sandboxName: "persisted-sandbox-name" }); }); + it("replaces a session sandbox when its persisted snapshot is unavailable", async () => { + const templateSandbox = createMockSandbox({ + name: "template-key", + snapshotId: "template-snapshot", + }); + const staleSessionSandbox = createMockSandbox({ name: "persisted-sandbox-name" }); + const replacementSessionSandbox = createMockSandbox({ name: "persisted-sandbox-name" }); + const snapshotUnavailable = Object.assign(new Error("snapshot_not_found"), { + json: { error: { code: "snapshot_not_found" } }, + response: { status: 410 }, + }); + const create = vi.fn().mockResolvedValue(replacementSessionSandbox); + const get = vi + .fn() + .mockImplementation(async ({ name, resume }: { name: string; resume: boolean }) => { + if (name === "template-key") return templateSandbox; + if (name === "persisted-sandbox-name" && resume) throw snapshotUnavailable; + if (name === "persisted-sandbox-name") return staleSessionSandbox; + return null; + }); + const sandboxModule = { Sandbox: { create, get } }; + + const backend = createTestVercelSandbox({ + loadSandboxModule: async () => sandboxModule as never, + }); + + await backend.prewarm({ + runtimeContext: { appRoot: "/tmp/test-app-root" }, + seedFiles: [], + templateKey: "template-key", + }); + + const handle = await backend.create({ + existingMetadata: { sandboxName: "persisted-sandbox-name" }, + runtimeContext: { appRoot: "/tmp/test-app-root" }, + sessionKey: "session-key", + templateKey: "template-key", + }); + + expect(staleSessionSandbox.delete).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "persisted-sandbox-name", + persistent: true, + source: { snapshotId: "template-snapshot", type: "snapshot" }, + }), + ); + expect((await handle.captureState()).metadata).toEqual({ + sandboxName: "persisted-sandbox-name", + }); + }); + + it("does not replace a session sandbox for an unrelated 410 response", async () => { + const templateSandbox = createMockSandbox({ + name: "template-key", + snapshotId: "template-snapshot", + }); + const unrelatedGone = Object.assign(new Error("resource gone"), { + json: { error: { code: "resource_gone" } }, + response: { status: 410 }, + }); + const create = vi.fn(); + const get = vi.fn().mockImplementation(async ({ name }: { name: string }) => { + if (name === "template-key") return templateSandbox; + throw unrelatedGone; + }); + const backend = createTestVercelSandbox({ + loadSandboxModule: async () => ({ Sandbox: { create, get } }) as never, + }); + + await backend.prewarm({ + runtimeContext: { appRoot: "/tmp/test-app-root" }, + seedFiles: [], + templateKey: "template-key", + }); + + await expect( + backend.create({ + existingMetadata: { sandboxName: "persisted-sandbox-name" }, + runtimeContext: { appRoot: "/tmp/test-app-root" }, + sessionKey: "session-key", + templateKey: "template-key", + }), + ).rejects.toThrow("Failed to create sandbox session"); + expect(create).not.toHaveBeenCalled(); + }); + it("stops the session sandbox on shutdown so no VM outlives the server", async () => { const { handle, sessionSandbox } = await createTestVercelSession(); @@ -1173,7 +1260,7 @@ describe("createVercelSandbox", () => { expect(sandboxModule.Sandbox.get).toHaveBeenCalledWith({ fetch: expect.any(Function), name: "deleted-sandbox", - resume: false, + resume: true, }); expect(sandboxModule.Sandbox.create).toHaveBeenCalledTimes(1); expect(sandboxModule.Sandbox.create).toHaveBeenCalledWith( diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.ts b/packages/eve/src/execution/sandbox/bindings/vercel.ts index 2ce7c5e653..55b23febed 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.ts @@ -41,6 +41,7 @@ import { } from "#execution/sandbox/bindings/vercel-create-sdk.js"; import { isVercelSandboxMissingError, + isVercelSnapshotNotFoundError, isVercelSnapshotUnavailableError, } from "#execution/sandbox/bindings/vercel-errors.js"; import { getNamedVercelSandbox } from "#execution/sandbox/bindings/vercel-lookup.js"; @@ -114,10 +115,7 @@ export function createVercelSandbox( tags, }); } catch (error) { - if ( - template !== null && - (isVercelSnapshotUnavailableError(error) || isVercelSandboxMissingError(error)) - ) { + if (template !== null && VercelTemplateSnapshotUnavailableError.is(error)) { prewarmedTemplates.delete(template.templateKey); const staleTemplate = await getNamedVercelSandbox({ createOptions, @@ -377,13 +375,41 @@ interface VercelSandboxSessionCreateResult { readonly sandbox: VercelSandbox; } +class VercelTemplateSnapshotUnavailableError extends Error { + static is(error: unknown): error is VercelTemplateSnapshotUnavailableError { + return error instanceof VercelTemplateSnapshotUnavailableError; + } +} + async function ensureSession(input: EnsureSessionInput): Promise { const sandboxName = getVercelSandboxName(input.existingMetadata) ?? input.sessionKey; - const existing = await getNamedVercelSandbox({ - createOptions: input.createOptions, - sandboxModule: input.sandboxModule, - sandboxName, - }); + let existing: VercelSandbox | null; + try { + existing = await getNamedVercelSandbox({ + createOptions: input.createOptions, + resume: true, + sandboxModule: input.sandboxModule, + sandboxName, + }); + } catch (error) { + if (!isVercelSnapshotNotFoundError(error)) { + throw error; + } + + const stale = await getNamedVercelSandbox({ + createOptions: input.createOptions, + sandboxModule: input.sandboxModule, + sandboxName, + }); + try { + await stale?.delete(); + } catch (deleteError) { + if (!isVercelSandboxMissingError(deleteError)) { + throw deleteError; + } + } + existing = null; + } if (existing !== null) { await ensureVercelSandboxTags(existing, input.tags); @@ -398,13 +424,23 @@ async function ensureSession(input: EnsureSessionInput): Promise Date: Fri, 21 Aug 2026 11:36:38 -0400 Subject: [PATCH 2/2] refactor(eve): extract Vercel session lifecycle and log snapshot recovery Move ensureSession, the template-snapshot error marker, session create-params, and the shared tag/network-policy helpers into vercel-session.ts, bringing vercel.ts back under the 700-line structural cap. Log a warning when an expired-snapshot recovery deletes and recreates a persisted session sandbox, and give the marker error a fixed message. Signed-off-by: Colton Padden --- .../sandbox/bindings/vercel-session.ts | 204 ++++++++++++++++++ .../src/execution/sandbox/bindings/vercel.ts | 174 +-------------- 2 files changed, 213 insertions(+), 165 deletions(-) create mode 100644 packages/eve/src/execution/sandbox/bindings/vercel-session.ts diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-session.ts b/packages/eve/src/execution/sandbox/bindings/vercel-session.ts new file mode 100644 index 0000000000..e39450a57e --- /dev/null +++ b/packages/eve/src/execution/sandbox/bindings/vercel-session.ts @@ -0,0 +1,204 @@ +import { createLogger } from "#internal/logging.js"; +import type { + VercelSandboxSessionCreateContext, + VercelSandboxSessionCreateOptions, +} from "#public/sandbox/vercel-sandbox.js"; +import type { + CreateVercelSandbox, + VercelSandboxCreateParams, +} from "#execution/sandbox/bindings/vercel-create-sdk.js"; +import { + isVercelSandboxMissingError, + isVercelSnapshotNotFoundError, + isVercelSnapshotUnavailableError, +} from "#execution/sandbox/bindings/vercel-errors.js"; +import { getNamedVercelSandbox } from "#execution/sandbox/bindings/vercel-lookup.js"; +import type { + VercelCreateOptions, + VercelModule, + VercelSandbox, +} from "#execution/sandbox/bindings/vercel-sdk-types.js"; + +const logger = createLogger("sandbox.vercel"); + +export type ResolveVercelSessionCreateOptions = ( + context: VercelSandboxSessionCreateContext, +) => Promise | VercelSandboxSessionCreateOptions; + +export interface EnsureSessionInput { + readonly createOptions: VercelCreateOptions; + readonly createSandbox: CreateVercelSandbox; + readonly existingMetadata?: Record; + readonly resolveSessionCreateOptions?: ResolveVercelSessionCreateOptions; + readonly sandboxModule: VercelModule; + readonly sessionId: string; + readonly sessionKey: string; + readonly snapshotId?: string; + readonly tags: Record | undefined; +} + +export interface VercelSandboxSessionCreateResult { + readonly created: boolean; + readonly sandbox: VercelSandbox; +} + +export class VercelTemplateSnapshotUnavailableError extends Error { + constructor(options?: ErrorOptions) { + super("template snapshot unavailable during session create", options); + } + + static is(error: unknown): error is VercelTemplateSnapshotUnavailableError { + return error instanceof VercelTemplateSnapshotUnavailableError; + } +} + +export async function ensureSession( + input: EnsureSessionInput, +): Promise { + const sandboxName = getVercelSandboxName(input.existingMetadata) ?? input.sessionKey; + let existing: VercelSandbox | null; + try { + existing = await getNamedVercelSandbox({ + createOptions: input.createOptions, + resume: true, + sandboxModule: input.sandboxModule, + sandboxName, + }); + } catch (error) { + if (!isVercelSnapshotNotFoundError(error)) { + throw error; + } + + // The backing snapshot expired, so the persisted filesystem is already + // unrecoverable. Delete-then-recreate under the same name mirrors the + // SDK's own `Sandbox.getOrCreate` recovery; concurrent creates for one + // session key share its race window and surface a name conflict to the + // loser. + logger.warn("session sandbox snapshot expired; deleting it and creating a replacement", { + sandboxName, + }); + const stale = await getNamedVercelSandbox({ + createOptions: input.createOptions, + sandboxModule: input.sandboxModule, + sandboxName, + }); + try { + await stale?.delete(); + } catch (deleteError) { + if (!isVercelSandboxMissingError(deleteError)) { + throw deleteError; + } + } + existing = null; + } + + if (existing !== null) { + await ensureVercelSandboxTags(existing, input.tags); + return { created: false, sandbox: existing }; + } + + const sessionCreateOptions = await input.resolveSessionCreateOptions?.({ + session: { id: input.sessionId }, + }); + const createParams = createSessionCreateParams(input, sandboxName, sessionCreateOptions); + if (input.tags !== undefined) { + createParams.tags = input.tags; + } + + try { + return { + created: true, + sandbox: await input.createSandbox({ + createOptions: createParams, + sandboxModule: input.sandboxModule, + }), + }; + } catch (error) { + if ( + input.snapshotId !== undefined && + (isVercelSnapshotUnavailableError(error) || isVercelSandboxMissingError(error)) + ) { + throw new VercelTemplateSnapshotUnavailableError({ cause: error }); + } + throw error; + } +} + +function createSessionCreateParams( + input: EnsureSessionInput, + sandboxName: string, + sessionCreateOptions: VercelSandboxSessionCreateOptions = {}, +): VercelSandboxCreateParams { + const createOptions = { ...input.createOptions, ...sessionCreateOptions } as VercelCreateOptions; + if (input.snapshotId === undefined) { + return withBaseSetupNetworkPolicy({ + ...createOptions, + name: sandboxName, + persistent: true, + }); + } + + /* + * Strip `source`, `runtime`, and `image` from author-supplied create options + * for the template-backed session path. The framework owns the source there, + * and a snapshot source is mutually exclusive with both `runtime` and `image` + * (the template snapshot already has the eve image baked in). + */ + const { + image: _image, + runtime: _runtime, + source: _source, + ...baseSessionCreateOptions + } = createOptions as VercelCreateOptions & + Partial>; + + return { + ...baseSessionCreateOptions, + name: sandboxName, + persistent: true, + source: { snapshotId: input.snapshotId, type: "snapshot" as const }, + }; +} + +function getVercelSandboxName(metadata: Record | undefined): string | undefined { + const sandboxName = metadata?.sandboxName; + return typeof sandboxName === "string" ? sandboxName : undefined; +} + +/* + * Shared with the template lifecycle in vercel.ts: templates and sessions + * apply the same tag reconciliation, and both fresh-create paths start + * permissive so framework base setup runs before the author's network + * policy applies. + */ +export function withBaseSetupNetworkPolicy( + createOptions: VercelSandboxCreateParams, +): VercelSandboxCreateParams { + return { ...createOptions, networkPolicy: "allow-all" }; +} + +export async function ensureVercelSandboxTags( + sandbox: VercelSandbox, + tags: Record | undefined, +): Promise { + if (tags === undefined || areVercelSandboxTagsEqual(sandbox.tags, tags)) { + return; + } + + await sandbox.update({ tags }); +} + +function areVercelSandboxTagsEqual( + current: Record | undefined, + next: Record, +): boolean { + const currentTags = current ?? {}; + const currentEntries = Object.entries(currentTags); + const nextEntries = Object.entries(next); + + if (currentEntries.length !== nextEntries.length) { + return false; + } + + return nextEntries.every(([key, value]) => currentTags[key] === value); +} diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.ts b/packages/eve/src/execution/sandbox/bindings/vercel.ts index 55b23febed..ef2b3efb2e 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.ts @@ -25,8 +25,6 @@ import type { import { SandboxTemplateNotProvisionedError } from "#public/definitions/sandbox-backend.js"; import type { VercelSandboxBootstrapUseOptions, - VercelSandboxSessionCreateContext, - VercelSandboxSessionCreateOptions, VercelSandboxSessionUseOptions, } from "#public/sandbox/vercel-sandbox.js"; import { WORKSPACE_ROOT } from "#runtime/workspace/types.js"; @@ -37,14 +35,20 @@ import { streamToBuffer } from "#execution/sandbox/stream-utils.js"; import { createVercelEveImageSandbox, type CreateVercelSandbox, - type VercelSandboxCreateParams, } from "#execution/sandbox/bindings/vercel-create-sdk.js"; import { isVercelSandboxMissingError, - isVercelSnapshotNotFoundError, isVercelSnapshotUnavailableError, } from "#execution/sandbox/bindings/vercel-errors.js"; import { getNamedVercelSandbox } from "#execution/sandbox/bindings/vercel-lookup.js"; +import { + ensureSession, + ensureVercelSandboxTags, + VercelTemplateSnapshotUnavailableError, + withBaseSetupNetworkPolicy, + type ResolveVercelSessionCreateOptions, + type VercelSandboxSessionCreateResult, +} from "#execution/sandbox/bindings/vercel-session.js"; import { normalizeVercelReadStream } from "#execution/sandbox/bindings/vercel-read-stream.js"; import { resolveSandboxModelPath } from "#shared/skill-paths.js"; import type { @@ -57,9 +61,7 @@ export interface CreateVercelSandboxInput { readonly createSandbox?: CreateVercelSandbox; readonly createOptions?: VercelCreateOptions; readonly loadSandboxModule?: () => Promise; - readonly resolveSessionCreateOptions?: ( - context: VercelSandboxSessionCreateContext, - ) => Promise | VercelSandboxSessionCreateOptions; + readonly resolveSessionCreateOptions?: ResolveVercelSessionCreateOptions; } /** * Creates the Vercel-backed sandbox backend. @@ -358,133 +360,6 @@ async function ensureTemplate(input: EnsureTemplateInput): Promise; - readonly resolveSessionCreateOptions?: CreateVercelSandboxInput["resolveSessionCreateOptions"]; - readonly sandboxModule: VercelModule; - readonly sessionId: string; - readonly sessionKey: string; - readonly snapshotId?: string; - readonly tags: Record | undefined; -} - -interface VercelSandboxSessionCreateResult { - readonly created: boolean; - readonly sandbox: VercelSandbox; -} - -class VercelTemplateSnapshotUnavailableError extends Error { - static is(error: unknown): error is VercelTemplateSnapshotUnavailableError { - return error instanceof VercelTemplateSnapshotUnavailableError; - } -} - -async function ensureSession(input: EnsureSessionInput): Promise { - const sandboxName = getVercelSandboxName(input.existingMetadata) ?? input.sessionKey; - let existing: VercelSandbox | null; - try { - existing = await getNamedVercelSandbox({ - createOptions: input.createOptions, - resume: true, - sandboxModule: input.sandboxModule, - sandboxName, - }); - } catch (error) { - if (!isVercelSnapshotNotFoundError(error)) { - throw error; - } - - const stale = await getNamedVercelSandbox({ - createOptions: input.createOptions, - sandboxModule: input.sandboxModule, - sandboxName, - }); - try { - await stale?.delete(); - } catch (deleteError) { - if (!isVercelSandboxMissingError(deleteError)) { - throw deleteError; - } - } - existing = null; - } - - if (existing !== null) { - await ensureVercelSandboxTags(existing, input.tags); - return { created: false, sandbox: existing }; - } - - const sessionCreateOptions = await input.resolveSessionCreateOptions?.({ - session: { id: input.sessionId }, - }); - const createParams = createSessionCreateParams(input, sandboxName, sessionCreateOptions); - if (input.tags !== undefined) { - createParams.tags = input.tags; - } - - try { - return { - created: true, - sandbox: await input.createSandbox({ - createOptions: createParams, - sandboxModule: input.sandboxModule, - }), - }; - } catch (error) { - if ( - input.snapshotId !== undefined && - (isVercelSnapshotUnavailableError(error) || isVercelSandboxMissingError(error)) - ) { - throw new VercelTemplateSnapshotUnavailableError(undefined, { cause: error }); - } - throw error; - } -} - -function createSessionCreateParams( - input: EnsureSessionInput, - sandboxName: string, - sessionCreateOptions: VercelSandboxSessionCreateOptions = {}, -): VercelSandboxCreateParams { - const createOptions = { ...input.createOptions, ...sessionCreateOptions } as VercelCreateOptions; - if (input.snapshotId === undefined) { - return withBaseSetupNetworkPolicy({ - ...createOptions, - name: sandboxName, - persistent: true, - }); - } - - /* - * Strip `source`, `runtime`, and `image` from author-supplied create options - * for the template-backed session path. The framework owns the source there, - * and a snapshot source is mutually exclusive with both `runtime` and `image` - * (the template snapshot already has the eve image baked in). - */ - const { - image: _image, - runtime: _runtime, - source: _source, - ...baseSessionCreateOptions - } = createOptions as VercelCreateOptions & - Partial>; - - return { - ...baseSessionCreateOptions, - name: sandboxName, - persistent: true, - source: { snapshotId: input.snapshotId, type: "snapshot" as const }, - }; -} - -function withBaseSetupNetworkPolicy( - createOptions: VercelSandboxCreateParams, -): VercelSandboxCreateParams { - return { ...createOptions, networkPolicy: "allow-all" }; -} - function createHandle( sandbox: VercelSandbox, sessionKey: string, @@ -637,11 +512,6 @@ function extractAuthorSnapshotId(createOptions: VercelCreateOptions): string | u return undefined; } -function getVercelSandboxName(metadata: Record | undefined): string | undefined { - const sandboxName = metadata?.sandboxName; - return typeof sandboxName === "string" ? sandboxName : undefined; -} - function resolveVercelSandboxTags( userTags: VercelCreateOptions["tags"], eveTags: SandboxBackendTags | undefined, @@ -675,32 +545,6 @@ function resolveVercelSandboxTags( return tags; } -async function ensureVercelSandboxTags( - sandbox: VercelSandbox, - tags: Record | undefined, -): Promise { - if (tags === undefined || areVercelSandboxTagsEqual(sandbox.tags, tags)) { - return; - } - - await sandbox.update({ tags }); -} - -function areVercelSandboxTagsEqual( - current: Record | undefined, - next: Record, -): boolean { - const currentTags = current ?? {}; - const currentEntries = Object.entries(currentTags); - const nextEntries = Object.entries(next); - - if (currentEntries.length !== nextEntries.length) { - return false; - } - - return nextEntries.every(([key, value]) => currentTags[key] === value); -} - function errorMessage(error: unknown): string { if (error instanceof Error) { const responseJson = (error as { readonly json?: unknown }).json;