Durable Workflows in Go

Create functions that run steps in the background, triggered by events or schedules.

A durable workflow is an Inngest function that runs in the background. An event or a schedule starts a run, and each step saves its result so that a retry or a wait doesn't repeat finished work. In Go, you create one with inngestgo.CreateFunction, then serve it over HTTP or connect a worker so that Inngest can run it.

Create a function

CreateFunction takes the client, the function's options, a trigger, and a handler:

process_order.go
import (
	"context"
	"errors"
	"time"

	"github.com/inngest/inngestgo"
	"github.com/inngest/inngestgo/step"
)

type OrderPlaced struct {
	OrderID string `json:"orderId"`
}

type OrderCancelled struct {
	OrderID string `json:"orderId"`
}

func ProcessOrder(client inngestgo.Client) (inngestgo.ServableFunction, error) {
	return inngestgo.CreateFunction(
		client,
		inngestgo.FunctionOpts{ID: "process-order"},
		// any time the `shop/order.placed` event is received, this will run.
		inngestgo.EventTrigger("shop/order.placed", nil),
		func(ctx context.Context, input inngestgo.Input[OrderPlaced]) (any, error) {
			order, err := step.Run(ctx, "load-order", func(ctx context.Context) (Order, error) {
				return loadOrder(ctx, input.Event.Data.OrderID)
			})
			if err != nil {
				return nil, err
			}
			if _, err := step.Run(ctx, "charge-order", func(ctx context.Context) (any, error) {
				return nil, chargeOrder(ctx, order)
			}); err != nil {
				return nil, err
			}
			if _, err := step.Run(ctx, "send-receipt", func(ctx context.Context) (any, error) {
				return nil, sendReceipt(ctx, order)
			}); err != nil {
				return nil, err
			}

			// this will wait for the `shop/order.cancelled` event for up to 6 hours,
			// and resume immediately when a matching event is received.  If an event
			// isn't received within 6 hours, the function resumes and WaitForEvent
			// returns step.ErrEventNotReceived.
			//
			// your compute is *not running* during this wait, and you do not need to handle
			// any matching.
			_, err = step.WaitForEvent[inngestgo.GenericEvent[OrderCancelled]](
				ctx,
				"wait-for-cancellation",
				step.WaitForEventOpts{
					Event:   "shop/order.cancelled",
					If:      inngestgo.StrPtr("event.data.orderId == async.data.orderId"),
					Timeout: 6 * time.Hour,
				},
			)
			if errors.Is(err, step.ErrEventNotReceived) {
				return map[string]any{"orderId": order.ID}, nil
			}
			if err != nil {
				return nil, err
			}

			// the order was cancelled, as we received a cancel event
			if _, err := step.Run(ctx, "refund-order", func(ctx context.Context) (any, error) {
				return nil, refundOrder(ctx, order)
			}); err != nil {
				return nil, err
			}
			return map[string]any{"orderId": order.ID, "status": "cancelled"}, nil
		},
	)
}

FunctionOpts.ID is required and must stay the same across deploys, because Inngest uses it to find the function's runs. CreateFunction registers the function with the client, so client.Serve() serves it with no extra step.

The handler

The handler has the signature func(ctx context.Context, input inngestgo.Input[T]) (any, error). T is the type of the event's data field, and the SDK decodes the event into it:

FieldDescription
input.EventThe event that started the run. input.Event.Data has type T.
input.EventsEvery event in the run. This has more than one event when the function uses batching.
input.InputCtxThe run's context, such as RunID, FunctionID, and Attempt.

Use any or map[string]any as T when the function doesn't read event data, for example a cron function. The value the handler returns is the run's output, and an error fails the current attempt.

Always pass the ctx that the handler receives to step tools. Steps use it to find the run's saved state.

Serve or connect your functions

Inngest runs your functions in one of two ways. Both use the same client and functions, so you can switch later without changing your function code.

OptionHow it worksUse it when
ServeInngest sends an HTTP request to an endpoint in your app for each step.Your app runs an HTTP server, including serverless platforms.
ConnectYour worker opens a WebSocket to Inngest and receives work over it.You run long-lived workers, or can't expose a public endpoint.

Serve over HTTP

Mount client.Serve() on any net/http server, usually at /api/inngest:

main.go
import (
	"log"
	"net/http"

	"github.com/inngest/inngestgo"
)

func Serve() {
	client, err := inngestgo.NewClient(inngestgo.ClientOpts{AppID: "billing"})
	if err != nil {
		log.Fatal(err)
	}

	// Create your functions with inngestgo.CreateFunction(client, ...) before
	// serving.  Each one registers itself with the client.

	// Inngest calls this endpoint to run your functions.
	http.Handle("/api/inngest", client.Serve())
	log.Fatal(http.ListenAndServe(":8080", nil))
}

