Error handling

Inngest functions are designed to handle failures gracefully and will automatically retry after a failure. This adds an immediate layer of durability to your code, ensuring it survives transient issues like network timeouts, outages, or database locks.

  • Automatic retries: Functions are retried automatically upon failure
  • Configurable retry policies: Tailor the retry behavior to suit your specific use case
  • Failure handlers: Utilize onFailure to handle all retries failing
  • Step-level retries: Each step within a function can have its own retry logic and be handled individually

Types of failure

Inngest helps you handle both errors and failures, which are defined differently.

A function that defines no steps is treated as a function with a single step, where that step is the entire function's code.

An error causes a step to retry. Exhausting all retry attempts will cause that step to fail, which means the step will never be attempted again this run.

A failed step can be handled with native language features such as try/catch, but unhandled errors will cause the function to fail, meaning the run is marked as "Failed" in the Inngest UI and all future executions are canceled.

Let's look at how to use and configure these retries.

Retries

By default, in addition to the initial attempt, Inngest will retry a function or a step up to 4 times until it succeeds. This means that for a function with a default configuration, it will be attempted 5 times in total.

For the function below, if the database write fails then it'll be retried up to 4 times until it succeeds:

inngest.createFunction(
  { id: "click-recorder" },
  { event: "app/button.clicked" },
  async ({ event, attempt }) => {
    await db.clicks.insertOne(event.data); // this code now retries!
  },
);

You can configure the number of retries by specifying it in your function configuration. Setting the value to 0 will disable retries.

inngest.createFunction(
  {
    id: "click-recorder",
    retries: 10, // choose how many retries you'd like
  },
  { event: "app/button.clicked" },
  async ({ event, step, attempt }) => { /* ... */ },
);

You can customize the behavior of your function based on the number of retries using the attempt argument. attempt is passed in the function handler's context and is zero-indexed, meaning the first attempt is 0, the second is 1, and so on. The attempt is incremented every time the function throws an error and is retried, and is reset when steps complete. This allows you to handle attempt numbers differently in each step.

Retries will be performed with backoff according to the default schedule.

Steps

A function can be broken down into multiple steps, where each step is individually executed and retried.

Here, both the "get-data" and "save-data" steps have their own set of retries. If the "save-data" step has a failure, it's retried, alone, in a separate request.

inngest.createFunction(
  { id: "sync-systems" },
  { event: "auto/sync.request" },
  async ({ step }) => {
    // Can be retried up to 4 times
    const data = await step.run("get-data", async () => {
      return getDataFromExternalSource();
    });

    // Can also be retried up to 4 times
    await step.run("save-data", async () => {
      return db.syncs.insertOne(data);
    });
  },
);

You can configure the number of retries for each function. This excludes the initial attempt. A retry count of 4 means that each step will be attempted up to 5 times.

Handling a failing step

Unlike an error being thrown in the main function's body, a failing step (one that has exhausted all retries) will throw a StepError. This allows you to handle failures for each step individually, where you can recover from the error gracefully.

If a step failure isn't handled, the error will bubble up to the function itself, which will then be marked as failed.

Below is an attempt to use DALL-E to generate an image from a prompt, and to fall back to Midjourney if it fails. Remember that these calls are split over separate requests, making the code much more durable against timeouts, transient errors, and these dependencies on external APIs.

inngest.createFunction(
  { id: "generate-result" },
  { event: "prompt.created" },
  async ({ event, step }) => {
    // try one AI model, if it fails, try another
    let imageURL: string | null = null;
    let via: "dall-e" | "midjourney";

    try {
      imageURL = await step.run("generate-image-dall-e", () => {
        // open api call to generate image...
      });
      via = "dall-e";
    } catch (err) {
      imageURL = await step.run("generate-image-midjourney", () => {
        // midjourney call to generate image...
      });
      via = "midjourney";
    }

    await step.run("notify-user", () => {
      return pusher.trigger(event.data.channelID, "image-result", {
        imageURL,
        via,
      });
    });
  },
);

Simple rollbacks

With this pattern, it's possible to assign a small rollback for each step, making sure that every action is safe regardless of how many steps are being run.

inngest.createFunction(
  { id: "add-data" },
  { event: "app/row.data.added" },
  async ({ event, step }) => {
    // ignore the error - this step is fine if it fails
    await step
      .run("Non-critical step", () => {
        return updateMetric();
      })
      .catch();

    // Add a rollback to a step
    await step
      .run("Create row", async () => {
        const row = await createRow(event.data.rowId);
        await addDetail(event.data.entry);
      })
      .catch((err) =>
        step.run("Rollback row creation", async () => {
          await removeRow(event.data.rowId);
        }),
      );
  },
);

Failure handlers

If your function exhausts all of its retries, it will be marked as "Failed." You can handle this circumstance by providing an onFailure handler when defining your function.

The example below checks if a user's subscription is valid a total of six times. If you can't check the subscription after all retries, you'll unsubscribe the user:

inngest.createFunction(
  {
    id: "update-subscription",
    retries: 5,
    onFailure: async ({ event, error }) => {
      // if the susbcription check fails after all retries, unsubscribe the user
      await unsubscribeUser(event.data.userId);
    },
  },
  { event: "user/subscription.check" },
  async ({ event }) => { /* ... */ },
);

Internally, this handler creates a second function that listens for the inngest/function.failed event, which you can listen to yourself to capture all failed runs across your system.

inngest.createFunction(
  { id: "handle-any-fn-failure" },
  { event: "inngest/function.failed" },
  async ({ event }) => { /* ... */ },
);

Preventing retries with Non-retriable errors

You can throw a non-retriable error from a step or a function, which will bypass any remaining retries and fail the step or function it was thrown from.

This is useful for when you know an error is permanent and want to stop all execution. In this example, the user doesn't exist, so there's no need to continue to email them.

import { NonRetriableError } from "inngest";

inngest.createFunction(
  { id: "user-weekly-digest" },
  { event: "user/weekly-digest-requested" },
  async ({ event, step }) => {
    const user = await step
      .run("Get user email", () => {
        return db.users.findOne(event.data.userId);
      })
      .catch((err) => {
        if (err.name === "UserNotFoundError") {
          throw new NonRetriableError("User no longer exists; stopping");
        }

        throw err;
      });

    await step.run("Send digest", () => {
      return sendDigest(user.email);
    });
  },
);

Customizing retry times

Retries are executed with exponential back-off with some jitter, but it's also possible to specify exactly when you'd like a step or function to be retried.

In this example, an external API provided Retry-After header with information on when requests can be made again, so you can tell Inngest to retry your function then.

import { RetryAfterError } from 'inngest';

inngest.createFunction(
  { id: "send-welcome-notification" },
  { event: "app/user.created" },
  async ({ event, step }) => {
    const { success, retryAfter } = await twilio.messages.create({
      to: event.data.user.phoneNumber,
      body: "Welcome to our service!",
    });

    if (!success && retryAfter) {
      throw new RetryAfterError("Hit Twilio rate limit", retryAfter);
    }
  },
);

Learn more

Check out some related resources to learn more about error handling in Inngest: