before, after, observe — and No Layer Ever Runs the Handler; It Runs at Most Once, Under the Runtime AloneStatus: Accepted
Date: October 8, 2026 (#121)
Answers: how logging, compression, rate limits, CORS, request ids and
timeouts wrap a tree of routes, without a layer controlling the handler
Related:
ADR-334 (a tree of routes is a comptime value);
ADR-332 (extractors, typed inputs);
ADR-326 D4 (a panic ends its task);
ADR-327 (memory budgets);
ADR-328 D8 (a scope cancels its tasks);
ADR-297 (cleanup on the way out);
Part III 17.1
How does work that many routes share, and that hands the handler no value, wrap them - and what may such work do to the handler and to the response?
An extractor (AuthUser) runs only where a handler names it and never sees the
response. Logging needs the status and the time taken, compression the body,
a rate limit must refuse before the handler, CORS writes headers on every
answer, a timeout must act while the handler runs.
| Shape | |
|---|---|
| Express, ASP.NET | a function with next: before it, after it, or never |
| Go | func(next http.Handler) http.Handler |
| axum / tower | Layer and Service around the handler |
| Spring | preHandle, postHandle, afterCompletion |
| Phoenix | plugs, conn → conn, in a pipeline |
From memory, not checked against a version. What transfers: Spring’s separate
phases, and an afterCompletion that runs once the answer is sent.
| Option | Why it lost |
|---|---|
A wrapper with next (Express, tower) |
next may run the handler twice, never, or in a loop; what follows it delays every answer; a panic after it turns a done side effect into a 500 a client retries; the wrapper’s locals live while the handler waits |
A wrapper limited to calling next once |
still opaque to the compiler: no parallel before, no header conflicts, no work moved past the send |
| A timeout written as a wrapper | the runtime cancels a task already (ADR-328 D8); a declared timeout cannot forget to |
Independent befores always concurrent |
a rate limit counts a request an authentication refuses at the same moment; the written order is often the intent |
Every failure of an after a 500 |
compression that fails can send the body as it was |
No layer calls, skips or repeats it. It does not run when a before refuses,
and a timeout cancels it as a scope cancels a task (ADR-328 D8),
its cleanups running (ADR-297).
http::Layer with any of three phases.comptime APP = http::Routes()
.mount("/api", api::ROUTES)
.layer(http::Timeout(2.seconds()))
.layer(http::Cors(origins: ["https://app.example"]))
.layer(http::RateLimit(100.per_second(); key: ApiKey))
.layer(http::Compress())
.layer(http::AccessLog())
| Phase | May | Runs |
|---|---|---|
before(…typed inputs…) -> R throws Reject |
refuse, or answer itself; hand R to its after |
before the handler |
after(r: R, response) -> Response |
change the response | after the handler |
observe(exchange: http::Exchange) |
read | after the response is sent |
A layer captures nothing, as a handler does not (ADR-334); its
inputs are typed parameters, read as a handler’s are, State[T] among them.
What before hands to after is its R, nothing else.
befores run in written order; concurrently only where nothing can tell.Two befores run at once only where neither depends on the other’s result by
its declared inputs, and either both touch nothing (sync, no effect) or the
layer declares that its effect stands when another refuses. A refusal ends the
befores that have not begun.
afters run one by one, innermost first; a layer declares how it fails.A failing after that fails open lets the response pass as it was - a
compression; one that fails closed turns it into a 500 - a signature. Once a
response has begun to stream, an after is a transformation of the stream,
and a failure after the first byte is reported and ends the connection; what
was sent stays sent. A handler that fails or is cancelled has its error
response pass through the afters, so CORS headers reach a refusal too.
observe is off the response’s path and cannot change it.It runs after the send; its failure is reported and changes nothing. What waits to be observed is bounded by a budget (ADR-327); past it an observation is dropped and counted, never waited for. It sees the method, the path, the status, the time and the sizes - not the bodies.
std’s.A timeout, a body’s size limit and a panic answered with 500 act on the
runtime itself, and only std declares them.
.layer on a tree applies to every route in it and in the trees it mounts;
the outer tree’s layers are outside the inner’s.
Two afters that write the same header, and a layer whose inputs a route under
it cannot give, are refused. A layer’s typed inputs appear in the API
description always; the statuses it may answer with appear where the layer
declares them.
befores with I/O wait together where that is safe; logging and
metrics leave the response’s path.std offers beyond those named, and their options.