Durable Endpoints in Go

Run net/http handlers as Inngest functions with the stephttp package.

Durable Endpoints (beta) run a net/http handler as an Inngest function. The handler calls step.Run and the other step tools, and Inngest records each request as a run with a trace. The client gets the handler's response directly, with no wait for Inngest. You don't need a client or Serve() for Durable Endpoints. The stephttp package handles requests from Inngest itself.

Set up the provider

Create one provider when the process starts with stephttp.Setup, and use it for every handler:

steps.go
import "github.com/inngest/inngestgo/stephttp"

// Create one provider when the process starts, and use it for every handler.
var steps = stephttp.Setup(stephttp.SetupOpts{})

Setup reads INNGEST_SIGNING_KEY, INNGEST_SIGNING_KEY_FALLBACK, INNGEST_ENV, and INNGEST_DEV from the environment. Set INNGEST_DEV=1 to send runs to the local Dev Server. You can also set each value in stephttp.SetupOpts.Optional.

Next, choose how handlers become functions, and wait for runs at shutdown.

Choose how handlers become functions

The provider can turn a handler into a Durable Endpoint in three ways, depending on your setup:

MethodUse it when
Handle and HandleFuncYou register each route yourself. Every request to the route is a function.
Middleware and ConfigureYou want one setup for a whole router, such as chi or a wrapped http.ServeMux. Each handler opts in with Configure.
StartThe provider can't wrap the handler, for example a route that generated code registers.

Wrap handlers with Handle or HandleFunc

Handle wraps an http.Handler and HandleFunc wraps a handler function. Both take the function's FnOpts:

orders.go
import (
	"context"
	"encoding/json"
	"net/http"

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

// steps is the provider from stephttp.Setup, created once at startup.
func RegisterRoutes(mux *http.ServeMux, steps stephttp.Provider) {
	mux.HandleFunc("POST /api/orders", steps.HandleFunc(stephttp.FnOpts{}, handleOrder))
}

func handleOrder(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	var body struct {
		OrderID string `json:"orderId"`
	}
	_ = json.NewDecoder(r.Body).Decode(&body)

	if body.OrderID == "" {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		_ = json.NewEncoder(w).Encode(map[string]string{"error": "orderId is required"})
		return
	}

	order, err := step.Run(ctx, "load-order", func(ctx context.Context) (Order, error) {
		return loadOrder(ctx, body.OrderID)
	})
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	reservation, err := step.Run(ctx, "reserve-inventory", func(ctx context.Context) (Reservation, error) {
		return reserveInventory(ctx, order)
	})
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"orderId":     body.OrderID,
		"reservation": reservation,
	})
}

The function ID is the route pattern, POST /api/orders.

Add middleware to a router

Put Middleware in front of many handlers. A handler behind it is a function only after it calls stephttp.Configure. Until then, its steps run with no run and the request makes no calls to Inngest:

router.go
import (
	"context"
	"encoding/json"
	"net/http"

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

func Router(steps stephttp.Provider) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /accounts/{id}/credits", listCredits)
	mux.HandleFunc("GET /healthz", healthz)

	// Wrap the whole router. With chi, use r.Use(steps.Middleware(...)).
	return steps.Middleware(stephttp.MiddlewareOpts{})(mux)
}

// healthz never calls Configure, so it isn't a function and makes no calls to Inngest.
func healthz(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}

