Skip to content

Request Lifecycle

Mark Lauter edited this page May 10, 2026 · 2 revisions

Request Lifecycle

A RequestContext<TRequest, TResponse> is the package every middleware sees. Plumber creates one per call to InvokeAsync, threads it through the pipeline, and returns its Response to the caller when the pipeline finishes.

This page walks through the context surface, the patterns that use it (sharing data, short-circuiting, the Unit response type), and the runtime concerns that wrap it (timeouts, cancellation, error handling).

The RequestContext surface

The full type is a sealed class with a primary constructor — every member below is read off context directly.

Member Type Description
Request TRequest The request value passed to InvokeAsync. Read-only.
Response TResponse? The response slot. Middleware sets this; the handler returns it from InvokeAsync. Defaults to default(TResponse) until written.
Id Ulid Unique identifier for the request, generated when the context is constructed. Use it as a correlation key in log lines.
Timestamp DateTime Wall-clock UTC time captured at construction (from the registered TimeProvider). Useful for log correlation; read it to render a human timestamp.
Elapsed TimeSpan Time since the context was constructed, measured against the monotonic clock exposed by TimeProvider.GetElapsedTime. Safe for performance measurements — wall-clock adjustments leave it unaffected.
Services IServiceProvider The per-request scoped provider. Resolves anything you'd resolve from a scope.
CancellationToken CancellationToken Combined cancellation signal for the request. Carries handler timeouts and any caller-supplied token.
IsCanceled bool Shorthand for CancellationToken.IsCancellationRequested. Lets middleware short-circuit without throwing.
ThrowIfCanceled() void Shorthand for CancellationToken.ThrowIfCancellationRequested().
Data IDictionary<string, object?> Lazy-initialized dictionary for inter-middleware sharing. The backing dictionary is allocated on first access.
TryGetValue<T>(key, out item) bool Strongly-typed lookup. Returns true only when the key exists and the stored value is a non-null T.

A few points worth highlighting:

  • Services is already scoped — consume it directly. The scope is created at the top of InvokeAsync and disposed when the pipeline returns.
  • Elapsed uses TimeProvider.GetElapsedTime, which is monotonic and high-resolution. It's not the same as DateTime.UtcNow - context.Timestamp (that arithmetic is exposed to wall-clock changes and has lower resolution on Windows).
  • Timestamp and Elapsed are both captured at context construction — within rounding, the moment the request enters the pipeline.
  • Id is a Ulid (lexicographically sortable, time-prefixed), useful as a stable log key across every line emitted by the request.

Sharing data between middleware

context.Data is the channel for passing values between middleware without modifying Request or Response. The dictionary is allocated lazily on first access — pipelines that share no data pay no allocation cost.

handler.Use((context, next) =>
{
    context.Data["user.id"] = AuthenticateAndExtractUserId(context.Request);
    return next(context);
});

handler.Use((context, next) =>
{
    if (context.TryGetValue<string>("user.id", out var userId))
    {
        // userId is non-null here
    }
    return next(context);
});

TryGetValue semantics

  • Returns false for missing keys.
  • Returns false when the stored value is null.
  • Returns false when the stored value's runtime type is not assignable to T.
  • Returns true for zero or default values of value types — the check is value is T, not value != default(T). If you stored 0 for an int key, the call returns true with 0. If you stored false for a bool, the call returns true with false.
  • The out parameter is decorated with [NotNullWhen(true)], so flow analysis will let you treat it as non-null inside the true branch.

Naming the keys

For pipelines that share more than one or two keys, lift the magic strings into a static class — typo-proof and grep-friendly:

internal static class DataKeys
{
    public const string UserId = "user.id";
    public const string Normalized = "normalized";
    public const string Tokens = "tokens";
}

context.Data[DataKeys.Normalized] = context.Request.ToLowerInvariant();

Sample.Cli uses this pattern — see the Sample.Cli walkthrough for a working end-to-end example with multiple shared keys.

Short-circuiting

Skip calling next and the rest of the pipeline doesn't run. This is the canonical pattern for validation, caching, and authorization — set context.Response directly and return:

internal sealed class ValidationMiddleware(RequestMiddleware<string, TextReport> next)
{
    public Task InvokeAsync(RequestContext<string, TextReport> context)
    {
        context.ThrowIfCanceled();

        if (string.IsNullOrWhiteSpace(context.Request))
        {
            context.Response = new TextReport(
                Original: context.Request ?? string.Empty,
                Normalized: string.Empty,
                Tokens: [],
                WordCount: 0,
                Elapsed: TimeSpan.Zero,
                ErrorMessage: "input must be non-empty");
            return Task.CompletedTask; // short-circuit: no next() call
        }

        return next(context);
    }
}

Middleware registered earlier in the pipeline still observes the short-circuit on the way out. Their code after await next(context) runs normally with context.Response already populated by the short-circuiter:

handler.Use(async (context, next) =>
{
    var sw = Stopwatch.StartNew();
    await next(context);                // returns even when downstream short-circuited
    sw.Stop();
    Console.WriteLine($"{context.Id}: {sw.ElapsedMilliseconds}ms");  // still runs
});

handler.Use<ValidationMiddleware>();    // short-circuits when input is whitespace
handler.Use<ProcessingMiddleware>();    // skipped on whitespace input

That property is what makes timing wrappers, error boundaries, and structured-logging middleware composable — they always see the response, whether it came from the bottom of the pipeline or from a short-circuit higher up.

