Skip to content

Recipe Azure Functions Http

Mark Lauter edited this page Jun 13, 2026 · 5 revisions

Recipe: Azure Functions HTTP (isolated worker)

An Azure Function in the .NET isolated worker model, triggered by an HTTP request, that receives an HttpRequestData, runs it through a Plumber pipeline, and returns an HttpResponseData. The pipeline shape is (HttpRequestData, HttpResponseData).

The interesting wrinkle is the isolated worker's own DI host. The isolated worker runs your code in a separate .NET process with its own IHostBuilder. Plumber can either build its own DI container alongside the worker host, or — preferred — register as a singleton inside the worker's DI and reuse the host's services. This recipe leads with the host-mode approach.

Cold start matters too. The worker process is recycled after periods of inactivity, and a Plumber handler held by DI lives for the lifetime of the worker process. Build it once, share it across every invocation.

Project setup

Start from the isolated worker template:

dotnet new install Microsoft.Azure.Functions.Worker.ProjectTemplates
dotnet new func --worker-runtime dotnetIsolated --name MyApi.Functions --target-framework net10.0
cd MyApi.Functions

Add Plumber:

dotnet add package MSL.Plumber.Pipeline

The template already pulls in:

  • Microsoft.Azure.Functions.Worker
  • Microsoft.Azure.Functions.Worker.Sdk
  • Microsoft.Azure.Functions.Worker.Extensions.Http

A representative MyApi.Functions.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.0.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
    <PackageReference Include="MSL.Plumber.Pipeline" Version="3.*" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json"><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></None>
    <None Update="local.settings.json"><CopyToPublishDirectory>Never</CopyToPublishDirectory></None>
  </ItemGroup>
</Project>

The template-generated host.json and local.settings.json are unchanged.

The pipeline

Two production patterns:

  • Host-mode (recommended) — register the handler as a singleton in the worker's DI so every middleware sees the same IConfiguration, ILoggerFactory, and registered services as the rest of your worker code.
  • Standalone — build a separate Plumber handler with its own DI when the pipeline is genuinely independent of the host.

Host-mode pipeline (recommended)

The Pipeline class only knows how to add middleware — there is no builder, because the DI container is owned by the worker host.

using Microsoft.Azure.Functions.Worker.Http;
using Plumber;

namespace MyApi.Functions;

internal static class Pipeline
{
    public static RequestHandler<HttpRequestData, HttpResponseData> Configure(
        RequestHandler<HttpRequestData, HttpResponseData> handler) =>
        handler
            .Use<ErrorBoundaryMiddleware>()
            .Use<RouteExtractionMiddleware>()
            .Use<AuthenticationMiddleware>()
            .Use<GreetingMiddleware>();
}

The handler is created from the host's IServiceProvider via the RequestHandler.Create static factory — see the entry point section below.

Standalone pipeline (alternative)

To isolate the pipeline's DI from the worker host's, fall back to RequestHandlerBuilder:

internal static class Pipeline
{
    public static RequestHandlerBuilder<HttpRequestData, HttpResponseData> CreateBuilder() =>
        RequestHandlerBuilder
            .Create<HttpRequestData, HttpResponseData>()
            .AddDefaultConfigurationSources()
            .ConfigureLogging(logging => logging.AddConsole())
            .ConfigureServices((services, configuration) => services
                .AddSingleton<IGreetingService, GreetingService>());

    public static RequestHandler<HttpRequestData, HttpResponseData> Configure(
        RequestHandler<HttpRequestData, HttpResponseData> handler) =>
        handler
            .Use<ErrorBoundaryMiddleware>()
            .Use<RouteExtractionMiddleware>()
            .Use<GreetingMiddleware>();
}

This works, at the cost of duplicating service registrations between the worker host and the Plumber builder. Reach for it when the pipeline is genuinely independent of the host — for example, a queue-trigger Function whose pipeline you also want to run from a CLI test harness with its own DI.

Middleware

Four middleware:

  • ErrorBoundaryMiddleware — catches exceptions and produces a 500 response
  • RouteExtractionMiddleware — pulls path and method into context.Data
  • AuthenticationMiddleware — verifies a bearer JWT, short-circuits with 401
  • GreetingMiddleware — the terminal business logic

ErrorBoundaryMiddleware

Catches everything, logs it, and produces a 500 response so the Functions host always returns something well-formed.

using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Plumber;
using System.Net;

namespace MyApi.Functions;

internal sealed class ErrorBoundaryMiddleware(
    RequestMiddleware<HttpRequestData, HttpResponseData> next,
    ILogger<ErrorBoundaryMiddleware> logger)
{
    public async Task InvokeAsync(RequestContext<HttpRequestData, HttpResponseData> context)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "request {Id} failed", context.Id);
            var response = context.Request.CreateResponse(HttpStatusCode.InternalServerError);
            await response.WriteStringAsync("{\"error\":\"internal server error\"}");
            response.Headers.Add("Content-Type", "application/json");
            context.Response = response;
        }
    }
}