func listCredits(w http.ResponseWriter, r *http.Request) {
	// Opt in on the first line, before the handler reads the request body.
	stephttp.Configure(r.Context(), func(o *stephttp.FnOpts) {
		o.ID = "list-credits"
	})

	credits, err := step.Run(r.Context(), "load-credits", func(ctx context.Context) (Credits, error) {
		return loadCredits(ctx, r.PathValue("id"))
	})
	if err != nil {
		http.Error(w, "could not load credits", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(credits)
}

Call Configure on the handler's first line, before it reads the request body. Behind Middleware, the request body is copied only from the point where the handler calls Configure. If a handler must read the body first, for example a GraphQL handler that parses the operation name, set MiddlewareOpts.RecordRequestBodies on the routes that need it. This copies every request body behind the middleware.

An async step such as step.Sleep in a handler that hasn't called Configure returns a 500, because no run exists to continue it.

Start a function inside a handler

Call Start on the first line of a handler that the provider can't wrap. Use the w and r that it returns for the rest of the handler, and always defer end():

credits.go
import (
	"context"
	"encoding/json"
	"net/http"

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

type API struct {
	steps stephttp.Provider
}

// GetCredits is registered by generated code, so the provider can't wrap it.
func (a API) GetCredits(w http.ResponseWriter, r *http.Request, accountID string) {
	// Use the returned w and r in the rest of the handler, and always defer end.
	w, r, end := a.steps.Start(w, r, stephttp.FnOpts{ID: "get-credits"})
	defer end()

	credits, err := step.Run(r.Context(), "load-credits", func(ctx context.Context) (Credits, error) {
		return loadCredits(ctx, accountID)
	})
	if err != nil {
		http.Error(w, "could not load credits", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(credits)
}

Without defer end(), a finished run isn't sent to Inngest, and an async step or a resume request from Inngest panics up to net/http. If Inngest resumes a run whose saved steps can't load, Start writes a 500 so that Inngest sends the request again. The handler still runs, but its steps don't execute, its request context is cancelled, and its writes are dropped.

Wait for runs at shutdown

Each finished run is sent to Inngest in the background after the response. Call Wait when the process shuts down, after the server stops accepting requests. A run that is still being sent when the process exits is lost:

shutdown.go
import (
	"context"
	"net/http"

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

func Shutdown(ctx context.Context, srv *http.Server, steps stephttp.Provider) error {
	// Stop accepting requests, and let in-flight requests finish.
	err := srv.Shutdown(ctx)

	// Wait for finished runs to reach Inngest, or for ctx to end.
	select {
	case <-steps.Wait(ctx):
	case <-ctx.Done():
	}
	return err
}

Configure a function

FnOpts configures one function. Handle, HandleFunc, and Start take it at the start, and stephttp.Configure changes it during a request. Configure only changes the fields that its function sets:

stephttp.Configure(r.Context(), func(o *stephttp.FnOpts) {
	o.ID = "gql/" + operationName
	o.OmitResponseBody = true
})
FieldDescription
IDThe function ID. If empty, this is the http.ServeMux pattern that routed the request, such as POST /users/{id}.
TrackAllRequestsCreate a run for every request, even when the handler runs no steps. By default, only requests that run a step create a run.
OmitRequestBodyDon't store the request body with the run. Set it in Handle, HandleFunc, or Start so that a body with secrets is never copied.
OmitResponseBodyDon't store the response body with the run. Configure can change it before the response is written, for example to store only errors.
MaxRequestBodySize, MaxResponseBodySizeThe number of body bytes stored with the run. Both default to 1 MiB. The handler always reads the full request, and the client always gets the full response.
AsyncResponseThe response the client gets when the run goes async. See runs and async steps.

Set ID when a router other than http.ServeMux routes the request, such as chi, or when generated code calls the handler. Without a pattern, Inngest builds the ID from the method and path, and each value in a path such as /users/123 creates a separate function. The SDK logs a warning once when this happens.

A change to ID after the run starts doesn't apply. Configure does nothing in a handler that the provider doesn't wrap.

Runs and async steps

A request gets a run when its first step runs, or at the start when TrackAllRequests is set. A request without a run sends no x-run-id header and makes no call to Inngest.

An async step, such as step.Sleep or step.WaitForEvent, makes the run continue in Inngest. The client gets the function's AsyncResponse:

TypeResponse
stephttp.AsyncResponseRedirect{}Default. A 303 redirect to an Inngest URL that waits for the run and returns its result. Set URL to redirect to your own URL instead.
stephttp.AsyncResponseToken{}A JSON body with run_id and token, which a client can use to fetch the result.
stephttp.AsyncResponseCustom(handler)Your own http.HandlerFunc writes the response.

Set a default for every function with stephttp.SetupOpts.Optional.DefaultAsyncResponse. See requesting a Durable Endpoint for how clients follow the redirect.