diff --git a/.changeset/fair-seahorses-push.md b/.changeset/fair-seahorses-push.md new file mode 100644 index 000000000..b54158e6c --- /dev/null +++ b/.changeset/fair-seahorses-push.md @@ -0,0 +1,5 @@ +--- +"inngest": minor +--- + +Add handling of per-step errors and returning step names during error cases to better display issues in the UI diff --git a/packages/inngest/etc/inngest.api.md b/packages/inngest/etc/inngest.api.md index 6272e7cc4..903ec160d 100644 --- a/packages/inngest/etc/inngest.api.md +++ b/packages/inngest/etc/inngest.api.md @@ -523,6 +523,13 @@ export type StandardEventSchemaToPayload = Simplify<{ }; }>; +// @public +export class StepError extends Error { + constructor( + stepId: string, err: unknown); + readonly stepId: string; +} + // @public export interface StepOptions { id: string; @@ -560,9 +567,9 @@ export type ZodEventSchemas = Record { - const opData = z.object({ data: z.any() }).safeParse(op.data); - - if (opData.success) { - (op.data as { data: unknown }).data = undefinedToNull( - opData.data.data - ); - } - + op.data = undefinedToNull(op.data); return op; }; @@ -791,7 +784,17 @@ export class InngestCommHandler< return { status: 206, - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(typeof result.retriable !== "undefined" + ? { + [headerKeys.NoRetry]: result.retriable ? "false" : "true", + ...(typeof result.retriable === "string" + ? { [headerKeys.RetryAfter]: result.retriable } + : {}), + } + : {}), + }, body: stringify([step]), version, }; diff --git a/packages/inngest/src/components/InngestFunction.test.ts b/packages/inngest/src/components/InngestFunction.test.ts index ac5597bab..1178099de 100644 --- a/packages/inngest/src/components/InngestFunction.test.ts +++ b/packages/inngest/src/components/InngestFunction.test.ts @@ -495,7 +495,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "A", }), }, @@ -508,7 +508,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B", }), }, @@ -539,8 +539,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -558,8 +558,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, - data: { data: "B" }, + op: StepOpCode.StepRun, + data: "B", displayName: "B", }), }, @@ -640,7 +640,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "A", }), }, @@ -655,7 +655,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B", }), }, @@ -708,8 +708,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -722,8 +722,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, - data: { data: "B" }, + op: StepOpCode.StepRun, + data: "B", displayName: "B", }), }, @@ -801,7 +801,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B", }), }, @@ -828,7 +828,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "A", }), }, @@ -852,7 +852,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: C, name: "C", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "C", }), }, @@ -917,8 +917,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, - data: { data: "B" }, + op: StepOpCode.StepRun, + data: "B", displayName: "B", }), }, @@ -954,8 +954,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -1005,8 +1005,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: C, name: "C", - op: StepOpCode.RunStep, - data: { data: "C" }, + op: StepOpCode.StepRun, + data: "C", displayName: "C", }), }, @@ -1097,7 +1097,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B", }), }, @@ -1111,7 +1111,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: BWins, name: "B wins", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B wins", }), }, @@ -1125,7 +1125,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "A", }), }, @@ -1180,8 +1180,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, - data: { data: "B" }, + op: StepOpCode.StepRun, + data: "B", displayName: "B", }), }, @@ -1218,8 +1218,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -1333,7 +1333,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: BWins, name: "B wins", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B wins", }), }, @@ -1449,8 +1449,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -1468,8 +1468,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "A", - op: StepOpCode.RunStep, - data: { data: "B" }, + op: StepOpCode.StepRun, + data: "B", displayName: "A", }), }, @@ -1491,8 +1491,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: C, name: "A", - op: StepOpCode.RunStep, - data: { data: "C" }, + op: StepOpCode.StepRun, + data: "C", displayName: "A", }), }, @@ -1599,8 +1599,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -1618,8 +1618,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "A", - op: StepOpCode.RunStep, - data: { data: "B" }, + op: StepOpCode.StepRun, + data: "B", displayName: "A", }), }, @@ -1748,7 +1748,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "A", }), }, @@ -1770,7 +1770,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, + op: StepOpCode.Step, error: matchError("B failed message"), retriable: true, }), @@ -1790,7 +1790,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: BFailed, name: "B failed", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B failed", }), }, @@ -1846,8 +1846,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -1874,9 +1874,15 @@ describe("runFn", () => { disableImmediateExecution: true, runStep: B, expectedReturn: { - type: "function-rejected", - error: matchError("B failed message"), + type: "step-ran", retriable: true, + step: expect.objectContaining({ + id: B, + name: "B", + displayName: "B", + op: StepOpCode.StepError, + error: matchError("B failed message"), + }), }, expectedStepsRun: ["B"], expectedErrors: ["B failed message"], @@ -1913,8 +1919,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: BFailed, name: "B failed", - op: StepOpCode.RunStep, - data: { data: "B failed" }, + op: StepOpCode.StepRun, + data: "B failed", displayName: "B failed", }), }, @@ -1967,8 +1973,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + op: StepOpCode.Step, error: matchError(new NonRetriableError("A error message")), }), }, @@ -1981,12 +1986,18 @@ describe("runFn", () => { hashes: { A: "A", }, - tests: () => ({ + tests: ({ A }) => ({ "first run executes A, which throws a NonRetriable error": { expectedReturn: { - type: "function-rejected", + type: "step-ran", retriable: false, - error: matchError(new NonRetriableError("A error message")), + step: expect.objectContaining({ + id: A, + name: "A", + displayName: "A", + op: StepOpCode.StepError, + error: matchError(new NonRetriableError("A error message")), + }), }, expectedStepsRun: ["A"], expectedErrors: ["A error message"], @@ -2180,7 +2191,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "A", }), }, @@ -2193,7 +2204,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "A", }), }, @@ -2211,7 +2222,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B", }), }, @@ -2248,8 +2259,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -2267,8 +2278,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, - data: { data: "B" }, + op: StepOpCode.StepRun, + data: "B", displayName: "B", }), }, @@ -2336,7 +2347,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "A", }), }, @@ -2365,7 +2376,7 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, + op: StepOpCode.Step, data: "B", }), }, @@ -2423,8 +2434,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: A, name: "A", - op: StepOpCode.RunStep, - data: { data: "A" }, + op: StepOpCode.StepRun, + data: "A", displayName: "A", }), }, @@ -2453,8 +2464,8 @@ describe("runFn", () => { step: expect.objectContaining({ id: B, name: "B", - op: StepOpCode.RunStep, - data: { data: "B" }, + op: StepOpCode.StepRun, + data: "B", displayName: "B", }), }, diff --git a/packages/inngest/src/components/StepError.ts b/packages/inngest/src/components/StepError.ts new file mode 100644 index 000000000..85267e8a5 --- /dev/null +++ b/packages/inngest/src/components/StepError.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +/** + * An error that represents a step exhausting all retries and failing. This is + * thrown by an Inngest step if it fails. + * + * It's synonymous with an `Error`, with the addition of the `stepId` that + * failed. + * + * @public + */ +export class StepError extends Error { + constructor( + /** + * The ID of the step that failed. + */ + public readonly stepId: string, + err: unknown + ) { + const parsedErr = z + .object({ + name: z.string(), + message: z.string(), + stack: z.string().optional(), + }) + .catch({ + name: "Error", + message: "An unknown error occurred; could not parse error", + }) + .parse(err); + + super(parsedErr.message); + this.name = parsedErr.name; + this.stepId = stepId; + + // Don't show the internal stack trace if we don't have one. + this.stack = parsedErr.stack ?? undefined; + } +} diff --git a/packages/inngest/src/components/execution/InngestExecution.ts b/packages/inngest/src/components/execution/InngestExecution.ts index 985d937ae..a1ae40370 100644 --- a/packages/inngest/src/components/execution/InngestExecution.ts +++ b/packages/inngest/src/components/execution/InngestExecution.ts @@ -13,7 +13,7 @@ import { type InngestFunction } from "../InngestFunction"; */ export interface ExecutionResults { "function-resolved": { data: unknown }; - "step-ran": { step: OutgoingOp }; + "step-ran": { step: OutgoingOp; retriable?: boolean | string }; "function-rejected": { error: unknown; retriable: boolean | string }; "steps-found": { steps: [OutgoingOp, ...OutgoingOp[]] }; "step-not-found": { step: OutgoingOp }; diff --git a/packages/inngest/src/components/execution/v0.ts b/packages/inngest/src/components/execution/v0.ts index bbb5e4a27..103c46cb2 100644 --- a/packages/inngest/src/components/execution/v0.ts +++ b/packages/inngest/src/components/execution/v0.ts @@ -169,7 +169,7 @@ export class V0InngestExecution const outgoingUserFnOp = { ...tickOpToOutgoing(userFnOp), - op: StepOpCode.RunStep, + op: StepOpCode.Step, }; await this.state.hooks.beforeExecution?.(); diff --git a/packages/inngest/src/components/execution/v1.ts b/packages/inngest/src/components/execution/v1.ts index 8f2bfee57..d79082c2d 100644 --- a/packages/inngest/src/components/execution/v1.ts +++ b/packages/inngest/src/components/execution/v1.ts @@ -37,6 +37,7 @@ import { } from "../InngestStepTools"; import { NonRetriableError } from "../NonRetriableError"; import { RetryAfterError } from "../RetryAfterError"; +import { StepError } from "../StepError"; import { InngestExecution, type ExecutionResult, @@ -178,9 +179,19 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { type: "step-ran", step: _internals.hashOp({ ...stepResult, - data: { data: transformResult.data }, + data: transformResult.data, }), }; + // } + } else if (transformResult.type === "function-rejected") { + return { + type: "step-ran", + step: _internals.hashOp({ + ...stepResult, + error: transformResult.error, + }), + retriable: transformResult.retriable, + }; } return transformResult; @@ -328,7 +339,7 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { const outgoingOp: OutgoingOp = { id, - op: StepOpCode.RunStep, + op: StepOpCode.StepRun, name, opts, displayName, @@ -351,6 +362,7 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { .catch((error) => { return { ...outgoingOp, + op: StepOpCode.StepError, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment error, }; @@ -439,8 +451,20 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { ): Promise { const output = { ...dataOrError }; + /** + * If we've been given an error and it's one that we just threw from a step, + * we should return a `NonRetriableError` to stop execution. + */ if (typeof output.error !== "undefined") { - output.data = serializeError(output.error); + const serializedError = serializeError(output.error); + output.data = serializedError; + + if (output.error === this.state.recentlyRejectedStepError) { + output.error = new NonRetriableError(serializedError.message, { + cause: output.error, + }); + output.data = serializeError(output.error); + } } const transformedOutput = await this.state.hooks?.transformOutput?.({ @@ -756,7 +780,12 @@ class V1InngestExecution extends InngestExecution implements IInngestExecution { reject(err); } else { - reject(stepState.error); + this.state.recentlyRejectedStepError = new StepError( + opId.id, + stepState.error + ); + + reject(this.state.recentlyRejectedStepError); } } @@ -944,6 +973,22 @@ export interface V1ExecutionState { * execution was completed. */ stepCompletionOrder: string[]; + + /** + * If defined, this is the error that purposefully thrown when memoizing step + * state in order to support per-step errors. + * + * We use this so that if the function itself rejects with the same error, we + * know that it was entirely uncaught (or at the very least rethrown), so we + * should send a `NonRetriableError` to stop needless execution of a function + * that will continue to fail. + * + * TODO This is imperfect, as this state is currently kept around for longer + * than it needs to be. It should disappear as soon as we've seen that the + * error did not immediately throw. It may need to be refactored to work a + * little more smoothly with the core loop. + */ + recentlyRejectedStepError?: StepError; } const hashId = (id: string): string => { diff --git a/packages/inngest/src/index.ts b/packages/inngest/src/index.ts index e21761371..50d291edc 100644 --- a/packages/inngest/src/index.ts +++ b/packages/inngest/src/index.ts @@ -28,6 +28,7 @@ export type { } from "./components/InngestMiddleware"; export { NonRetriableError } from "./components/NonRetriableError"; export { RetryAfterError } from "./components/RetryAfterError"; +export { StepError } from "./components/StepError"; export { headerKeys, internalEvents, queryKeys } from "./helpers/consts"; export { slugify } from "./helpers/strings"; export type { diff --git a/packages/inngest/src/test/functions/handling-step-errors/index.test.ts b/packages/inngest/src/test/functions/handling-step-errors/index.test.ts new file mode 100644 index 000000000..8824144b7 --- /dev/null +++ b/packages/inngest/src/test/functions/handling-step-errors/index.test.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { + checkIntrospection, + eventRunWithName, + runHasTimeline, + sendEvent, +} from "@local/test/helpers"; + +checkIntrospection({ + name: "handling-step-errors", + triggers: [{ event: "demo/handling.step.errors" }], +}); + +describe("run", () => { + let eventId: string; + let runId: string; + + beforeAll(async () => { + eventId = await sendEvent("demo/handling.step.errors"); + }); + + test("runs in response to 'demo/handling.step.errors'", async () => { + runId = await eventRunWithName(eventId, "handling-step-errors"); + expect(runId).toEqual(expect.any(String)); + }, 60000); + + test(`ran "a" step and it failed, twice`, async () => { + const item = await runHasTimeline(runId, { + attempt: 1, + type: "StepFailed", + stepName: "a", + }); + expect(item).toBeDefined(); + + const output = await item?.getOutput(); + expect(output).toEqual({ + error: { name: "Error", message: "Oh no!", stack: expect.any(String) }, + }); + }, 10000); + + test(`ran "b" step`, async () => { + const item = await runHasTimeline(runId, { + type: "StepCompleted", + stepName: "b", + }); + expect(item).toBeDefined(); + + const output = await item?.getOutput(); + expect(output).toEqual({ data: expect.any(String) }); + }, 10000); + + test(`ran "c succeeds" step`, async () => { + const item = await runHasTimeline(runId, { + type: "StepCompleted", + stepName: "c succeeds", + }); + expect(item).toBeDefined(); + + const output = await item?.getOutput(); + expect(output).toEqual({ data: "c succeeds" }); + }); + + test(`ran "d fails" step and it failed, twice`, async () => { + const item = await runHasTimeline(runId, { + attempt: 1, + type: "StepFailed", + stepName: "d fails", + }); + expect(item).toBeDefined(); + + const output = await item?.getOutput(); + expect(output).toEqual({ + error: { name: "Error", message: "D failed!", stack: expect.any(String) }, + }); + }); + + test(`ran "e succeeds" step`, async () => { + const item = await runHasTimeline(runId, { + type: "StepCompleted", + stepName: "e succeeds", + }); + expect(item).toBeDefined(); + + const output = await item?.getOutput(); + expect(output).toEqual({ data: { errMessage: "D failed!" } }); + }); +}); diff --git a/packages/inngest/src/test/functions/handling-step-errors/index.ts b/packages/inngest/src/test/functions/handling-step-errors/index.ts new file mode 100644 index 000000000..e787be7ce --- /dev/null +++ b/packages/inngest/src/test/functions/handling-step-errors/index.ts @@ -0,0 +1,32 @@ +import { inngest } from "../client"; + +export default inngest.createFunction( + { id: "handling-step-errors", retries: 1 }, + { event: "demo/handling.step.errors" }, + async ({ step }) => { + try { + await step.run("a", () => { + throw new Error("Oh no!"); + }); + } catch (err) { + await step.run("b", () => { + return `err was: ${err.message}`; + }); + } + + await Promise.all([ + step.run("c succeeds", () => "c succeeds"), + step + .run("d fails", () => { + throw new Error("D failed!"); + }) + .catch((err: Error) => { + return step.run("e succeeds", () => { + return { + errMessage: err.message, + }; + }); + }), + ]); + } +); diff --git a/packages/inngest/src/test/functions/index.ts b/packages/inngest/src/test/functions/index.ts index cd9bd3db6..da8823403 100644 --- a/packages/inngest/src/test/functions/index.ts +++ b/packages/inngest/src/test/functions/index.ts @@ -1,3 +1,4 @@ +import handlingStepErrors from "./handling-step-errors"; import helloWorld from "./hello-world"; import parallelReduce from "./parallel-reduce"; import parallelWork from "./parallel-work"; @@ -9,6 +10,7 @@ import sequentialReduce from "./sequential-reduce"; import stepInvokeFunctions from "./step-invoke"; import stepInvokeNotFound from "./step-invoke-not-found"; import undefinedData from "./undefined-data"; +import unhandledStepErrors from "./unhandled-step-errors"; export const functions = [ helloWorld, @@ -22,6 +24,8 @@ export const functions = [ undefinedData, ...stepInvokeFunctions, stepInvokeNotFound, + handlingStepErrors, + unhandledStepErrors, ]; export { inngest } from "./client"; diff --git a/packages/inngest/src/test/functions/unhandled-step-errors/index.test.ts b/packages/inngest/src/test/functions/unhandled-step-errors/index.test.ts new file mode 100644 index 000000000..c0ccced92 --- /dev/null +++ b/packages/inngest/src/test/functions/unhandled-step-errors/index.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { + checkIntrospection, + eventRunWithName, + runHasTimeline, + sendEvent, +} from "@local/test/helpers"; + +checkIntrospection({ + name: "unhandled-step-errors", + triggers: [{ event: "demo/unhandled.step.errors" }], +}); + +describe("run", () => { + let eventId: string; + let runId: string; + + beforeAll(async () => { + eventId = await sendEvent("demo/unhandled.step.errors"); + }); + + test("runs in response to 'demo/unhandled.step.errors'", async () => { + runId = await eventRunWithName(eventId, "unhandled-step-errors"); + expect(runId).toEqual(expect.any(String)); + }, 60000); + + test(`ran "a fails" step and it failed, twice`, async () => { + const item = await runHasTimeline(runId, { + attempt: 1, + type: "StepFailed", + stepName: "a fails", + }); + expect(item).toBeDefined(); + + const output = await item?.getOutput(); + expect(output).toEqual({ + error: { name: "Error", message: "A failed!", stack: expect.any(String) }, + }); + }, 10000); + + test("function failed", async () => { + const item = await runHasTimeline(runId, { + type: "FunctionFailed", + }); + expect(item).toBeDefined(); + }, 10000); + + test(`never ran "b never runs" step`, async () => { + const item = await runHasTimeline( + runId, + { + type: "StepCompleted", + stepName: "b never runs", + }, + 1 + ); + expect(item).toBeUndefined(); + }, 10000); +}); diff --git a/packages/inngest/src/test/functions/unhandled-step-errors/index.ts b/packages/inngest/src/test/functions/unhandled-step-errors/index.ts new file mode 100644 index 000000000..64b98e65a --- /dev/null +++ b/packages/inngest/src/test/functions/unhandled-step-errors/index.ts @@ -0,0 +1,13 @@ +import { inngest } from "../client"; + +export default inngest.createFunction( + { id: "unhandled-step-errors", retries: 1 }, + { event: "demo/unhandled.step.errors" }, + async ({ step }) => { + await step.run("a fails", () => { + throw new Error("A failed!"); + }); + + await step.run("b never runs", () => "b"); + } +); diff --git a/packages/inngest/src/test/helpers.ts b/packages/inngest/src/test/helpers.ts index 60b0963e1..a7593943a 100644 --- a/packages/inngest/src/test/helpers.ts +++ b/packages/inngest/src/test/helpers.ts @@ -853,26 +853,29 @@ export const eventRunWithName = async ( for (let i = 0; i < 140; i++) { const start = new Date(); - const res = await fetch("http://localhost:8288/v0/gql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: `query GetEventRuns($eventId: ID!) { - event(query: {eventId: $eventId}) { + const body = { + query: `query GetEventStream { + stream(query: {limit: 999, includeInternalEvents: false}) { id - functionRuns { + trigger + runs { id - name + function { + name + } } } }`, - variables: { - eventId, - }, - operationName: "GetEventRuns", - }), + variables: {}, + operationName: "GetEventStream", + }; + + const res = await fetch("http://localhost:8288/v0/gql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), }); if (!res.ok) { @@ -881,10 +884,25 @@ export const eventRunWithName = async ( const data = await res.json(); - const run = data?.data?.event?.functionRuns?.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let run: any; + + for (let i = 0; i < data?.data?.stream?.length ?? 0; i++) { + const item = data?.data?.stream[i]; + + if (item?.id !== eventId) { + continue; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any - (run: any) => run.name === name - ); + run = item?.runs?.find((run: any) => { + return run?.function?.name === name; + }); + + if (run) { + break; + } + } if (run) { return run.id; @@ -975,9 +993,11 @@ export const runHasTimeline = async ( timeline: { stepName?: string; type: HistoryItemType; - } + attempt?: number; + }, + attempts = 140 ): Promise => { - for (let i = 0; i < 140; i++) { + for (let i = 0; i < attempts; i++) { const start = new Date(); const res = await fetch("http://localhost:8288/v0/gql", { @@ -993,6 +1013,7 @@ export const runHasTimeline = async ( type stepName createdAt + attempt } } }`, diff --git a/packages/inngest/src/types.ts b/packages/inngest/src/types.ts index dd4e8ce08..80e037ccc 100644 --- a/packages/inngest/src/types.ts +++ b/packages/inngest/src/types.ts @@ -125,7 +125,19 @@ export type FinishedEventPayload = { */ export enum StepOpCode { WaitForEvent = "WaitForEvent", - RunStep = "Step", + + /** + * Legacy equivalent to `"StepRun"`. Has mixed data wrapping (e.g. `data` or + * `data.data` depending on SDK version), so this is phased out in favour of + * `"StepRun"`, which never wraps. + * + * Note that it is still used for v0 executions for backwards compatibility. + * + * @deprecated Only used for v0 executions; use `"StepRun"` instead. + */ + Step = "Step", + StepRun = "StepRun", + StepError = "StepError", StepPlanned = "StepPlanned", Sleep = "Sleep",