You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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 routeThe client gets
Fails with an error caused by the deadline, before the response started503 with Retry-After: 1, through the application's error renderer
Fails with an error that carries its own status, muzak.GatewayTimeout(...) sayThat status
Fails because of the deadline after the response startedAn aborted connection, as for any failure once a response has started
Ignores the deadline and returns successfullyIts success, however late
Fails because the client left, or a shorter deadline of the handler's own passedWhat 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.