From 84427cb8b9aa26592070a6f7d7652714e93b2d58 Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:22:35 -0800 Subject: [PATCH 1/2] Preserve ConvexError data in failed job results --- README.md | 4 +++ example/convex/_generated/api.d.ts | 2 ++ example/convex/errorData.test.ts | 55 ++++++++++++++++++++++++++++++ example/convex/errorData.ts | 37 ++++++++++++++++++++ src/client/index.test.ts | 4 +++ src/component/errors.test.ts | 39 +++++++++++++++++++++ src/component/errors.ts | 13 +++++++ src/component/shared.ts | 5 ++- src/component/worker.ts | 17 ++------- 9 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 example/convex/errorData.test.ts create mode 100644 example/convex/errorData.ts diff --git a/README.md b/README.md index 1c2d7b18..7e45f777 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,10 @@ To get the result of your function, you can either write to the database from within your function, call or schedule another function from there, or use the `onComplete` handler to respond to the job result. +If a job throws a `ConvexError`, its failed result also includes `errorData`, +containing the original `ConvexError.data`. The `error` string remains available +for logging. Ordinary errors and older recorded failures have no `errorData`. + ## Get started ### Pre-requisite: Convex diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index e794b2bd..e237a1c8 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as crons from "../crons.js"; +import type * as errorData from "../errorData.js"; import type * as example from "../example.js"; import type * as staticHosting from "../staticHosting.js"; import type * as test_dashboard from "../test/dashboard.js"; @@ -35,6 +36,7 @@ import type { declare const fullApi: ApiFromModules<{ crons: typeof crons; + errorData: typeof errorData; example: typeof example; staticHosting: typeof staticHosting; "test/dashboard": typeof test_dashboard; diff --git a/example/convex/errorData.test.ts b/example/convex/errorData.test.ts new file mode 100644 index 00000000..f6d91356 --- /dev/null +++ b/example/convex/errorData.test.ts @@ -0,0 +1,55 @@ +import { Workpool } from "@convex-dev/workpool"; +import workpool from "@convex-dev/workpool/test"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { components, internal } from "./_generated/api.js"; +import { initConvexTest } from "./setup.test.js"; + +const pool = new Workpool(components.bigPool, { logLevel: "ERROR" }); +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +test.each(["query", "mutation", "action"] as const)( + "preserves ConvexError data in the %s completion callback", + async (functionType) => { + const t = initConvexTest(); + workpool.register(t, "bigPool"); + const data = { code: "INVALID", count: 7 }; + const options = { + onComplete: internal.errorData.onComplete, + context: null, + }; + await t.mutation(async (ctx) => { + switch (functionType) { + case "query": + return await pool.enqueueQuery( + ctx, + internal.errorData.failingQuery, + { data }, + options, + ); + case "mutation": + return await pool.enqueueMutation( + ctx, + internal.errorData.failingMutation, + { data }, + options, + ); + case "action": + return await pool.enqueueAction( + ctx, + internal.errorData.failingAction, + { data }, + { + ...options, + retry: { maxAttempts: 2, initialBackoffMs: 1, base: 1 }, + }, + ); + } + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const results = await t.query((ctx) => ctx.db.query("data").take(2)); + expect(results).toHaveLength(1); + expect(results[0].misc).toMatchObject({ kind: "failed", errorData: data }); + expect(typeof results[0].misc.error).toBe("string"); + }, +); diff --git a/example/convex/errorData.ts b/example/convex/errorData.ts new file mode 100644 index 00000000..1beaf9f1 --- /dev/null +++ b/example/convex/errorData.ts @@ -0,0 +1,37 @@ +import { vOnCompleteArgs } from "@convex-dev/workpool"; +import { ConvexError, v, type Value } from "convex/values"; +import { + internalAction, + internalMutation, + internalQuery, +} from "./_generated/server.js"; + +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 onComplete = internalMutation({ + args: vOnCompleteArgs(v.null()), + returns: v.null(), + handler: async (ctx, { result }) => { + await ctx.db.insert("data", { misc: result }); + return null; + }, +}); diff --git a/src/client/index.test.ts b/src/client/index.test.ts index f259813a..382c7de8 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -334,6 +334,10 @@ describe("completion callbacks through the client and scheduler", () => { expect((await events("terminal"))[1].result).toEqual({ kind: "failed", error: "work failed", + errorData: { + __convexWorkpoolNonRetryable: true, + message: "work failed", + }, }); }); diff --git a/src/component/errors.test.ts b/src/component/errors.test.ts index 1437a1fb..6d35725c 100644 --- a/src/component/errors.test.ts +++ b/src/component/errors.test.ts @@ -4,11 +4,50 @@ import { getNonRetryableErrorMessage, isNonRetryableError, NonRetryableError, + failedResult, } from "./errors.js"; class CustomTerminalError extends NonRetryableError {} describe("errors", () => { + it.each([ + null, + false, + 0, + "", + "invalid input", + { code: "INVALID", count: 7n }, + ])("preserves ConvexError data: %s", (data) => { + const error = new ConvexError(data); + expect(failedResult(error)).toEqual({ + kind: "failed", + error: error.message, + errorData: data, + }); + }); + + it("keeps ordinary failures as strings, even if they have a data property", () => { + expect( + failedResult(Object.assign(new Error("failure"), { data: "extra" })), + ).toEqual({ + kind: "failed", + error: "failure", + }); + expect(failedResult("failure")).toEqual({ + kind: "failed", + error: "failure", + }); + }); + + it("preserves NonRetryableError data without changing its error message", () => { + const error = new NonRetryableError("invalid input"); + expect(failedResult(error)).toEqual({ + kind: "failed", + error: "invalid input", + errorData: error.data, + }); + }); + it("identifies non-retryable error subclasses", () => { // Users should be able to model domain-specific terminal failures. const error = new CustomTerminalError("invalid input"); diff --git a/src/component/errors.ts b/src/component/errors.ts index 6a510843..f24f4c4e 100644 --- a/src/component/errors.ts +++ b/src/component/errors.ts @@ -1,4 +1,17 @@ import { ConvexError } from "convex/values"; +import type { RunResult } from "./shared.js"; + +export function failedResult( + error: unknown, +): Extract { + return { + kind: "failed", + error: + getNonRetryableErrorMessage(error) ?? + (error instanceof Error ? error.message : String(error)), + ...(error instanceof ConvexError ? { errorData: error.data ?? null } : {}), + }; +} const NON_RETRYABLE_ERROR_MARKER = "__convexWorkpoolNonRetryable"; diff --git a/src/component/shared.ts b/src/component/shared.ts index 53bf8a9e..50e2c674 100644 --- a/src/component/shared.ts +++ b/src/component/shared.ts @@ -1,4 +1,4 @@ -import type { Infer, Validator, VAny } from "convex/values"; +import type { Infer, Validator, Value, VAny } from "convex/values"; import { v } from "convex/values"; import { type Logger, logLevel } from "./logging.js"; @@ -84,6 +84,7 @@ export function vRunResult = VAny>( v.object({ kind: v.literal("failed"), error: v.string(), + errorData: v.optional(v.any()), }), v.object({ kind: v.literal("canceled"), @@ -104,6 +105,8 @@ export type RunResult = * The error message of the run, if it failed. */ error: string; + /** The original ConvexError data, when the job threw a ConvexError. */ + errorData?: Value; } | { kind: "canceled"; diff --git a/src/component/worker.ts b/src/component/worker.ts index 227f5917..f9fb0fd6 100644 --- a/src/component/worker.ts +++ b/src/component/worker.ts @@ -13,7 +13,7 @@ import { internalQuery, type ActionCtx, } from "./_generated/server.js"; -import { getNonRetryableErrorMessage, isNonRetryableError } from "./errors.js"; +import { failedResult, isNonRetryableError } from "./errors.js"; import { createLogger, type Logger, logLevel } from "./logging.js"; import type { RunResult } from "./shared.js"; import type { CompleteJob } from "./complete.js"; @@ -66,7 +66,7 @@ export const runMutationWrapper = internalMutation({ }); } catch (e: unknown) { console.error(e); - const runResult = { kind: "failed" as const, error: formatError(e) }; + const runResult = failedResult(e); await ctx.scheduler.runAfter(0, internal.complete.complete, { jobs: [ { @@ -81,17 +81,6 @@ export const runMutationWrapper = internalMutation({ }, }); -function formatError(e: unknown) { - const nonRetryableMessage = getNonRetryableErrorMessage(e); - if (nonRetryableMessage !== undefined) { - return nonRetryableMessage; - } - if (e instanceof Error) { - return e.message; - } - return String(e); -} - /** Legacy entrypoint, keeping around for graceful upgrade for scheduled functions in flight */ export const runActionWrapper = internalAction({ args: { @@ -193,7 +182,7 @@ async function runOne( console.error(e); // We let the main loop handle the retries. return { - runResult: { kind: "failed", error: formatError(e) } satisfies RunResult, + runResult: failedResult(e), nonRetryable: isNonRetryableError(e), }; } From ca813c0b21369b91fab3ed317a3b5ed3bb6357f8 Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:41:25 -0800 Subject: [PATCH 2/2] Assert action retries before checking error data callback --- example/convex/errorData.test.ts | 11 +++++++---- example/convex/errorData.ts | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/example/convex/errorData.test.ts b/example/convex/errorData.test.ts index f6d91356..af71d6e7 100644 --- a/example/convex/errorData.test.ts +++ b/example/convex/errorData.test.ts @@ -47,9 +47,12 @@ test.each(["query", "mutation", "action"] as const)( } }); await t.finishAllScheduledFunctions(vi.runAllTimers); - const results = await t.query((ctx) => ctx.db.query("data").take(2)); - expect(results).toHaveLength(1); - expect(results[0].misc).toMatchObject({ kind: "failed", errorData: data }); - expect(typeof results[0].misc.error).toBe("string"); + const events = await t.query((ctx) => ctx.db.query("data").take(4)); + expect(events.map(({ misc }) => misc.kind)).toEqual( + functionType === "action" ? ["attempt", "attempt", "failed"] : ["failed"], + ); + const result = events[events.length - 1].misc; + expect(result).toMatchObject({ kind: "failed", errorData: data }); + expect(typeof result.error).toBe("string"); }, ); diff --git a/example/convex/errorData.ts b/example/convex/errorData.ts index 1beaf9f1..c468479e 100644 --- a/example/convex/errorData.ts +++ b/example/convex/errorData.ts @@ -1,5 +1,6 @@ import { vOnCompleteArgs } from "@convex-dev/workpool"; import { ConvexError, v, type Value } from "convex/values"; +import { internal } from "./_generated/api.js"; import { internalAction, internalMutation, @@ -24,7 +25,19 @@ export const failingMutation = internalMutation({ export const failingAction = internalAction({ args, returns: v.null(), - handler: fail, + handler: async (ctx, args): Promise => { + await ctx.runMutation(internal.errorData.recordActionAttempt, {}); + return fail(ctx, args); + }, +}); + +export const recordActionAttempt = internalMutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + await ctx.db.insert("data", { misc: { kind: "attempt" } }); + return null; + }, }); export const onComplete = internalMutation({