Timeouts
muzak.Timeout gives a route a deadline for answering a request.
r := muzak.NewRouter(muzak.WithPrefix("/reports"), muzak.Timeout(5*time.Second))
r.Get("/{id}", handlers.GetReport) // 5s, from the router
r.Post("/export", handlers.Export, muzak.Timeout(30*time.Second)) // 30s, its own
r.Get("/live", handlers.LiveTotals, muzak.Timeout(-1)) // none
It is a SharedOption, so it goes on a route, on a router, at the point a router is
included, or on New for the whole application. The narrower declaration wins, whether it
is shorter or longer, a negative value removes an inherited deadline, and zero leaves the
inherited one as it is.
A cooperative deadline
The deadline is set on the request's context, ctx.Context(), after a rate limit counted
before the dependencies and before anything the route itself runs: the security schemes it
enforces, the body CaptureBody keeps, its dependencies and the binding of its input.
Guards, providers, the handler and everything they pass the context to see it:
func GetReport(ctx *muzak.Context, in schemas.ReportParams) (schemas.ReportOut, error) {
// The query gives up when the route's deadline passes, and its error
// comes back here.
rows, err := db.QueryContext(ctx.Context(), reportQuery, in.ID)
if err != nil {
return schemas.ReportOut{}, err
}
defer rows.Close()
// ...
}
Nothing that does not watch the context is interrupted. There is no second goroutine running
the handler while the first one writes a 503, which is how timeout middleware usually works
and why it usually races: the handler keeps running, keeps the request's resources, and can
still write into a response that was already answered. A Muzak handler always finishes on its
own goroutine, with its *muzak.Context to itself.
What the client receives
| The route | The client gets |
|---|---|
| Fails with an error caused by the deadline, before the response started | 503 with Retry-After: 1, through the application's error renderer |
Fails with an error that carries its own status, muzak.GatewayTimeout(...) say | That status |
| Fails because of the deadline after the response started | An aborted connection, as for any failure once a response has started |
| Ignores the deadline and returns successfully | Its success, however late |
| Fails because the client left, or a shorter deadline of the handler's own passed | What that error renders as, not a 503 |
An error is caused by the deadline when errors.Is reports it as context.DeadlineExceeded
and the deadline that passed is the route's own. That second condition is what keeps a client
that disconnected, or a context.WithTimeout the handler set for one call, from being reported
as the route running out of time. The cause is logged; the client is told only that the request
took longer than the operation allows:
{
"error": {
"code": "service_unavailable",
"message": "the request took longer than this operation allows; try again shortly",
"status": 503
},
"request_id": "0192a7c1-..."
}
A success is sent, however late. A handler that ignores its context and returns a value has done its work, and throwing the result away would only make the client repeat it. The deadline is a budget the handler is asked to respect, not one the framework can enforce without the second goroutine it refuses to start.
The exception is work that was undone underneath the handler. A request's Transaction is
rolled back by database/sql the moment the deadline passes, so a handler that carries on and
succeeds has nothing left to commit, and is answered with the 503 rather than a success whose
writes were thrown away. See Dependencies.
503, not 504. A 504 Gateway Timeout says a gateway's upstream did not answer, which sends
whoever reads it to look at a proxy. A 503 with a Retry-After says this server could not
answer in time, and when to try again. A route with a deadline documents the 503 in the
OpenAPI document, unless it declares a 503 of its own with WithResponseDoc.
Event streams and WebSockets
A stream lasts as long as its connection, so a deadline on it would be a deadline on the
conversation. Timeout declared directly on an SSE or WS route is a build error that names
the option that bounds a stream instead:
muzak: SSE GET /events: Timeout cannot be declared on a route whose response lasts as long as its connection; bound it with SSEOptions.MaxLifetime instead
A deadline inherited from a router is not applied to the streams beneath it, so a router can
carry both, and Timeout(-1) is always allowed. See
Server-Sent Events and
WebSockets.
Mounted handlers
A handler served with Router.Mount writes its own response and may be streaming one, so it is
treated the same way. A Timeout given to Mount is a build error:
muzak: mount at "/legacy": Timeout cannot be declared on a mount, whose handler writes its own response; give the handler a deadline of its own, with http.TimeoutHandler for one
and one inherited from a router is not applied to the mounted handler. See Mounting Handlers.
Next to the server's timeouts
Timeout is not WriteTimeout. The server's WriteTimeout still bounds how long a response
may take to write and closes the connection when it passes, so a route deadline longer than
WriteTimeout is cut short by it, with no 503. Keep route deadlines below it, or raise it for
the routes' sake. See Server Configuration.
A background task registered with AfterResponse does not inherit the deadline: it keeps the
request's values but not its cancellation. See
Background Tasks.
Cost
A route without a deadline pays nothing for the option existing: no context, no timer, no
copy of the request. A route with one pays for one context with a timer and one shallow copy of
the *http.Request, whatever the size of its body.
Where to go next
Dependencies covers the providers that run under the deadline, and Error Handling the renderer the 503 goes through.