HttpRequestData.CreateResponse is the canonical factory for HttpResponseData in the isolated worker — it ties the response to the originating request so the worker's framework can flush it correctly.

RouteExtractionMiddleware

Extracts the path and method into context.Data so downstream middleware reads keyed values.

using Microsoft.Azure.Functions.Worker.Http;
using Plumber;

namespace MyApi.Functions;

internal sealed class RouteExtractionMiddleware(
    RequestMiddleware<HttpRequestData, HttpResponseData> next)
{
    public Task InvokeAsync(RequestContext<HttpRequestData, HttpResponseData> context)
    {
        var request = context.Request;
        context.Data["route.path"] = request.Url.AbsolutePath;
        context.Data["route.method"] = request.Method;
        return next(context);
    }
}

AuthenticationMiddleware

Short-circuits with a 401 when the bearer token is missing or invalid. The verifier is method-injected so it picks up the per-request scope.

using Microsoft.Azure.Functions.Worker.Http;
using Plumber;
using System.Net;

namespace MyApi.Functions;

internal sealed class AuthenticationMiddleware(
    RequestMiddleware<HttpRequestData, HttpResponseData> next)
{
    public async Task InvokeAsync(
        RequestContext<HttpRequestData, HttpResponseData> context,
        IJwtVerifier verifier)
    {
        if (!TryGetBearerToken(context.Request, out var token)
            || !verifier.TryVerify(token, out var subject))
        {
            var response = context.Request.CreateResponse(HttpStatusCode.Unauthorized);
            await response.WriteStringAsync("{\"error\":\"unauthorized\"}");
            response.Headers.Add("Content-Type", "application/json");
            context.Response = response;
            return; // short-circuit
        }

        context.Data["auth.subject"] = subject;
        await next(context);
    }

    private static bool TryGetBearerToken(HttpRequestData request, out string token)
    {
        token = string.Empty;
        if (!request.Headers.TryGetValues("Authorization", out var values)) return false;
        var header = values.FirstOrDefault() ?? string.Empty;
        if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return false;
        token = header["Bearer ".Length..].Trim();
        return token.Length > 0;
    }
}

GreetingMiddleware

The terminal business logic. By the time it runs, the path is in context.Data and the authenticated subject is too.

using Microsoft.Azure.Functions.Worker.Http;
using Plumber;
using System.Net;
using System.Text.Json;

namespace MyApi.Functions;

internal sealed class GreetingMiddleware(
    RequestMiddleware<HttpRequestData, HttpResponseData> next)
{
    public async Task InvokeAsync(
        RequestContext<HttpRequestData, HttpResponseData> context,
        IGreetingService greetings)
    {
        var path = (string)context.Data["route.path"]!;
        var subject = context.TryGetValue<string>("auth.subject", out var s) ? s : "anonymous";

        if (path != "/api/greet")
        {
            var notFound = context.Request.CreateResponse(HttpStatusCode.NotFound);
            await notFound.WriteStringAsync("{\"error\":\"not found\"}");
            notFound.Headers.Add("Content-Type", "application/json");
            context.Response = notFound;
            await next(context);
            return;
        }

        var greeting = greetings.For(subject);
        var response = context.Request.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "application/json");
        await response.WriteStringAsync(JsonSerializer.Serialize(new { greeting }));
        context.Response = response;
        await next(context);
    }
}

The entry point

Two pieces: a Program.cs that builds the host, and a [Function]-attributed method that handles the trigger. Plumber slots into both.

Program.cs — register the handler as a singleton

Build the Plumber handler from the host's IServiceProvider and register it as a singleton. Configure it inside the registration so the middleware list is fixed at construction time.

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MyApi.Functions;
using Plumber;

var builder = FunctionsApplication.CreateBuilder(args);

builder.ConfigureFunctionsWebApplication();

builder.Services
    .AddSingleton<IJwtVerifier, HmacJwtVerifier>()
    .AddSingleton<IGreetingService, GreetingService>()
    .AddSingleton(sp => Pipeline.Configure(
        RequestHandler.Create<HttpRequestData, HttpResponseData>(sp)));

builder.Build().Run();

RequestHandler.Create<TReq, TRes>(serviceProvider) is the host-mode factory — Plumber reuses the supplied provider rather than building its own. The handler does not own the provider. When the host shuts down, the worker disposes the provider and the handler's disposal is a no-op.

The Function class

The Function method takes the registered handler from DI and delegates to it.

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Plumber;

namespace MyApi.Functions;