In production, set INNGEST_SIGNING_KEY so the handler can verify requests from Inngest, then sync your app with its URL. Use client.ServeWithOpts to change options such as the path that Inngest calls.

Connect a worker

Call inngestgo.Connect with the clients to run. It returns once the connection is active:

worker.go
import (
	"context"
	"log"
	"os/signal"
	"syscall"

	"github.com/inngest/inngestgo"
)

func Worker() {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	client, err := inngestgo.NewClient(inngestgo.ClientOpts{AppID: "billing"})
	if err != nil {
		log.Fatal(err)
	}

	// Create your functions with inngestgo.CreateFunction(client, ...) before
	// connecting.  Each one registers itself with the client.

	// Connect opens a WebSocket to Inngest and returns once it's active.
	conn, err := inngestgo.Connect(ctx, inngestgo.ConnectOpts{
		Apps: []inngestgo.Client{client},
	})
	if err != nil {
		log.Fatal(err)
	}

	// Close finishes in-flight steps before it returns.
	<-ctx.Done()
	if err := conn.Close(); err != nil {
		log.Fatal(err)
	}
}

A Connect worker syncs itself when it connects, so you don't sync it by URL. Set INNGEST_SIGNING_KEY in production. See Connect for instance IDs, worker concurrency, and health checks.

For local development, set INNGEST_DEV=1 and run the Dev Server with either option.

Triggers

TriggerStarts a run
inngestgo.EventTrigger(name, expression)For each event named name. Pass a CEL expression, such as inngestgo.StrPtr("event.data.plan != 'free'"), to filter events, or nil for every event.
inngestgo.CronTrigger(cron)On a schedule. Prefix the expression with TZ= to set a time zone.
inngestgo.CronTriggerWithJitter(cron, jitter)On a schedule, with each run delayed by a random amount up to jitter.
inngestgo.MultipleTriggers{...}For any of the triggers in the list.
sync_account.go
import (
	"context"
	"time"

	"github.com/inngest/inngestgo"
	"github.com/inngest/inngestgo/step"
)

type AccountUpdated struct {
	AccountID string `json:"accountId"`
	Plan      string `json:"plan"`
}

func SyncAccount(client inngestgo.Client) (inngestgo.ServableFunction, error) {
	return inngestgo.CreateFunction(
		client,
		inngestgo.FunctionOpts{ID: "sync-account"},
		inngestgo.MultipleTriggers{
			// Run for each account update on a paid plan.
			inngestgo.EventTrigger("account/updated", inngestgo.StrPtr("event.data.plan != 'free'")),
			// Also run every night, spread across up to 10 minutes.
			inngestgo.CronTriggerWithJitter("TZ=America/New_York 0 2 * * *", 10*time.Minute),
		},
		func(ctx context.Context, input inngestgo.Input[AccountUpdated]) (any, error) {
			// A cron run has no event data, so AccountID is empty.
			_, err := step.Run(ctx, "sync-account", func(ctx context.Context) (any, error) {
				return nil, syncAccount(ctx, input.Event.Data.AccountID)
			})
			return nil, err
		},
	)
}

Send events that trigger functions with client.Send or client.SendMany. See sending events.

Steps

Step tools are in the step package. Each one takes the handler's ctx and a step ID:

Step toolDescription
step.RunRun code, such as an API call or a database write, and save its result. A failed step retries on its own.
step.SleepPause the run for a duration.
step.SleepUntilPause the run until a time.
step.WaitForEventPause until a matching event arrives. On timeout, it returns step.ErrEventNotReceived.
step.WaitForSignalPause until one run receives a unique signal.
step.InvokeRun another function and wait for its result.
step.Send, step.SendManySend events as a step, so a retry doesn't send them again.
step.Fetch, step.InferMake an HTTP request or an AI inference call that Inngest runs for you, so your function doesn't wait on it.
group.ParallelRun steps in parallel. It's in the group package.

A sleep or wait doesn't keep your process running. Inngest calls your function again when the wait ends, and finished steps return their saved results without running again. Put code with side effects inside a step so that it runs once.

Errors and retries

When a step or the handler returns an error, Inngest retries it with backoff. By default, each step retries 4 times. Set FunctionOpts.Retries to change this. The errors package controls retries for one error:

  • errors.NoRetryError(err) fails the step or run with no more retries.
  • errors.RetryAtError(err, at) retries at a time that you choose, for example when an API sends a Retry-After header.
