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..af71d6e7 --- /dev/null +++ b/example/convex/errorData.test.ts @@ -0,0 +1,58 @@ +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 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 new file mode 100644 index 00000000..c468479e --- /dev/null +++ b/example/convex/errorData.ts @@ -0,0 +1,50 @@ +import { vOnCompleteArgs } from "@convex-dev/workpool"; +import { ConvexError, v, type Value } from "convex/values"; +import { internal } from "./_generated/api.js"; +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: 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({ + 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), }; }