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:
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:
| Field | Description |
|---|---|
input.Event | The event that started the run. input.Event.Data has type T. |
input.Events | Every event in the run. This has more than one event when the function uses batching. |
input.InputCtx | The 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.
| Option | How it works | Use it when |
|---|---|---|
| Serve | Inngest sends an HTTP request to an endpoint in your app for each step. | Your app runs an HTTP server, including serverless platforms. |
| Connect | Your 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:
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:
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
| Trigger | Starts 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. |
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 tool | Description |
|---|---|
step.Run | Run code, such as an API call or a database write, and save its result. A failed step retries on its own. |
step.Sleep | Pause the run for a duration. |
step.SleepUntil | Pause the run until a time. |
step.WaitForEvent | Pause until a matching event arrives. On timeout, it returns step.ErrEventNotReceived. |
step.WaitForSignal | Pause until one run receives a unique signal. |
step.Invoke | Run another function and wait for its result. |
step.Send, step.SendMany | Send events as a step, so a retry doesn't send them again. |
step.Fetch, step.Infer | Make an HTTP request or an AI inference call that Inngest runs for you, so your function doesn't wait on it. |
group.Parallel | Run 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 aRetry-Afterheader.
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:
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
},
)
}| Field | Type | Description |
|---|---|---|
ID | string | Required. The function ID. |
Name | string | A name for the dashboard. |
Retries | *int | The number of retries for each step. See retries. |
Concurrency | *ConfigConcurrency | Limits on running runs (Fn) or steps (Step), with an optional key. See concurrency. |
Throttle | *ConfigThrottle | Limit how often runs start, and queue the rest. See throttling. |
RateLimit | *ConfigRateLimit | Limit how often runs start, and skip the rest. See rate limiting. |
Debounce | *ConfigDebounce | Wait for events to stop before a run starts. See debounce. |
BatchEvents | *ConfigBatchEvents | Start one run for many events. See batching. |
Singleton | *ConfigSingleton | Allow one run at a time for each key. See singleton. |
Priority | *ConfigPriority | Run some runs before others. See priority. |
Idempotency | *string | A CEL expression. Only one run starts for each value in 24 hours. See idempotency. |
Cancel | []ConfigCancel | Events that cancel a run. See cancel on events. |
Timeouts | *ConfigTimeouts | Cancel a run that doesn't start or finish in time. See timeouts. |
Checkpoint | *checkpoint.Config | Run steps with less latency between them. See checkpointing. |
Each Config* type is in the inngestgo package. See pkg.go.dev for every field.