Inngest in Azure Functions
Inngest.AzureFunctions serves the Inngest endpoint from an HTTP-triggered function of an Azure Functions app in the
isolated worker model. You
write one short function in your project and call ServeInngestAsync in it. The package answers introspection, syncs
and calls, and checks signatures, as MapInngest does in an ASP.NET Core app. It works with both HTTP models of the
worker: the built-in one (HttpRequestData and HttpResponseData) and the ASP.NET Core integration
(ConfigureFunctionsWebApplication).
The functions are the same as everywhere else; only the way Inngest reaches them changes. An Azure Functions app limits how long one HTTP call can take, which shapes how you run long steps. See The 230-second limit.
Set it up
Section titled “Set it up”Start from a Functions project for the isolated worker, such as the one func init --worker-runtime dotnet-isolated
creates. It needs version 2.0 or later of Microsoft.Azure.Functions.Worker, and the
Microsoft.Azure.Functions.Worker.Extensions.Http package for the HttpTrigger attribute. Install
Inngest.AzureFunctions from NuGet. It brings in Inngest and Inngest.AspNetCore:
dotnet add package Inngest.AzureFunctionsLike Inngest.AspNetCore, the package references the ASP.NET Core shared framework, so the runtime that runs the app
needs it, as it does for the worker’s own ASP.NET Core integration. An app that uses the built-in HTTP model needs it
now too.
A function is a class, as in Getting started. It is resolved from the services of the invocation, so it can take what you registered:
using Inngest;using Microsoft.Extensions.Logging;
public sealed record UserSignedUp(string Email);
[InngestFunction("send-welcome-email")][EventTrigger("user/signed.up")]public sealed class SendWelcomeEmail(ILogger<SendWelcomeEmail> logger) : InngestFunction<UserSignedUp>{ public override async Task<object?> ExecuteAsync(FunctionContext<UserSignedUp> context, CancellationToken cancellationToken) { var message = await context.Step.RunAsync("compose", () => $"Welcome, {context.Event.Data.Email}!"); await context.Step.SleepAsync("wait", TimeSpan.FromMinutes(10)); await context.Step.RunAsync("send", () => logger.LogInformation("{Message}", message)); return message; }}Program.cs registers the SDK like any other app. There is no MapInngest: the Functions host routes requests to
functions.
using Inngest;using Microsoft.Azure.Functions.Worker.Builder;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction<SendWelcomeEmail>();
builder.Build().Run();ConfigureFunctionsWebApplication turns on the ASP.NET Core integration, which needs the
Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore package. Leave it out to use the built-in model.
The two models differ in one respect that matters here.
The function that serves Inngest is yours to write, because its name, route and authorization level are yours to choose. This is all of it:
using Microsoft.Azure.Functions.Worker;using Microsoft.Azure.Functions.Worker.Http;
public sealed class InngestEndpoint{ [Function("inngest")] public Task<HttpResponseData> RunAsync( [HttpTrigger(AuthorizationLevel.Anonymous, "get", "put", "post", Route = "inngest")] HttpRequestData request) => request.ServeInngestAsync();}- All three methods. Inngest uses
GETfor introspection,PUTto sync the app andPOSTto call functions. AuthorizationLevel.Anonymous. Inngest sends no function key. It signs its requests with your signing key. In cloud mode the SDK refuses a call, an in-band sync or an introspection request whose signature does not verify, before it does anything. An out-of-band sync is the exception, which the SDK accepts without a signature by default, as the TypeScript SDK does. See The URL Inngest calls for what to set on a public endpoint.- The route. The Functions host puts its route prefix,
apiby default, in front ofinngest, so Inngest calls/api/inngest, the path it uses by default. Setextensions.http.routePrefixinhost.jsonto change the prefix, or to an empty string to remove it. See the HTTP trigger reference. HttpRequestDatais the one parameter. It carries theFunctionContext, so the SDK runs your functions in the services of the invocation and cancels them when the host cancels the invocation.
Run it locally
Section titled “Run it locally”Put the settings in local.settings.json, next to the ones the template created. INNGEST_DEV=1 runs the SDK in dev
mode, where it contacts the dev server at http://localhost:8288 and verifies nothing:
{ "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", "INNGEST_DEV": "1" }}Start the app with Azure Functions Core Tools, which listens on port 7071 unless told otherwise:
func startStart the Inngest dev server and point it at the function, with npx, or with Docker when Node.js is not installed:
npx --ignore-scripts=false inngest-cli@latest dev -u http://localhost:7071/api/inngestdocker run --rm -p 8288:8288 inngest/inngest:v1.49.0 inngest dev -u http://host.docker.internal:7071/api/inngestOpen http://localhost:8288. The app and its functions are listed once the dev server has synced it, and sending an
event from its UI starts a run. The container reaches the app at host.docker.internal, since localhost inside it
is the container itself.
Run it in Azure
Section titled “Run it in Azure”Do not set INNGEST_DEV in Azure; cloud mode is the default. Set the keys from the Inngest dashboard as application
settings, or as Key Vault references, which reach the SDK like any other setting:
| Application setting | Value |
|---|---|
INNGEST_SIGNING_KEY |
The signing key of the environment |
INNGEST_EVENT_KEY |
The event key, to send events from the app |
Then add the app in the Inngest dashboard with the URL of the function, such as
https://shop.azurewebsites.net/api/inngest, and sync again after each deploy. A sync from the dashboard is in-band:
Inngest sends the URL it called in a signed request, and the SDK registers that one.
Deploying also shows a curl -X PUT from a deploy pipeline. That is an
out-of-band sync, and the next section says what to set for it.
To check that the function is reachable, send a GET to it. In dev mode it answers with the introspection body; in
cloud mode it answers an unsigned request with a 401.
The URL Inngest calls
Section titled “The URL Inngest calls”A sync that starts from the app’s side, such as curl -X PUT https://shop.azurewebsites.net/api/inngest from a
deploy pipeline, is out-of-band: the SDK registers the URL the request arrived on and posts its configuration to the
Inngest it is configured for. By default it accepts such a sync without a signature. The function is anonymous and
public, so anyone can send one, and unless ServeOrigin is set, the URL it registers comes from the request, which
the sender chooses.
What the app sees as that address is what the Functions host saw. In both HTTP models the worker is given the scheme,
host name, path and query of the host’s own request, the ASP.NET Core integration through the X-Forwarded-* headers
that the host sends and the integration applies. The host’s source corrects the scheme and the host name from the
front end’s headers (X-Forwarded-Proto and Disguised-Host) only on Linux Consumption and Linux Dedicated plans.
Nothing in it does that on Windows plans, or in a container behind TLS termination, where the address can carry the
scheme http or the host name of the hop before the app. The path has the route prefix in it, so it reads
/api/inngest unless you changed the prefix.
So in production, say where Inngest should call, and the path too if a gateway such as Azure Front Door, API
Management or Application Gateway changes it, in the settings or as INNGEST_SERVE_ORIGIN and INNGEST_SERVE_PATH:
options.ServeOrigin = new Uri("https://api.shop.example.com");options.ServePath = "/inngest";With the origin fixed, an unsigned sync can only make the SDK register that URL again. To refuse unsigned syncs
altogether, turn them off, in the settings or with INNGEST_ENABLE_UNAUTHED_SYNC=false:
options.AllowUnsignedSync = false;Then a curl -X PUT from a pipeline is answered with a 401, and you sync from the Inngest dashboard, whose syncs are
in-band and signed. See Syncs and
Behind a proxy. The other settings are the same as in any
app: Configuration.
The 230-second limit
Section titled “The 230-second limit”An HTTP-triggered function has to answer within 230 seconds, whatever its functionTimeout. After that the Azure
load balancer times the request out and returns a 502; the function goes on running but can no longer answer.
Microsoft’s HTTP trigger reference
describes the limit. Inngest calls the endpoint once for each step, or once for several steps with checkpointing, so
the limit is on one call, not on a run, which can last days. A call that hits it is a failed attempt, and Inngest
retries the step, which starts again from its beginning.
Choose by how long your longest step is:
- Steps well under the limit. Nothing to do. Split long work into more steps.
- Many short steps. Checkpointing runs consecutive steps in one call and saves each to Inngest as it completes,
which saves round trips.
MaxRuntimeis how long a call may keep starting steps, 10 seconds when you leave it out. A call lasts up toMaxRuntimeplus the step it is running, so keep the two together well below 230 seconds. - A step that takes longer than the limit. A plain response cannot outlast it. Use streaming where the response is really streamed (below), or move the work out of the request: start it from the step on a queue-triggered function or another worker, and let the run wait for an event that the worker sends when it is done. Connect suits a worker that stays up, such as a container; a plan that scales to zero does not keep the connection Connect needs.
Checkpointing is set for the whole app, or for one function with FunctionOptions.Checkpointing:
options.Checkpointing = new CheckpointingOptions { MaxRuntime = TimeSpan.FromSeconds(30) };Streaming
Section titled “Streaming”With streaming on, a call still running after three seconds is answered with a 201 at once, then with a space every three seconds, then with its result, which keeps traffic on the connection (see Behind a proxy):
options.Streaming = true;That only helps when the bytes reach Inngest as they are written. With the ASP.NET Core integration they leave the
worker as they are written: the host forwards the request to the worker’s Kestrel with YARP’s forwarder, which passes
the response on as it arrives, and the SDK answers through that request, as MapInngest does.
Microsoft’s announcement
of the integration names streaming content types among what it enables. With the built-in model they do not: the
worker keeps the response until the function returns and then hands it to the host, so a long call stays silent. The
SDK still answers normally there, and logs a warning, once per app, if Streaming is on.
Whether streaming carries a call past the 230 seconds on your plan has not been tested. Microsoft words the limit as the function not completing in time, and its App Service article calls it the load balancer’s idle timeout. Whether the spaces count as activity there is for you to test, with a step longer than the limit, before you rely on it.
The two HTTP models
Section titled “The two HTTP models”| Built-in | ASP.NET Core integration | |
|---|---|---|
| Registration | ConfigureFunctionsWorkerDefaults |
ConfigureFunctionsWebApplication, with the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore package |
| The response | Kept by the worker until the function returns | Written to the request while the function runs |
| Streaming | No effect | Works as in any ASP.NET Core app |
| The request body | Received whole by the host; a chunked request without a Content-Length arrives empty |
Read from the request as it arrives |
Use HttpRequestData and HttpResponseData, as above, in both. In the ASP.NET Core integration the SDK writes the
answer to the response of the request itself, and the HttpResponseData the function returns is the integration’s
wrapper of that response with nothing added to it.
Inngest’s calls and syncs declare the length of their body, so the built-in model’s rule about the body does not affect them. Behind a proxy that re-chunks requests, a call would arrive empty and the SDK would answer it with a 401.
What is not supported
Section titled “What is not supported”- The in-process model. The package serves the isolated worker only.
- Streaming in the built-in model, for the reason above.
- A function key. Inngest sends none, so the function stays anonymous; the signature check is what protects it in cloud mode. In dev mode nothing is verified, so keep dev mode on your machine.
- Native AOT. Microsoft’s isolated worker guide documents
ReadyToRun, not
Native AOT, and the worker’s libraries are not marked as trimming or AOT compatible. The code of
Inngest.AzureFunctionsis trim and AOT safe, so it adds no warnings to an app that trims. For a web app published as a Native AOT binary, see Native AOT. - .NET Framework. The package targets
net8.0andnet10.0, like the others.
What has been tested
Section titled “What has been tested”The package is tested against fakes of the worker’s request, response and function context, written from the worker’s source, and against the Inngest server in Docker: a self-hosted server that signs its requests, for syncs, signed calls and signed answers through the built-in model, and the dev server, for streaming through the ASP.NET Core integration. In those tests an app stands in for the Functions host. It has not been run in a deployed function app, so how the host and the load balancer behave, as described above, comes from Microsoft’s documentation and from the source of the Functions host and worker.
inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.