diff --git a/.changeset/preserve-convex-error-data.md b/.changeset/preserve-convex-error-data.md new file mode 100644 index 00000000..ba44bd18 --- /dev/null +++ b/.changeset/preserve-convex-error-data.md @@ -0,0 +1,8 @@ +--- +"@convex-dev/workflow": minor +--- + +Preserve `ConvexError` identity and structured error data when catching failed +steps, including inline calls, queued functions, nested workflows, and journal +replay. Raises the `@convex-dev/workpool` peer minimum to 0.4.16. Oversized +failures are replaced with a bounded diagnostic so they can still be journaled. diff --git a/README.md b/README.md index 87022e1b..0099c1bf 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,17 @@ that the "myAction" step is considered "in progress" while it is waiting, and it only enqueues one item into the Workpool (myAction@delay), instead of two (sleep@delay, myAction@now). +### Catching step errors + +When a step throws a `ConvexError`, catching it in the workflow preserves +`error instanceof ConvexError` and the original `error.data`. This applies to +queries, mutations, actions, and nested workflows, including journal replay. +Other failures throw an `Error` with the recorded error message. + +Failures larger than 800 KiB, counting the message, stack, and structured data +together, are replaced with an `Error` containing a bounded diagnostic preview. +The oversized structured data is omitted so the failure can be journaled. + ### Specifying retry behavior Sometimes actions fail due to transient errors, whether it was an unreliable diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 91b96422..61ce7af4 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -17,6 +17,7 @@ import type * as oversized from "../oversized.js"; import type * as passingSignals from "../passingSignals.js"; import type * as test_contextRoundtrip from "../test/contextRoundtrip.js"; import type * as test_fanIn from "../test/fanIn.js"; +import type * as test_convexErrors from "../test/convexErrors.js"; import type * as test_inline from "../test/inline.js"; import type * as test_oldSyntax from "../test/oldSyntax.js"; import type * as transcription from "../transcription.js"; @@ -38,6 +39,7 @@ declare const fullApi: ApiFromModules<{ passingSignals: typeof passingSignals; "test/contextRoundtrip": typeof test_contextRoundtrip; "test/fanIn": typeof test_fanIn; + "test/convexErrors": typeof test_convexErrors; "test/inline": typeof test_inline; "test/oldSyntax": typeof test_oldSyntax; transcription: typeof transcription; diff --git a/example/convex/test/convexErrors.test.ts b/example/convex/test/convexErrors.test.ts new file mode 100644 index 00000000..16a78240 --- /dev/null +++ b/example/convex/test/convexErrors.test.ts @@ -0,0 +1,95 @@ +import { getStatus, WorkflowManager } from "@convex-dev/workflow"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { components, internal } from "../_generated/api.js"; +import { initConvexTest } from "../setup.test.js"; +import { getConvexSize, type Value } from "convex/values"; + +const workflow = new WorkflowManager(components.workflow); + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +test.each([ + { functionType: "query" as const, inline: true }, + { functionType: "mutation" as const, inline: true }, + { functionType: "query" as const, inline: false }, + { functionType: "mutation" as const, inline: false }, + { functionType: "action" as const, inline: false }, +])( + "preserves ConvexError from $functionType (inline: $inline) through replay", + async (options) => { + const t = initConvexTest(); + const data = { + code: "INVALID_INPUT", + message: "Please try again", + count: 7, + }; + const workflowId = await t.mutation((ctx) => + workflow.start(ctx, internal.test.convexErrors.catchingWorkflow, { + ...options, + data, + }), + ); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const status = await t.query((ctx) => + getStatus(ctx, components.workflow, workflowId), + ); + expect(status).toEqual({ + type: "completed", + result: { isConvexError: true, data }, + }); + }, +); + +test("preserves an uncaught ConvexError from a nested workflow", async () => { + const t = initConvexTest(); + const data = { code: "CHILD_FAILED", message: "Please try again" }; + const workflowId = await t.mutation((ctx) => + workflow.start(ctx, internal.test.convexErrors.catchingNestedWorkflow, { + data, + }), + ); + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect( + await t.query((ctx) => getStatus(ctx, components.workflow, workflowId)), + ).toEqual({ + type: "completed", + result: { isConvexError: true, data }, + }); +}); + +test.each(["query", "mutation"] as const)( + "oversized inline %s failures remain catchable through replay", + async (functionType) => { + const t = initConvexTest(); + const workflowId = await t.mutation((ctx) => + workflow.start(ctx, internal.test.convexErrors.catchingOversizedError, { + functionType, + }), + ); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const status = await t.query((ctx) => + getStatus(ctx, components.workflow, workflowId), + ); + expect(status.type).toBe("completed"); + if (status.type !== "completed") + throw new Error("Expected recovery to complete"); + expect(status.result).toMatchObject({ + isConvexError: false, + data: expect.stringContaining("Step failure too large"), + }); + expect(getConvexSize(status.result as Value)).toBeLessThan(100 << 10); + const steps = await t.query((ctx) => + workflow.listSteps(ctx, workflowId, { + paginationOpts: { numItems: 10, cursor: null }, + }), + ); + expect(steps.page).toHaveLength(2); + const failure = steps.page[0].runResult; + expect(failure).toMatchObject({ kind: "failed" }); + if (failure?.kind !== "failed") + throw new Error("Expected a recorded failure"); + expect(failure).not.toHaveProperty("errorData"); + expect(getConvexSize(failure)).toBeLessThan(100 << 10); + }, +); diff --git a/example/convex/test/convexErrors.ts b/example/convex/test/convexErrors.ts new file mode 100644 index 00000000..540343d8 --- /dev/null +++ b/example/convex/test/convexErrors.ts @@ -0,0 +1,156 @@ +import { WorkflowManager } from "@convex-dev/workflow"; +import { ConvexError, v, type Value } from "convex/values"; +import { components, internal } from "../_generated/api.js"; +import { + internalAction, + internalMutation, + internalQuery, +} from "../_generated/server.js"; + +const workflow = new WorkflowManager(components.workflow); +const args = { data: v.any() }; +const fail = (_ctx: unknown, { data }: { data: Value }): never => { + throw new ConvexError(data); +}; + +export const failingQuery = internalQuery({ + args, + returns: v.null(), + handler: fail, +}); +export const failingMutation = internalMutation({ + args, + returns: v.null(), + handler: fail, +}); +export const failingAction = internalAction({ + args, + returns: v.null(), + handler: fail, +}); + +export const oversizedQuery = internalQuery({ + args: {}, + returns: v.null(), + handler: (ctx) => fail(ctx, { data: "x".repeat(900_000) }), +}); + +export const oversizedMutation = internalMutation({ + args: {}, + returns: v.null(), + handler: (ctx) => fail(ctx, { data: "x".repeat(900_000) }), +}); + +export const catchingOversizedError = workflow + .define({ + args: { functionType: v.union(v.literal("query"), v.literal("mutation")) }, + returns: v.object({ isConvexError: v.boolean(), data: v.any() }), + }) + .handler(async (step, { functionType }) => { + try { + if (functionType === "query") { + await step.runQuery( + internal.test.convexErrors.oversizedQuery, + {}, + { inline: true }, + ); + } else { + await step.runMutation( + internal.test.convexErrors.oversizedMutation, + {}, + { inline: true }, + ); + } + } catch (error) { + // Journal these caught values in another step so replay must agree. + return await step.runAction(internal.test.convexErrors.echo, { + isConvexError: error instanceof ConvexError, + data: error instanceof Error ? error.message : String(error), + }); + } + throw new Error("Expected an oversized error"); + }); + +export const echo = internalAction({ + args: { isConvexError: v.boolean(), data: v.any() }, + returns: v.object({ isConvexError: v.boolean(), data: v.any() }), + handler: async (_ctx, args) => args, +}); + +export const failingWorkflow = workflow + .define({ args, returns: v.null() }) + .handler(async (_step, { data }) => { + throw new ConvexError(data); + }); + +export const catchingNestedWorkflow = workflow + .define({ + args, + returns: v.object({ isConvexError: v.boolean(), data: v.any() }), + }) + .handler(async (step, { data }) => { + try { + await step.runWorkflow(internal.test.convexErrors.failingWorkflow, { + data, + }); + } catch (error) { + return await step.runAction(internal.test.convexErrors.echo, { + isConvexError: error instanceof ConvexError, + data: error instanceof ConvexError ? error.data : null, + }); + } + throw new Error("Expected the child workflow to fail"); + }); + +export const catchingWorkflow = workflow + .define({ + args: { + data: v.any(), + functionType: v.union( + v.literal("query"), + v.literal("mutation"), + v.literal("action"), + ), + inline: v.boolean(), + }, + returns: v.object({ isConvexError: v.boolean(), data: v.any() }), + }) + .handler(async (step, { data, functionType, inline }) => { + let caught: { isConvexError: boolean; data: Value } = { + isConvexError: false, + data: null, + }; + try { + switch (functionType) { + case "query": + await step.runQuery( + internal.test.convexErrors.failingQuery, + { data }, + { inline }, + ); + break; + case "mutation": + await step.runMutation( + internal.test.convexErrors.failingMutation, + { data }, + { inline }, + ); + break; + case "action": + await step.runAction( + internal.test.convexErrors.failingAction, + { data }, + { retry: false }, + ); + break; + } + } catch (error) { + caught = { + isConvexError: error instanceof ConvexError, + data: error instanceof ConvexError ? error.data : null, + }; + } + // A queued recovery step forces replay of the failed step and validates + // that the caught data stays the same when used in subsequent step args. + return await step.runAction(internal.test.convexErrors.echo, caught); + }); diff --git a/package-lock.json b/package-lock.json index a74f43b6..b8480c4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "devDependencies": { "@changesets/cli": "3.0.3", "@convex-dev/eslint-plugin": "5.0.1", - "@convex-dev/workpool": "0.4.15", + "@convex-dev/workpool": "0.4.16", "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "3.3.7", "@eslint/js": "10.0.1", @@ -40,7 +40,7 @@ "vitest": "5.1.0" }, "peerDependencies": { - "@convex-dev/workpool": "^0.4.4", + "@convex-dev/workpool": "^0.4.16", "convex": "^1.46.0", "convex-helpers": "^0.1.127" } @@ -838,9 +838,9 @@ } }, "node_modules/@convex-dev/workpool": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/@convex-dev/workpool/-/workpool-0.4.15.tgz", - "integrity": "sha512-eweNWj3nHnUpwT40S5qwtA/d9LbuVMXlBP6GqM5tE4+eAOkzWbudiiCz9bi1bBpFSjtnbdRfQ+PoBG/STqgj8A==", + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@convex-dev/workpool/-/workpool-0.4.16.tgz", + "integrity": "sha512-QtR9lEidys0yGtjlbtJzhcC6RGxs4uICnewWZmiolDnUlX4Y8z5v4hGwDzON3TRDIzL3Pd/WPY/jXRKZnQW4Bg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5468,18 +5468,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/read-package-json-fast": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-6.0.0.tgz", diff --git a/package.json b/package.json index 635ec6ff..68b5edef 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ } }, "peerDependencies": { - "@convex-dev/workpool": "^0.4.4", + "@convex-dev/workpool": "^0.4.16", "convex": "^1.46.0", "convex-helpers": "^0.1.127" }, @@ -68,7 +68,7 @@ "devDependencies": { "@changesets/cli": "3.0.3", "@convex-dev/eslint-plugin": "5.0.1", - "@convex-dev/workpool": "0.4.15", + "@convex-dev/workpool": "0.4.16", "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "3.3.7", "@eslint/js": "10.0.1", diff --git a/src/client/step.ts b/src/client/step.ts index 2897e790..80b0eb14 100644 --- a/src/client/step.ts +++ b/src/client/step.ts @@ -17,7 +17,7 @@ import type { TransactionLimits, WorkflowComponent, } from "./types.js"; -import { MAX_JOURNAL_SIZE, formatErrorWithStack } from "../shared.js"; +import { MAX_JOURNAL_SIZE, failedResult } from "../shared.js"; import type { EventId, SchedulerOptions } from "../types.js"; import { pick } from "convex-helpers"; @@ -190,7 +190,7 @@ export class StepExecutor { ); runResult = { kind: "success", returnValue: result ?? null }; } catch (error: unknown) { - runResult = { kind: "failed", error: formatErrorWithStack(error) }; + runResult = failedResult(error); } } diff --git a/src/client/stepContext.test.ts b/src/client/stepContext.test.ts index f2c92617..00d86e54 100644 --- a/src/client/stepContext.test.ts +++ b/src/client/stepContext.test.ts @@ -8,6 +8,7 @@ import { createWorkflowCtx } from "./workflowContext.js"; import type { WorkflowId } from "../types.js"; import { anyApi, type FunctionReference } from "convex/server"; import { initConvexTest } from "./setup.test.js"; +import { ConvexError } from "convex/values"; // Fake function reference that satisfies the type constraints. function fakeFuncRef(name: string) { @@ -125,9 +126,37 @@ describe("StepExecutor + WorkflowCtx integration", () => { ]); expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(ConvexError); expect((error as Error).message).toBe("something broke"); }); + it.each([ + null, + false, + 0, + "", + "invalid input", + { code: "INVALID", count: 7n }, + new Uint8Array([1, 2]).buffer, + ])("restores ConvexError data from a journaled failure: %s", async (data) => { + const channel = new BaseChannel(0); + const ctx = createWorkflowCtx("wf-error" as WorkflowId, channel); + const entry = journalEntry({ + runResult: { + kind: "failed", + error: "error with server stack", + errorData: data, + }, + }); + const [error] = await Promise.all([ + ctx.runAction(fakeFuncRef("test"), {}).catch((error: unknown) => error), + replayFromJournal(channel, [entry]), + ]); + expect(error).toBeInstanceOf(ConvexError); + expect((error as ConvexError).data).toEqual(data); + expect((error as Error).message).toBe(new ConvexError(data).message); + }); + it("throws on a canceled step", async () => { const channel = new BaseChannel(0); const ctx = createWorkflowCtx("wf-3" as any, channel); diff --git a/src/client/types.ts b/src/client/types.ts index bb069939..f37fec6b 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -17,7 +17,7 @@ export type InferFromOptionalValidator = [ export type RunResult = | { kind: "success"; returnValue: Returns } - | { kind: "failed"; error: string } + | { kind: "failed"; error: string; errorData?: Value } | { kind: "canceled" }; /** diff --git a/src/client/workflowContext.ts b/src/client/workflowContext.ts index 01a8046c..21a27e63 100644 --- a/src/client/workflowContext.ts +++ b/src/client/workflowContext.ts @@ -10,7 +10,7 @@ import type { FunctionType, FunctionVisibility, } from "convex/server"; -import type { Validator } from "convex/values"; +import { ConvexError, type Validator } from "convex/values"; import type { EventId, SchedulerOptions, WorkflowId } from "../types.js"; import { safeFunctionName } from "./safeFunctionName.js"; import type { StepRequest } from "./step.js"; @@ -330,6 +330,9 @@ async function run( case "success": return result.returnValue; case "failed": + if (result.errorData !== undefined) { + throw new ConvexError(result.errorData); + } throw new Error(result.error); case "canceled": throw new Error("Canceled"); diff --git a/src/client/workflowMutation.ts b/src/client/workflowMutation.ts index 445736b4..73e1c86c 100644 --- a/src/client/workflowMutation.ts +++ b/src/client/workflowMutation.ts @@ -18,7 +18,7 @@ import { } from "convex/values"; import { createLogger } from "../component/logging.js"; import { type JournalEntry } from "../component/schema.js"; -import { formatErrorWithStack } from "../shared.js"; +import { failedResult, formatErrorWithStack } from "../shared.js"; import { vWorkflowId, type OnCompleteArgs, type WorkflowId } from "../types.js"; import { setupEnvironment } from "./environment.js"; import type { WorkflowDefinition, WorkflowHandler } from "./index.js"; @@ -84,6 +84,7 @@ const vRunResult = ( v.object({ kind: v.literal("failed"), error: v.string(), + errorData: v.optional(v.any()), }), v.object({ kind: v.literal("canceled") }), ); @@ -280,9 +281,8 @@ export function workflowMutation< } } } catch (error) { - const message = formatErrorWithStack(error); - console.error(message); - runResult = { kind: "failed", error: message }; + runResult = failedResult(error); + console.error(runResult.error); } return { type: "handlerDone", runResult }; }; diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index 3a52bea4..65b8e86a 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -39,7 +39,7 @@ export type ComponentApi = name?: string; result: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; workflowId?: string; workpoolOptions?: { @@ -79,7 +79,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workId?: string; @@ -94,7 +94,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workflowId?: string; @@ -109,7 +109,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; } @@ -122,7 +122,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workId?: string; @@ -142,7 +142,7 @@ export type ComponentApi = onComplete?: { context?: any; fnHandle: string }; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt?: any; state?: any; @@ -173,7 +173,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workId?: string; @@ -188,7 +188,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workflowId?: string; @@ -203,7 +203,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; } @@ -216,7 +216,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workId?: string; @@ -249,7 +249,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workId?: string; @@ -264,7 +264,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workflowId?: string; @@ -279,7 +279,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; } @@ -292,7 +292,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workId?: string; @@ -325,7 +325,7 @@ export type ComponentApi = generationNumber: number; runResult: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; workflowId: string; }, @@ -367,7 +367,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workId?: string; @@ -382,7 +382,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workflowId?: string; @@ -397,7 +397,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; } @@ -410,7 +410,7 @@ export type ComponentApi = name: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; workId?: string; @@ -429,7 +429,7 @@ export type ComponentApi = onComplete?: { context?: any; fnHandle: string }; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt?: any; state?: any; @@ -461,7 +461,7 @@ export type ComponentApi = name?: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; workflowId: string; }>; @@ -494,7 +494,7 @@ export type ComponentApi = name?: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; workflowId: string; }>; @@ -530,7 +530,7 @@ export type ComponentApi = nestedWorkflowId?: string; runResult?: | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } + | { error: string; errorData?: any; kind: "failed" } | { kind: "canceled" }; startedAt: number; stepId: string; diff --git a/src/component/oversizedValues.test.ts b/src/component/oversizedValues.test.ts new file mode 100644 index 00000000..53284d44 --- /dev/null +++ b/src/component/oversizedValues.test.ts @@ -0,0 +1,54 @@ +import { ConvexError, getConvexSize, type Value } from "convex/values"; +import { expect, test } from "vitest"; +import { failedResult } from "../shared.js"; +import { + checkForOversizedResult, + MAX_RETURN_VALUE_SIZE, +} from "./oversizedValues.js"; + +test.each([null, false, 0, "", { code: "INVALID", count: 7n }])( + "preserves small structured failures: %s", + (errorData) => { + const result = { + kind: "failed" as const, + error: "server stack", + errorData, + }; + expect(checkForOversizedResult(result)).toBe(result); + }, +); + +test.each([ + { error: "server stack", errorData: "x".repeat(MAX_RETURN_VALUE_SIZE) }, + { + error: "x".repeat(MAX_RETURN_VALUE_SIZE / 2), + errorData: "y".repeat(MAX_RETURN_VALUE_SIZE / 2), + }, + { error: "x".repeat(MAX_RETURN_VALUE_SIZE), errorData: null }, +])("bounds the entire failed result before recording it", (failure) => { + const result = checkForOversizedResult({ kind: "failed", ...failure }); + expect(result).toMatchObject({ kind: "failed" }); + expect(result.error).toContain("Step failure too large"); + expect(result).not.toHaveProperty("errorData"); + expect(getConvexSize(result)).toBeLessThan(100 << 10); +}); + +test("accepts the failure size limit and rejects the next byte", () => { + const overhead = getConvexSize({ kind: "failed", error: "" }); + const result = { + kind: "failed" as const, + error: "x".repeat(MAX_RETURN_VALUE_SIZE - overhead), + }; + expect(getConvexSize(result)).toBe(MAX_RETURN_VALUE_SIZE); + expect(checkForOversizedResult(result)).toBe(result); + expect( + checkForOversizedResult({ ...result, error: result.error + "x" }).error, + ).toContain("Step failure too large"); +}); + +test("bounds ConvexError data and its formatted message before crossing into the component", () => { + const result = failedResult(new ConvexError("x".repeat(2 << 20))); + expect(result.error).toContain("Step failure too large"); + expect(result).not.toHaveProperty("errorData"); + expect(getConvexSize(result)).toBeLessThan(100 << 10); +}); diff --git a/src/component/oversizedValues.ts b/src/component/oversizedValues.ts index 2339f83a..007e87d4 100644 --- a/src/component/oversizedValues.ts +++ b/src/component/oversizedValues.ts @@ -22,7 +22,20 @@ export function checkReturnValueSize( return null; } -export function checkForOversizedResult(result: RunResult): RunResult { +export function checkForOversizedResult( + result: Result, +): Result | Extract { + if (result.kind === "failed") { + // Count the whole failure: the message/stack may duplicate the error data. + const size = getConvexSize(result); + if (size > MAX_RETURN_VALUE_SIZE) { + return { + kind: "failed", + error: `Step failure too large (${size} bytes). Maximum is ${MAX_RETURN_VALUE_SIZE} bytes. Original error data omitted. Preview: ${truncatedPreview(result.error)}`, + }; + } + return result; + } if (result.kind !== "success") { return result; } diff --git a/src/shared.ts b/src/shared.ts index 858ac3b6..2b8bb454 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,5 +1,19 @@ +import { ConvexError } from "convex/values"; +import type { RunResult } from "./client/types.js"; +import { checkForOversizedResult } from "./component/oversizedValues.js"; + export const MAX_JOURNAL_SIZE = 8 << 20; +export function failedResult( + error: unknown, +): Extract { + return checkForOversizedResult({ + kind: "failed", + error: formatErrorWithStack(error), + ...(error instanceof ConvexError ? { errorData: error.data ?? null } : {}), + }); +} + export function formatErrorWithStack(error: unknown): string { if (error instanceof Error) { return error.toString() + (error.stack ? "\n" + error.stack : "");