public sealed class GreetingFunction(RequestHandler<HttpRequestData, HttpResponseData> handler)
{
    [Function(nameof(Greet))]
    public async Task<HttpResponseData> Greet(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "{*path}")]
        HttpRequestData request,
        FunctionContext functionContext)
    {
        var response = await handler.InvokeAsync(request, functionContext.CancellationToken);
        return response ?? request.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
    }
}

The Route = "{*path}" catch-all sends every request through Plumber; the route extraction middleware decides what to do with the path. To use standard [HttpTrigger] per-route routing instead, declare one Function per route and let each forward into a shared pipeline (or into separate pipelines per route).

FunctionContext.CancellationToken is the worker's cancellation signal — flowing it through to InvokeAsync lets your middleware honour shutdown and per-invocation cancellation.

Cold-start and lifetime considerations

The Plumber handler lives as a singleton in the worker's DI container. The worker process is long-lived — Functions hosts only recycle on idle timeout, deployment, or scale events — so the handler is built once and reused for every invocation on the same worker.

Cold-start cost in the host-mode setup:

  • The worker starts the host, which builds the DI container and resolves the singleton handler. Plumber wires up its component list at this point, but the actual middleware pipeline is built lazily on the first InvokeAsync call.
  • The first request after a cold start pays the lazy pipeline build (one expression-tree compile per class middleware). Subsequent requests are warm.

Things that help:

  • Keep singleton constructors lightweight. Network calls in IGreetingService's constructor add to cold-start latency on every container.
  • Eagerly resolve the handler at startup to move the lazy pipeline build off the first request: var _ = serviceProvider.GetRequiredService<RequestHandler<HttpRequestData, HttpResponseData>>(); followed by a noop InvokeAsync warms it.
  • Premium and Dedicated plans keep the worker resident, so cold starts are rare. On Consumption, every scale-out event pays it once.

Error handling

ErrorBoundaryMiddleware catches every exception and produces a 500. Two finer-grained patterns are worth knowing.

Distinguishing handler timeout from caller cancellation

Pass a CancellationTokenSource linked to a per-request budget:

using var cts = CancellationTokenSource.CreateLinkedTokenSource(functionContext.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(20));
var response = await handler.InvokeAsync(request, cts.Token);

In the error boundary, separate the catches:

catch (TimeoutException) { /* Plumber's handler-wide timeout fired */ }
catch (OperationCanceledException) { /* host or per-request budget cancelled */ }

Set the handler-wide timeout at construction:

builder.Services.AddSingleton(sp => Pipeline.Configure(
    RequestHandler.Create<HttpRequestData, HttpResponseData>(sp, TimeSpan.FromSeconds(30))));

Per-route validation

Earlier middleware can short-circuit with a 400 by setting context.Response and returning without calling next.

Testing the pipeline

The Function class is a thin shim. PlumberApplicationFactory<TReq, TRes> builds the real pipeline once and lets you swap selected services for stubs — construct an HttpRequestData (the isolated worker has helpers in Microsoft.Azure.Functions.Worker.Testing, or fake one) and invoke the factory. See Testing and PlumberApplicationFactory for the full surface.

using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Plumber.Testing;
using Xunit;

namespace MyApi.Functions.Tests;

public sealed class GreetingPipelineTests
{
    [Fact]
    public async Task ValidRequestReturns200Async()
    {
        // Test-only standalone pipeline that mirrors Configure() without the host.
        using var factory = new PlumberApplicationFactory<HttpRequestData, HttpResponseData>(
                _ => RequestHandlerBuilder
                    .Create<HttpRequestData, HttpResponseData>()
                    .ConfigureServices((services, _) => services
                        .AddSingleton<IGreetingService, GreetingService>()
                        .AddSingleton<IJwtVerifier>(new StubJwtVerifier(subject: "alice"))),
                Pipeline.Configure);

        var request = FakeHttpRequestData.Get("/api/greet", bearer: "test-token");

        var response = await factory.InvokeAsync(request);

        Assert.NotNull(response);
        Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
    }
}

Faking HttpRequestData is the awkward bit — the type is abstract and tightly coupled to the worker context. Two options:

  • Use the helpers from Microsoft.Azure.Functions.Worker.Testing (still in preview at time of writing).
  • Build a small FakeHttpRequestData : HttpRequestData in your test project that overrides the members you actually exercise.

The pipeline itself is easy to test; the runtime types around it are the rough edge.

Tested against

  • .NET 10
  • MSL.Plumber.Pipeline 3.*
  • MSL.Plumber.Pipeline.Testing 3.* (test project)
  • Microsoft.Azure.Functions.Worker 2.0.0
  • Microsoft.Azure.Functions.Worker.Sdk 2.0.0
  • Microsoft.Azure.Functions.Worker.Extensions.Http 3.3.0
  • Azure Functions runtime v4

The isolated worker SDK ships frequent updates. Check the Microsoft.Azure.Functions.Worker* packages on NuGet before copying these versions into a new project.

See also

Clone this wiki locally