A caching middleware looks the same: check the cache, set context.Response and return on hit, call next on miss. So does an authorization middleware: deny → set an unauthorized response and return; allow → call next.

Pipelines with no response: Unit

Some pipelines exist purely to do work — event handlers, queue consumers, notifications. There's no meaningful response to produce, but Plumber's handler type is RequestHandler<TRequest, TResponse> — every handler has both type parameters.

Unit is the no-information response type:

public readonly record struct Unit;

Zero-sized record struct, exactly one value. Use it as TResponse when the work is fire-and-forget:

using var handler = RequestHandlerBuilder
    .Create<MessageBatch, Unit>()
    .Build();

handler
    .Use<ValidateMiddleware>()
    .Use<ProcessMiddleware>();

await handler.InvokeAsync(batch);   // result is Unit?, typically discarded

Unit is borrowed from F# (unit) and Haskell (()). It's more expressive than object? (it actually means "no information") and keeps every handler typed as RequestHandler<TRequest, TResponse> — there's no separate void shape.

The most common places to see Unit are SQS/SNS handlers, EventBridge handlers, queue consumers, file watchers, and any pipeline whose effect is a side effect rather than a return value. The Recipes section has end-to-end examples.

Timeouts

Two timeout layers, applied independently.

Handler-wide timeout

Configured at Build():

using var handler = builder.Build(TimeSpan.FromSeconds(30));

Every InvokeAsync call inherits this timeout. The handler creates an internal CancellationTokenSource(timeout, timeProvider) per invocation; when the timer fires, the source cancels and the pipeline tears down. The exception surfaced to the caller is TimeoutException (with the original OperationCanceledException as InnerException) so timeouts are distinguishable from caller-driven cancellation.

Caller-supplied cancellation

Layered on top of the handler-wide timeout:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await handler.InvokeAsync(request, cts.Token);

The token is linked with the internal timeout source. Caller cancellation throws OperationCanceledException.

When both fire

When both fire at roughly the same time, caller cancellation wins — the propagated exception is OperationCanceledException, not TimeoutException. The internal logic distinguishes the two by checking which source was the trigger:

// pseudocode — illustrates the disambiguation rule
catch (OperationCanceledException ex)
    when (timeoutTokenSource.IsCancellationRequested
       && !cancellationToken.IsCancellationRequested)
{
    throw new TimeoutException(...);
}

The parameterless InvokeAsync(request) overload skips the caller layer entirely. When Timeout is Timeout.InfiniteTimeSpan (the Build() no-arg default), CancellationToken.None is passed straight through to the context.

Controlling the clock in tests

The timeout timer is driven by the registered TimeProvider. Register FakeTimeProvider (from Microsoft.Extensions.TimeProvider.Testing) when a test wants to control when the timeout fires:

builder.ConfigureServices((services, _) =>
    services.AddSingleton<TimeProvider>(new FakeTimeProvider()));

Cancellation inside middleware

Inside middleware, the standard pattern is to call context.ThrowIfCanceled() at the top — defense-in-depth. The terminal middleware at the end of the pipeline already checks cancellation before invoking, so ThrowIfCanceled is most useful in long-running middleware that does work before deferring to next.

To short-circuit on cancellation without throwing, check context.IsCanceled and set context.Response yourself.

Error handling

Exceptions propagate through the pipeline by default. There's no automatic catch — the pipeline stops, the per-request scope disposes, and the exception surfaces to the caller of InvokeAsync.

To convert or log exceptions in one place, register an error-boundary middleware first. The boundary catches every exception thrown anywhere downstream:

internal sealed class ErrorBoundary<TReq, TRes>(
    RequestMiddleware<TReq, TRes> next,
    ILogger<ErrorBoundary<TReq, TRes>> logger)
    where TReq : notnull
{
    public async Task InvokeAsync(RequestContext<TReq, TRes> context)
    {
        try
        {
            await next(context);
        }
        catch (OperationCanceledException)
        {
            logger.LogWarning("request {Id} was cancelled", context.Id);
            throw;
        }
        catch (TimeoutException)
        {
            logger.LogWarning("request {Id} timed out", context.Id);
            throw;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "request {Id} failed", context.Id);
            throw;
        }
    }
}

The class is generic, so spell out the closed generic when you register it for a specific (TRequest, TResponse) pair:

handler.Use<ErrorBoundary<MyRequest, MyResponse>>();

A few points to keep in mind:

  • Catching the three exception types separately lets the logger distinguish "the user cancelled" from "we ran out of time" from "something broke" — three different operational signals.
  • throw; (not throw ex;) preserves the original stack trace.
  • Register the boundary first if you want it to wrap the whole pipeline. Register it after some other middleware (a logger, a metrics middleware) and that earlier middleware's post-processing code still runs after the boundary re-throws.
  • To swallow an exception and produce a fallback response, write context.Response = ... in the catch and skip the throw;. Callers will see the fallback as a normal completion.

For middleware that wants to attempt a retry rather than swallow, see the RetryMiddleware example — same shape, but the boundary loops over next instead of catching once.

See also

  • MiddlewareUse overloads, delegate vs class, method vs constructor injection
  • Building a pipeline — builder configuration, Build(timeout), multiple builds
  • Testing — testing the lifecycle behaviors (timeouts, cancellation, short-circuiting) in isolation
  • FAQ — common questions about the lifecycle and exception model

Clone this wiki locally