TimeoutMiddleware puts a hard deadline on every HTTP request. A slow
handler - a hung database query, an unresponsive upstream API, an
accidental infinite loop in some hot path - would otherwise hold a hyper
connection open until the client gave up or the OS killed the process.
The timeout middleware caps that wait, drops the in-flight handler, and
returns 503 Service Unavailable so the operator sees the failure
instead of the application silently leaking connections.
Reach for it when you're building anything that talks to the public internet, anything that fans out to third-party APIs, or anything where "the database might be slow today" is a realistic Tuesday.
use ;
pub async
That single line gives the whole application the same default ceiling Suprnova uses for its database connect timeout - pick once, apply everywhere. Per-route overrides are one line each. The rest of this chapter explains exactly what the deadline bounds, what it intentionally doesn't, and how it interacts with the panic boundary, streaming responses, and WebSockets.
The middleware
TimeoutMiddleware lives at suprnova::TimeoutMiddleware. It exposes
three constructors and one accessor:
use Duration;
use TimeoutMiddleware;
let default_30s = default;
let custom = new;
let whole_secs = seconds;
assert_eq!;
assert_eq!;
assert_eq!;
TimeoutMiddleware::default() uses a 30-second deadline. That number is
not arbitrary - it matches DB_CONNECT_TIMEOUT (also 30s) so a request
blocked waiting for a brand-new database connection and a request blocked
inside the handler share one ceiling. If you raise one, raise the other.
TimeoutMiddleware::seconds(n) is shorthand for the common whole-seconds
case. TimeoutMiddleware::new(Duration::…) is the escape hatch when you
need millisecond precision (an internal health check that should never
take more than 200ms; a synthetic probe with a 50ms budget).
Installing globally
A global timeout is the right starting point: it gives every route a
ceiling without anyone having to remember to add it. Install it in
bootstrap.rs alongside your other global middleware:
// src/bootstrap.rs
use ;
use crateLoggingMiddleware;
pub async
The order matters because global middleware wraps the rest of the chain
in registration order: RequestIdMiddleware runs first on the way in
and last on the way out, so the request id is in scope while the timeout
fires its 503. Putting the timeout before logging would hide slow
requests that did eventually complete from the access log.
Tightening per route
A 30-second global ceiling is generous on purpose - it's there to catch runaway handlers, not to enforce SLAs. When a specific endpoint should fail faster, attach a per-route timeout:
use ;
new
// Public report endpoint: must respond in 5s or we'd rather 503
// and let the client retry than block.
.get
.middleware;
You can attach a tighter timeout to a route group too. This is the typical shape for a public API where each request should be quick, while the rest of the app keeps the 30-second default:
use Router;
use TimeoutMiddleware;
new
.group
.middleware;
Global is a ceiling; per-route can only tighten
Global middleware runs outside route middleware. The chain wraps inside-out:
Global timeout (30s) → Route timeout (3s) → handler
Both tokio::time::timeout futures are armed; the inner one fires
first because it has the shorter deadline. So a per-route timeout can
only make a route stricter than the global, never looser.
If a single endpoint legitimately needs to run longer than the global default - a slow report, a large upload, a long-poll fallback - you have two options:
- Raise the global value. Simplest, but it relaxes the ceiling for every other route too.
- Scope the global middleware to a route group that excludes the long endpoint, and attach a separate timeout (or none) to the slow route. This keeps the strict default everywhere else.
The second option is the right shape for one outlier; the first is right when the whole class of work needs more room.
What the deadline actually bounds
The deadline races the future returned by next(request). That future
resolves the moment your handler returns its HttpResponse - not when
the body finishes streaming. That distinction is load-bearing:
- Normal handlers build their full body before returning, so the deadline effectively bounds total handler time. A handler that serialises a JSON list, renders an Inertia page, or assembles an HTML response holds the future until the work is done.
- Streaming responses (
HttpResponse::sse(...),HttpResponse::stream_bytes(...)) return immediately with a lazy body. The middleware chain has already completed by the time hyper starts pulling bytes off the stream, so the deadline never observes the body's lifetime. An SSE event stream can stay open for hours under a 30-second timeout, by design - see Server-Sent Events for the streaming model. - WebSocket upgrades are skipped explicitly. See the next section.
This is the behaviour you almost certainly want. If you wrapped a long-lived SSE stream in a 30-second timeout, the framework would tear the connection down mid-stream every 30 seconds and the feature would be unusable.
WebSocket carve-out
The middleware inspects the request before arming the deadline:
if is_websocket_upgrade
Any request carrying Upgrade: websocket skips the timeout entirely.
The check is case-insensitive on the token value (WebSocket,
websocket, WEBSOCKET all match), and a bare Connection: upgrade
without Upgrade: websocket is not treated as a WS upgrade - that
flows through the timeout normally.
Today, WebSocket upgrades take a separate server path that doesn't run global middleware at all, so this guard is defence in depth - it keeps the timeout from ever bounding a long-lived bidirectional channel the day that changes. See WebSockets for how upgrades are dispatched and the lifetime of a connected socket.
What happens at the deadline
When tokio::time::timeout elapses before the handler completes, the
middleware does three things, in order:
-
Drops the in-flight handler future. The future was being polled inside the
timeoutcombinator; the combinator returnsErr(Elapsed)and the future is dropped where it was last suspended. -
Logs a warning with the route path and the timeout duration in milliseconds:
WARN suprnova::timeout request exceeded its timeout; returning 503 Service Unavailable route=/report timeout_ms=5000The log is at
WARNso it surfaces in operator dashboards by default, separate fromINFOaccess logs of normal requests. -
Returns
503 Service Unavailablewith a plain-text body:HTTP/1.1 503 Service Unavailable Content-Type: text/plain Content-Length: 42 Service Unavailable: request timed out
The 503 is wrapped in Err(HttpResponse::…) so it short-circuits the
rest of the chain just like any other middleware-rejected request.
Outer middleware (logging, request-id, CORS) still runs its post-handler
side, so the response goes out with the correct headers.
Why 503 and not 504
504 Gateway Timeout is the right code when you are the gateway and
an upstream timed out. 503 Service Unavailable is the right code
when this service couldn't produce the response in time. The timeout
middleware is bounding our own handler, so it returns 503. If you
want a different shape - a JSON body, a different status, a
machine-readable code - wrap your own outer middleware around the
timeout and translate its 503 response.
Cancel safety
When the deadline elapses, the handler future is dropped at its
current .await point. This is normal Tokio cancellation; the same
thing happens when a client closes the connection mid-request. Anything
held across the await boundary is released by its Drop impl:
- Database transactions roll back. A SeaORM
DatabaseTransactionhas aDropimpl that issuesROLLBACKon the underlying connection. - Mutex and RwLock guards release. A standard library or
parking_lotguard releases on drop; another waiter can take it immediately. - File handles close. The OS-level descriptor is released when the
tokio::fs::Fileis dropped. - Network connections check back into the pool or close, depending on the pool's drop behaviour.
The result is that a timed-out handler leaves nothing dangling - the operator sees the 503, the database sees the rollback, the next request sees a clean pool.
What is not cancelled
Anything you moved off the request with tokio::spawn is detached.
Spawned tasks live on the runtime, not the request future, so dropping
the request does not stop them. This matters when you wrote something
like this:
pub async
If the request times out before the spawn line runs, the spawn
never happens. If the request times out after the spawn, the
background task keeps running - it is not cancelled with the request.
That's almost always what you want for webhook-style work, but it does
mean cleanup after a long .await inside the handler is not
guaranteed to run:
pub async
The fix is to use RAII. Wrap the temporary file in a struct whose
Drop impl removes it; then the cleanup runs whether the handler
returns, returns an error, or is dropped mid-.await by the timeout.
This is the same discipline you'd apply for any cancellation source -
client disconnect, runtime shutdown, panic recovery.
Interaction with the panic boundary
The Suprnova server wraps the entire middleware chain in
execute_chain_safely, which uses
AssertUnwindSafe(...).catch_unwind() to translate panics into a sanitised
500 Internal Server Error. A timed-out request is not a panic -
the future is dropped cleanly - so the timeout's 503 goes out
without involving the panic boundary at all.
The two boundaries handle different failure modes:
| Failure | Boundary | Status | Body |
|---|---|---|---|
Handler .await exceeds deadline |
TimeoutMiddleware |
503 |
Service Unavailable: request timed out |
Handler panics (.unwrap() on None, etc.) |
execute_chain_safely |
500 |
{"message": "Internal Server Error"} |
Handler returns Err(HttpResponse) |
normal Response flow |
whatever the handler set | whatever the handler set |
You don't have to pick - both boundaries are always installed. A handler that panics after exceeding its timeout still produces a 503 (the future was dropped before the panic could happen). A handler that panics before exceeding its timeout produces a 500.
Operational tuning
Three considerations when picking timeout values:
- Match your database connect timeout. If
DB_CONNECT_TIMEOUT=30(the default), a request timeout shorter than 30s will fire before a slow connect ever completes - the user sees503instead of the chance to recover. Either raise the connect timeout or accept that "30s" is the floor. - Account for the slowest legitimate handler. Look at a histogram
of your
INFO-level request durations. The p99 of the slow tail should sit comfortably below the timeout, with headroom for clock skew and event-loop jitter. A timeout that fires routinely on healthy traffic is a misconfiguration, not a feature. - Per-route timeouts are observability. Tightening
TimeoutMiddleware::seconds(3)on/api/*turns a degraded API into a visible alert (logs full of WARN, 503s in the load balancer) instead of a creeping latency problem. Use them where you have an SLA and want a hard failure when you miss it.
The framework's own integration tests use durations in the millisecond
range (TimeoutMiddleware::new(Duration::from_millis(50))) to exercise
the deadline deterministically. Production deadlines are almost always
in whole seconds.
Why Suprnova diverges
In a Laravel + PHP-FPM deployment, request timeouts live outside the
application: nginx's proxy_read_timeout, PHP-FPM's
request_terminate_timeout, the load balancer's idle timeout. The
PHP process is killed when the budget is exhausted, and any open
state - database connections, file handles - leaks until the next
request reuses the worker.
Suprnova bounds the request inside the application because it can. The
handler is a Tokio future, not a PHP process, so dropping it runs Drop
impls cleanly: transactions roll back, locks release, descriptors close,
the connection pool stays healthy. The 503 also goes out as a real HTTP
response - clients see a proper status code instead of an upstream
reset.
This is also why the middleware doesn't try to be a Tower
Timeout layer. Tower's layer is generic over any Tokio service and
returns tower::timeout::error::Elapsed, which callers then have to map
to an HTTP status. The Suprnova middleware knows it's wrapping an HTTP
request pipeline; it returns 503 directly, logs the offending route,
and respects the framework's WebSocket and streaming carve-outs without
the caller having to reason about them. The Tower layer is the right
primitive for a generic Tokio service; for an HTTP request, this is the
right shape.
Next
- Middleware - the trait, the chain, global vs per-route registration, terminable hooks
- Request Lifecycle - where the timeout sits in the chain, and how
execute_chain_safelyhandles panics - Server-Sent Events - the streaming response model the timeout intentionally doesn't bound
- WebSockets - the upgrade path that bypasses the timeout entirely
- Errors - how 5xx responses are dispatched as
ErrorOccurredevents for observability
