-
Notifications
You must be signed in to change notification settings - Fork 0
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 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:
-
Servicesis already scoped — consume it directly. The scope is created at the top ofInvokeAsyncand disposed when the pipeline returns. -
ElapsedusesTimeProvider.GetElapsedTime, which is monotonic and high-resolution. It's not the same asDateTime.UtcNow - context.Timestamp(that arithmetic is exposed to wall-clock changes and has lower resolution on Windows). -
TimestampandElapsedare both captured at context construction — within rounding, the moment the request enters the pipeline. -
Idis aUlid(lexicographically sortable, time-prefixed), useful as a stable log key across every line emitted by the request.
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);
});- Returns
falsefor missing keys. - Returns
falsewhen the stored value is null. - Returns
falsewhen the stored value's runtime type is not assignable toT. - Returns
truefor zero or default values of value types — the check isvalue is T, notvalue != default(T). If you stored0for anintkey, the call returnstruewith0. If you storedfalsefor abool, the call returnstruewithfalse. - The
outparameter is decorated with[NotNullWhen(true)], so flow analysis will let you treat it as non-null inside thetruebranch.
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.
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 inputThat 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.
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 discardedUnit 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.
Two timeout layers, applied independently.
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.
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 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.
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()));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.
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;(notthrow 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 thethrow;. 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.
-
Middleware —
Useoverloads, 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
Documents Plumber v4.x · Repository · MIT License · Report an issue
Getting Started
Pipeline (core)
Testing
Serilog Extensions
Diagnostics
Recipes
- AWS Lambda — API Gateway
- AWS Lambda — SQS
- Azure Functions — HTTP
- SQS polling console
- ASP.NET Core integration
- BackgroundService worker
- Webhook receiver
- Multi-command CLI
- File watcher
- Configuration reload
Repo · NuGet · NuGet — Testing · NuGet — Serilog · NuGet — Diagnostics