build_report.go
import (
	"context"
	"fmt"
	"time"

	"github.com/inngest/inngestgo"
	inngesterrors "github.com/inngest/inngestgo/errors"
	"github.com/inngest/inngestgo/step"
)

type ReportRequested struct {
	ReportID string `json:"reportId"`
}

var errRateLimited = fmt.Errorf("rate limited")

func BuildReport(client inngestgo.Client) (inngestgo.ServableFunction, error) {
	return inngestgo.CreateFunction(
		client,
		inngestgo.FunctionOpts{ID: "build-report"},
		inngestgo.EventTrigger("report/requested", nil),
		func(ctx context.Context, input inngestgo.Input[ReportRequested]) (any, error) {
			if input.Event.Data.ReportID == "" {
				// The event can never succeed, so don't retry.
				return nil, inngesterrors.NoRetryError(fmt.Errorf("reportId is required"))
			}

			return step.Run(ctx, "build-report", func(ctx context.Context) (Report, error) {
				report, err := buildReport(ctx)
				if err == errRateLimited {
					// Retry this step in 5 minutes instead of on the default backoff.
					return Report{}, inngesterrors.RetryAtError(err, time.Now().Add(5*time.Minute))
				}
				// Any other error retries the step with backoff.
				return report, err
			})
		},
	)
}

When a step runs out of retries, step.Run returns the error to the handler. Return it to fail the run, or handle it, for example to roll back earlier steps. See error handling for failure handlers and more.

Function options

FunctionOpts configures retries, flow control, cancellation, and timeouts:

charge_invoice.go
import (
	"context"
	"time"

	"github.com/inngest/inngestgo"
	"github.com/inngest/inngestgo/step"
)

type InvoiceCreated struct {
	InvoiceID string `json:"invoiceId"`
	AccountID string `json:"accountId"`
}

func ChargeInvoice(client inngestgo.Client) (inngestgo.ServableFunction, error) {
	return inngestgo.CreateFunction(
		client,
		inngestgo.FunctionOpts{
			ID:   "charge-invoice",
			Name: "Charge invoice",
			// Retry each failed step up to 10 times.
			Retries: inngestgo.IntPtr(10),
			// Run one invoice at a time for each account.
			Concurrency: &inngestgo.ConfigConcurrency{
				Fn: []inngestgo.ConfigFnConcurrency{
					{Limit: 1, Key: inngestgo.StrPtr("event.data.accountId")},
				},
			},
			// Start at most 100 runs a minute.
			Throttle: &inngestgo.ConfigThrottle{Limit: 100, Period: time.Minute},
			// Cancel the run when the invoice is voided.
			Cancel: []inngestgo.ConfigCancel{
				{Event: "invoice/voided", If: inngestgo.StrPtr("event.data.invoiceId == async.data.invoiceId")},
			},
			// Cancel the run if it doesn't finish within an hour.
			Timeouts: &inngestgo.ConfigTimeouts{Finish: inngestgo.Ptr(time.Hour)},
		},
		inngestgo.EventTrigger("invoice/created", nil),
		func(ctx context.Context, input inngestgo.Input[InvoiceCreated]) (any, error) {
			invoice, err := step.Run(ctx, "load-invoice", func(ctx context.Context) (Invoice, error) {
				return loadInvoice(ctx, input.Event.Data.InvoiceID)
			})
			if err != nil {
				return nil, err
			}
			_, err = step.Run(ctx, "charge", func(ctx context.Context) (any, error) {
				return nil, chargeInvoice(ctx, invoice)
			})
			return nil, err
		},
	)
}
FieldTypeDescription
IDstringRequired. The function ID.
NamestringA name for the dashboard.
Retries*intThe number of retries for each step. See retries.
Concurrency*ConfigConcurrencyLimits on running runs (Fn) or steps (Step), with an optional key. See concurrency.
Throttle*ConfigThrottleLimit how often runs start, and queue the rest. See throttling.
RateLimit*ConfigRateLimitLimit how often runs start, and skip the rest. See rate limiting.
Debounce*ConfigDebounceWait for events to stop before a run starts. See debounce.
BatchEvents*ConfigBatchEventsStart one run for many events. See batching.
Singleton*ConfigSingletonAllow one run at a time for each key. See singleton.
Priority*ConfigPriorityRun some runs before others. See priority.
Idempotency*stringA CEL expression. Only one run starts for each value in 24 hours. See idempotency.
Cancel[]ConfigCancelEvents that cancel a run. See cancel on events.
Timeouts*ConfigTimeoutsCancel a run that doesn't start or finish in time. See timeouts.
Checkpoint*checkpoint.ConfigRun steps with less latency between them. See checkpointing.

Each Config* type is in the inngestgo package. See pkg.go.dev for every field.