# Muzak documentation (0.3.0) Muzak is a type-safe web framework for Go, built on net/http and Go 1.27 with no third-party dependencies. A handler is an ordinary typed function: its input type is the request and its return type is the response body, both checked at compile time. Install it with `go get muzak.dev/framework`. This describes Muzak 0.3.0, which is not the current release. The current documentation is at https://muzak.dev/llms.txt, and this version's pages are at https://muzak.dev/docs/0.3.0. The page index is at https://muzak.dev/docs/0.3.0/llms.txt. Every page below is also served as HTML under https://muzak.dev/docs/0.3.0. -------------------------------------------------------------------------------- title: "Introduction" description: "Muzak is a type-safe web framework for Go. You write plain typed functions, and the framework turns them into an HTTP API with validation, dependency injection and OpenAPI docs." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/introduction" -------------------------------------------------------------------------------- # Introduction Muzak is a web framework for Go. You use it to build HTTP APIs: a function becomes a route, the input type of that function is the request, and the return type is the response body. Both are checked when you compile, not when a request arrives. The framework is built on `net/http` and Go 1.27, with no third-party dependencies at all. The pieces you reach for every day (routing, request binding, validation, dependency injection, configuration, logging, OpenAPI) are part of the framework, not separate modules you have to wire together yourself. Here is a complete application. It answers `GET /users/{username}` with a JSON object. ```go package main import ( "log" "muzak.dev/framework" ) type Params struct { Username string `path:"username" doc:"The username to look up"` } type UserOut struct { Username string `json:"username"` } func main() { app := muzak.New(muzak.AppOptions{Title: "Hello", Version: "1.0.0"}) app.Get("/users/{username}", func(ctx *muzak.Context, in Params) (UserOut, error) { return UserOut{Username: in.Username}, nil }) log.Fatal(app.RunSignals()) } ``` `Params` is the request. `UserOut` is the response. You never write the type arguments for `app.Get`: Go 1.27 added generic methods and generalized function type inference, so the router reads both types off the handler literal. ## What you get - **Routing from typed functions.** A handler is `func(ctx *muzak.Context, in In) (Out, error)`. There is no wrapper type around the response and no filtering pass at run time, so a field you did not declare on `Out` cannot leak. - **Typed request data.** Struct tags say where each field comes from: `path`, `query`, `header`, `cookie`, `form`, `file`, or the JSON body when a field carries none of them (a `muzak.Dep[T]` field is the exception: it is filled by a provider, never from the request). Bad input is rejected with `422` before your function runs. - **Validation against the field itself.** `v.String(&in.Email).Trim().Lower().Required().Email()` names the field by address, so renaming it is a change the compiler checks and there is no tag string to typo. - **Dependency injection.** A guard validates and produces nothing; a provider produces a typed value that the handler reads with `muzak.From[T](ctx)` or declares on its input as a `muzak.Dep[T]` field, which a missing provider turns into a build error. `muzak.Acquire` adds a release that runs however the request ends, and `muzak.Transaction` gives each request a `*sql.Tx` committed only when it succeeds. - **Nested routers.** A package exports its own router and stays unaware of the prefix, tags and guards it will eventually run under. The application decides where things mount. - **OpenAPI for free.** Routes, models and validation rules produce an OpenAPI 3.1 document at `/openapi.json`, grouped by tag and by category, with the authentication schemes you declare. A scheme built by `muzak.JWTBearer` or `muzak.APIKeyVerifier` is also enforced on the routes that name it, so the document and the server cannot disagree. A documentation page is one import away, and costs a service that does not want one nothing at all. - **API versioning.** A route declares the version or versions it answers, read from the path, a header, the `Accept` header or a function of your own. - **Real-time built in.** `Router.WS` speaks RFC 6455 WebSockets and `Router.SSE` serves typed server-sent events, both behind the same middleware, guards and binding as any other route. - **Safe defaults.** Every listener timeout is non-zero, bodies and headers are capped, unknown JSON members are rejected, WebSocket connections are kept alive or closed, CORS denies everything until a policy is written, and a panic becomes a generic 500 with the stack in the log rather than in the response. ## What a bigger application looks like Each router is written on its own. The application decides where they mount and what protects them, and that decision lives in one visible place. ```go settings := muzak.MustLoadConfig[core.Settings](muzak.EnvFile(".env")) app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", Addr: settings.Addr, }, muzak.WithDependencies(core.GetQueryToken), muzak.WithSingleton(settings), ) app.Include(routers.Users()) app.Include(routers.Items()) app.Include(routers.Admin(), muzak.WithPrefix("/admin"), muzak.WithTags("admin"), muzak.WithDependencies(core.GetTokenHeader(settings)), ) log.Fatal(app.RunSignals()) ``` `App` embeds `*Router`, so `app.Get(...)` works at the root using the same generic methods any nested router uses. ## Where to go next Start with [First Steps](/docs/getting-started/first-steps) to create a project and run it. After that, [Routers](/docs/getting-started/routers) covers paths and methods, [Request Data](/docs/getting-started/request-data) covers how the input struct is filled in, and [Dependencies](/docs/getting-started/dependencies) shows how services and resources reach your handlers. -------------------------------------------------------------------------------- title: "First Steps" description: "Create a Muzak project, write your first route, run the server and open the generated documentation." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/first-steps" -------------------------------------------------------------------------------- # First Steps Muzak needs Go 1.27. The framework uses generic methods, generalized function type inference, `encoding/json/v2` and the standard library `uuid` package, none of which exist in earlier versions. ```bash go version ``` ## Install Create a module and add the dependency. ```bash mkdir awesome-api && cd awesome-api go mod init awesome-api go get muzak.dev/framework ``` The framework also ships a `muzak` command, which can create the project for you, laid out like the one [below](#project-layout), and rebuild and restart it while you work on it: ```bash go install muzak.dev/framework/cmd/muzak@latest muzak new awesome-api cd awesome-api && go mod tidy ``` This page builds the project by hand so that every piece is visible. See [Command Line](/docs/getting-started/command-line) for what the command does. ## The smallest application ```go [cmd/main.go] package main import ( "log" "muzak.dev/framework" ) type HelloOut struct { Message string `json:"message"` } func main() { app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", Addr: ":8080", }) app.Get("/", func(ctx *muzak.Context, _ muzak.Empty) (HelloOut, error) { return HelloOut{Message: "Hello from Muzak"}, nil }) log.Fatal(app.RunSignals()) } ``` Run it: ```bash go run ./cmd ``` ``` 14:32:07.482 INFO [Server] Starting Muzak application... 14:32:07.492 INFO [Server] Listening on :8080 ``` And call it: ```bash curl http://localhost:8080/ ``` ```json {"message":"Hello from Muzak"} ``` ### What each piece does - `muzak.New` builds the application. `AppOptions` embeds `OpenAPIOptions` and `ServerOptions`, which is why `Title` and `Addr` sit next to each other in one literal. - `app.Get` registers a route. `App` embeds `*Router`, so the root application has the same registration methods any nested router has. - `muzak.Empty` is the input type for a route that reads nothing from the request. Binding is skipped entirely for it. - `HelloOut` is the response model. What the handler returns is what the client receives. - `app.RunSignals` builds the application, starts the lifecycle components, listens, and shuts down gracefully on `SIGINT` or `SIGTERM`. ## The generated documentation Where it is, is the last thing the server says as it starts: ``` INFO [Server] Listening on [::]:8080 scheme=http INFO [Docs] OpenAPI document at http://localhost:8080/openapi.json ui="set AppOptions.DocsUI to serve a documentation page" ``` Every application publishes that document. It is generated from the routes and the types, and it is all a client generator, a linter or another service needs: ```bash curl http://localhost:8080/openapi.json ``` ### Adding the dashboard Rendering the document is a separate module, so a service that wants no page carries none. Add the import and the option, and `/docs` starts serving one: ```bash go get muzak.dev/openapi ``` ```go [main.go] import ( "muzak.dev/framework" "muzak.dev/openapi/ui" ) app := muzak.New(muzak.AppOptions{ Title: "Awesome API", DocsUI: ui.Files(), }) ``` Restart, and the log names the page instead: ``` INFO [Docs] Documentation at http://localhost:8080/docs openapi=http://localhost:8080/openapi.json ``` Open it. The page is served from the binary itself: it fetches nothing from a third party and runs under a content security policy that hashes its own inline script. Operations are grouped in a sidebar by category, or by tag where a route has none, every schema is an outline you can expand, and each operation has a console that sends the request and shows you what came back -- or hands you the same request as a `curl` command. See [OpenAPI](/docs/fundamentals/openapi#grouping-operations) for how to file a router under a category and give a route a title. Nothing about this happens at run time. The dashboard is embedded in the binary that imports it, so an air-gapped deployment gets a working page with no further setup, and one that never imports it downloads none of those bytes. ### Where they are served Both paths are configurable, and both can be turned off entirely: ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", DocsUI: ui.Files(), DocsPath: "/reference", // defaults to "/docs" OpenAPIPath: "/spec.json", // defaults to "/openapi.json" }) ``` ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", DisableDocs: true, // serve neither the page nor the document }) ``` Leaving `DocsUI` unset serves no page while keeping the document; `DisableDocs` stops both. With no page configured, `DocsPath` is not reserved either, so a route of your own may answer `/docs`. Guards given to `muzak.New` cover the documentation too; see [OpenAPI](/docs/fundamentals/openapi#guarding-the-documentation). ## Reading the request A handler asks for what it needs by declaring an input type. Each field says where it comes from. ```go [cmd/main.go] type SearchIn struct { Query string `query:"q" doc:"What to search for"` Limit int `query:"limit" default:"20" doc:"How many results to return"` Page *int `query:"page" doc:"Which page to return, if any"` } type SearchOut struct { Query string `json:"query"` Limit int `json:"limit"` Results []string `json:"results"` } func main() { app := muzak.New(muzak.AppOptions{Title: "Awesome API"}) app.Get("/search", func(ctx *muzak.Context, in SearchIn) (SearchOut, error) { return SearchOut{Query: in.Query, Limit: in.Limit, Results: nil}, nil }) log.Fatal(app.RunSignals()) } ``` ```bash curl 'http://localhost:8080/search?q=muzak&limit=nope' ``` ```json { "error": { "code": "validation_error", "message": "The request could not be validated.", "status": 422, "details": [ { "field": "limit", "location": "query", "issue": "must be a valid integer" } ] }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` Every offending field is reported at once, and the `request_id` in the body is the same identifier the server wrote to its log and returned in the `X-Request-Id` header. ## Project layout One file is fine for a demonstration. A real service outgrows it quickly, so the layout below is the one the framework's own example uses. `cmd/main.go` does nothing but compose, and every piece of behaviour lives in a package of its own. ``` awesome-api/ |-- cmd/ | `-- main.go composition, and nothing else |-- core/ configuration, guards, dependencies, managed resources |-- schemas/ the request and response models the API exposes |-- handlers/ the functions that answer requests |-- routers/ which handler answers which path |-- .env `-- go.mod ``` The dependencies point one way. Routers know handlers, handlers know schemas and core, and core knows nothing about any of them, so every package is testable on its own. `muzak new` writes a smaller project of the same shape: `routers`, `handlers` and `schemas` with one route, its handler and its models, a handler test that serves the application with `testclient`, and the server in `cmd/server/main.go` rather than `cmd/main.go`. It writes no `core` package and no `.env`; add them when the service needs them. See [Command Line](/docs/getting-started/command-line). Here is the same hello world, written that way. ::code-group ```go [cmd/main.go] package main import ( "log" "awesome-api/routers" "muzak.dev/framework" ) func main() { app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", Addr: ":8080", }) app.Include(routers.Users()) log.Fatal(app.RunSignals()) } ``` ```go [routers/users.go] package routers import ( "awesome-api/handlers" "muzak.dev/framework" ) // Users returns the users router. It knows nothing about where it will be // mounted or what will guard it. func Users() *muzak.Router { r := muzak.NewRouter(muzak.WithTags("users")) r.Get("/users/{username}", handlers.ReadUser, muzak.Summary("Read a user by name")) return r } ``` ```go [handlers/users.go] package handlers import ( "awesome-api/schemas" "muzak.dev/framework" ) // ReadUser returns one user by name. func ReadUser(ctx *muzak.Context, in schemas.UserLookupParams) (schemas.UserOut, error) { return schemas.UserOut{Username: in.Username}, nil } ``` ```go [schemas/users.go] package schemas import "muzak.dev/framework" type UserLookupParams struct { Username string `path:"username" doc:"The username to look up"` } type UserOut struct { Username string `json:"username" doc:"The user's login name"` Email string `json:"email,omitzero" doc:"Where we reach the user"` } // Validate constrains the username to what the store can hold. func (in *UserLookupParams) Validate(v *muzak.Validation) { v.String(&in.Username).Trim().Lower().Required().MinLen(2).MaxLen(32). Matches(`^[a-z0-9_]+$`). Message("may only contain lower case letters, digits and underscores") } ``` :: ## Catching mistakes before the socket opens Route problems are collected while the application is built, not while it is serving. A duplicate route, an input type that cannot be bound, a path parameter no field reads, a duplicate operation identifier and a malformed prefix are all reported together. ```go if err := app.Build(); err != nil { log.Fatal(err) } ``` `Build` is called for you by `Run`, `RunContext`, `RunSignals` and `ServeHTTP`, so calling it directly is only useful to surface configuration errors early, which is what a start-up check or a test wants. Building is idempotent: the work happens once, and later calls return the same result. ## Where to go next [Routers](/docs/getting-started/routers) covers paths, methods and how routers compose. [Request Data](/docs/getting-started/request-data) covers every place an input field can be read from. -------------------------------------------------------------------------------- title: "Routers" description: "Register routes with typed handlers, group them into routers, and mount those routers under prefixes, tags and guards decided by the application." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/routers" -------------------------------------------------------------------------------- # Routers A `Router` groups related routes under a shared prefix, tag set and dependency chain. Routers are built independently and composed with `Router.Include`: a package exports its own routes and stays unaware of where they will be mounted. `App` embeds `*Router`, so an application is a router that also knows how to serve. Everything on this page works the same on both. ## Registering a route ```go r := muzak.NewRouter(muzak.WithTags("items")) r.Get("/items/{item_id}", handlers.ReadItem) ``` The registration methods are `Get`, `Post`, `Put`, `Patch`, `Delete` and `Head`, plus `Handle` for any other method. Each takes a path template, a handler, and any number of route options. Each returns the `*Route` it registered. ```go r.Handle(http.MethodOptions, "/things", handlers.DescribeThings) ``` There is no `Router.Options` method. `App.Options` applies configuration to an application, and since `App` embeds `*Router` the two would shadow each other, so OPTIONS is registered with `Handle`. In practice you rarely need to: OPTIONS is answered automatically with an `Allow` header. `Router.WS`, `Router.SSE` and `Router.SSEHandle` register real-time routes and are covered in [WebSockets](/docs/realtime/websockets) and [Server-Sent Events](/docs/realtime/server-sent-events). `Router.Frontend` and `Router.Static` serve files and are covered in [Static Files and Frontends](/docs/techniques/static-files). `Router.Mount` serves any `http.Handler` at a prefix and is covered in [Mounting Handlers](/docs/techniques/mounting-handlers), and `Router.Implement` registers an operation declared once for a server and its Go callers, covered in [Typed Endpoints](/docs/techniques/typed-endpoints). ## The handler shape Every handler is `func(ctx *muzak.Context, in In) (Out, error)`. ```go func ReadItem(ctx *muzak.Context, in schemas.ItemParams) (schemas.ItemOut, error) { store := muzak.From[*core.ItemStore](ctx) item, err := store.Get(in.ID) if err != nil { return schemas.ItemOut{}, muzak.NotFound("Item not found") } return schemas.ItemOut{ID: item.ID, Name: item.Name}, nil } ``` `In` and `Out` are inferred from the handler literal or from the named function, so you never write the type arguments at the call site. A route that reads nothing declares `muzak.Empty` as its input: ```go r.Get("/healthz", func(ctx *muzak.Context, _ muzak.Empty) (schemas.HealthOut, error) { return schemas.HealthOut{Status: "ok"}, nil }) ``` ## Path templates A path may contain `{name}` parameters and one trailing `{name...}` wildcard. Matching walks a segment-wise radix trie and allocates nothing. ```go r.Get("/users/{username}", handlers.ReadUser) // one segment r.Get("/files/{path...}", handlers.ServeFile) // the rest of the path ``` Every parameter in the template must be read by a field of the input type. A template parameter nothing binds is reported when the application is built, because it is almost always a typo: ```go type ItemParams struct { ID string `path:"item_id" doc:"The item to operate on"` } r.Get("/items/{item_id}", handlers.ReadItem) ``` Captured values are percent-decoded once before the handler runs, so `%2F` reaches you as `/` rather than as an escape you have to remember to decode. That also means a wildcard's value can contain `/` and `..`, and any value can contain a NUL byte: `/files/a%2F..%2Fb` binds `a/../b`. Treat one as text a client wrote, and validate it before using it as a file name. Static segments are compared percent-decoded too, as `net/http.ServeMux` does, for every kind of route, so `GET /users/%61dmin` is the route `/users/admin` and cannot slip round the guards it declares to be answered by a public `/users/{id}`. An encoded `%2F` never matches a static segment, and a lookup path that does not begin with `/` matches nothing. Two consequences are build errors: - A static segment that is not validly encoded (`/50%off`), or that encodes a `/` (`/a%2Fb`), could never match. Write `%25` for a literal percent sign, and use a parameter for a value that may contain `/`. - Two spellings of one path (`/a` and `/%61`) conflict, as two routes at one path do. **A wildcard receives other spellings of the routes beside it.** A path is matched as it arrives, and nothing normalises it first. With a guarded `/admin/panel` and a public `/{rest...}`, every one of `//admin/panel`, `/admin//panel`, `/admin/panel/`, `/ADMIN/panel`, `/./admin/panel`, `/x/../admin/panel`, `/%2e/admin/panel` and `/admin%2Fpanel` reaches the public wildcard, the last with `rest` set to `admin/panel`. The guarded route itself is never served without its guard, as with `net/http.ServeMux`; the danger is in what the wildcard's handler does with the value. One that serves a file, looks up a record or calls another service by it must check that the caller is allowed what the value refers to, rather than rely on the guards of a route the request did not match, and must never forward the value to a backend that cleans, decodes or case-folds paths, which may resolve it to the very resource the guard was protecting. To hand everything beneath a prefix to a `net/http` handler, such as a legacy application or `net/http/pprof`, use `Router.Mount` rather than a wildcard route that calls one. The same rule holds there: nothing normalises the path, a request reaches the mount only through its exact prefix, and a handler that cleans or case-folds the path it is given needs guards of its own. See [Mounting Handlers](/docs/techniques/mounting-handlers). A template that declares the same parameter name twice is a build error as well. `/orgs/{id}/users/{id}` used to build, and a handler binding `path:"id"` silently received the first segment, so a check on the user's id was made against the organisation's. Give each parameter its own name. Paths are matched exactly. `/items/` and `/items` are two different templates, and nothing redirects between them, so register the one you mean. Registering two routes for the same method and path is an error, with one exception: an application with versioning enabled may register several at one path as long as the versions they answer never overlap. See [Versioning](/docs/fundamentals/versioning). ## Route options Options passed after the handler configure that route alone. ```go r.Post("/items/", handlers.CreateItem, muzak.Summary("Create an item"), muzak.Description("Creates an item under the identifier the caller chooses."), muzak.Status(http.StatusCreated), muzak.OperationID("createItem"), muzak.WithResponseDoc(http.StatusConflict, "An item with that identifier already exists")) ``` | Option | What it does | |---|---| | `Summary(s)` | The one-line description shown beside the operation | | `Description(s)` | The long-form description, rendered as CommonMark | | `OperationID(id)` | The operation's identifier, which client generators use to name methods | | `Status(code)` | The status written when the handler returns without an error | | `Deprecated()` | Marks the operation deprecated in the document. Changes nothing at runtime | | `Hidden()` | Leaves the route fully routable but out of the document and the docs page | | `SkipValidation()` | Stops the input model's `Validate` method from running | | `AllowUnknownFields()` | Accepts JSON members with no matching field instead of rejecting them | | `MaxBodySize(n)` | Overrides the request body limit for this route | | `MaxUploadSize(n)` | Overrides the body limit for a route that declares a `file` field. A route that binds only `form` values is bounded by `MaxBodySize` | | `MaxFileSize(n)` | Bounds any single uploaded file | | `WithResponseDoc(code, desc)` | Documents an outcome the handler produces through an error | | `WithResponseModel[T](code, desc)` | Documents an outcome and the model its body carries | | `WithTags(...)` | Adds OpenAPI tags | | `WithCategory(name)` | Files a router, or one route, under a single category in the documentation. See [OpenAPI](/docs/fundamentals/openapi#categories-and-titles) | | `Title(s)` | A short human name the documentation lists the route by, in place of its path | | `WithSecurity(...)` / `Public()` | Says which declared security scheme a route needs, or that it needs none. Enforced for a scheme built by `JWTBearer` or `APIKeyVerifier`; a descriptive scheme is documentation only. See [Authentication](/docs/security/authentication) | | `WithDependencies(...)` | Attaches guards | | `Needs(provider)` | Declares a request-scoped value dependency | | `Singleton(provider)` / `WithSingleton(v)` | Publishes a value shared by every request | | `RateLimit(...)` / `SkipRateLimit()` / `WithRateLimit(...)` | Narrow the rate limit policy | | `WithWebSocket(...)` / `WithSSE(...)` | Configure a real-time route's connection or stream | | `WithVersion(...)` | Declare the API version or versions the route answers | Most of these are `SharedOption`, meaning they are equally valid on a router, where they apply to every route beneath it. `Summary`, `Title`, `Description`, `OperationID`, `Status` and `SkipValidation` belong to a single route; `WithPrefix` belongs to a router. ## Grouping routes into a router A router is created with the options every route beneath it should inherit. ```go [routers/items.go] package routers import ( "net/http" "awesome-api/core" "awesome-api/handlers" "muzak.dev/framework" ) // Items returns the items router. func Items() *muzak.Router { r := muzak.NewRouter(muzak.WithTags("items")) r.Get("/items/", handlers.ListItems, muzak.Summary("List items")) r.Get("/items/{item_id}", handlers.ReadItem, muzak.Summary("Read an item"), muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist"), // Only this route resolves the caller, so only this route pays for it. muzak.Needs(core.GetCurrentUser)) r.Post("/items/", handlers.CreateItem, muzak.Summary("Create an item"), muzak.Status(http.StatusCreated), muzak.WithResponseDoc(http.StatusConflict, "An item with that identifier already exists")) r.Put("/items/{item_id}", handlers.RenameItem, muzak.Summary("Rename an item"), muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist")) return r } ``` Notice what the router does not decide: it has no prefix, no authentication and no knowledge of the application it belongs to. Those are decisions made where it is included. ## Including a router ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", Addr: settings.Addr, }, muzak.WithDependencies(core.GetQueryToken), muzak.WithSingleton(settings), ) app.Include(routers.Users()) app.Include(routers.Items()) // The admin router is written without a prefix or a guard. Both are applied // here, which keeps that router reusable and puts the security decision // somewhere a reviewer will find it. app.Include(routers.Admin(), muzak.WithPrefix("/admin"), muzak.WithTags("admin"), muzak.WithDependencies(core.GetTokenHeader(settings)), muzak.WithResponseDoc(http.StatusTeapot, "I'm a teapot"), ) ``` The child keeps its own configuration and inherits everything from the parent, with the include's options layered in between. Guards contributed by the parent run before those contributed at the include, which run before the child's own. A router may be included only once. Including it twice is reported as an error when the application is built, because a route has a single resolved path. ### Prefixes `WithPrefix` must begin with `/` and must not end with one. Prefixes nest, so including a router that is itself included concatenates both. ```go muzak.WithPrefix("/admin") // "/" becomes "/admin/", "/reports" becomes "/admin/reports" ``` ```go v1 := muzak.NewRouter() v1.Include(routers.Users()) v1.Include(routers.Items()) app.Include(v1, muzak.WithPrefix("/v1")) ``` ### Options applied after creation `App.Options` adds router options to an application after `New` has returned, which is how a dependency discovered later is published. ```go models := core.NewModelRegistry() app.Options(muzak.WithSingleton(models)) ``` It must be called before the application is built. A call made afterwards **panics**, naming it, because the routing tree and the dependency chains are resolved once and the call would have no effect. That is the loud failure the mistake needs: a guard added late used to be dropped without a word and left routes open. The same holds for `Use`, `Include`, route registration, WebSocket and event stream registration, and `Frontend`, `Static` and `Mount` after `Build`, `ServeHTTP`, `Document` or a run method. Make every configuration call before the first of those. ## What the router answers on its own - **HEAD.** A `GET` route answers `HEAD` automatically by running the handler and letting `net/http` discard the body. Registering an explicit `Head` route takes precedence. Such a request is a call to the `GET` route, with its guards and security, and is named after it: the access log's `route`, `RouteFromContext`, the span and the request observer all report the `GET` route's template. - **OPTIONS.** A path with no OPTIONS route answers `204` with an `Allow` header listing every method it serves, including the automatic `HEAD` and `OPTIONS`. - **405.** A path that exists but does not serve the method answers `405` with the same `Allow` header, rendered by the error renderer like any other error. - **404.** A path that matches nothing answers `404`, unless a frontend is mounted and claims it. A path that is not valid UTF-8 gets its `404` too, with the path quoted in its escaped form, and one longer than 1 KiB is cut in the message and in the log. The `OPTIONS` and `405` answers come from the route table alone, as `net/http.ServeMux` answers them, so they skip a route's guards and rate limit. An unauthenticated client can therefore learn which paths exist and which methods they take, and never a route's data. Middleware installed with `App.Use` still covers them: put a guard or a limit there when the existence of a path is itself a secret. ```bash curl -i -X OPTIONS http://localhost:8080/items/ ``` ``` HTTP/1.1 204 No Content Allow: GET, POST, HEAD, OPTIONS ``` ## Inspecting registered routes `Router.Routes` returns the routes registered directly on a router, excluding those of any included router. Before the application is built the paths are the ones supplied at registration, without inherited prefixes. ```go if err := app.Build(); err != nil { log.Fatal(err) } for _, route := range app.Routes() { log.Printf("%s %s (%s)", route.Method, route.Path, route.OperationID) } ``` A `*Route` carries `Method`, `Path`, `Summary`, `Title`, `Description`, `OperationID`, `Tags`, `Category`, `Deprecated`, `Hidden` and `Status`. Its fields are filled in when the application is built and are read-only from that point on. The route being executed is available inside a handler as `ctx.Route()`. ## Errors reported at build time These are collected while the application is built and reported together, so a first run in a new environment lists everything wrong at once instead of one thing per attempt: - two routes registered for the same method and path, unless versioning tells them apart - an input or output type that cannot be bound or described - a path parameter no field binds, or a template that declares one name twice - a static segment that is not validly encoded, or two spellings of one path - a duplicate operation identifier - a prefix that does not begin with `/`, or that ends with one - a router included more than once - a `MaxConnections` or `MaxStreams` set anywhere but on the application - a static or frontend mount whose directory does not exist, or two mounts at one path (a second `Static` or `Frontend` used to lose to the first without a word, guards included) - a `path`, `query`, `header`, `cookie`, `form` or `file` tag on a field the binder cannot reach, and a `Nested` that would validate nothing (see [Request Data](/docs/getting-started/request-data) and [Validation](/docs/fundamentals/validation)) - a certificate without a key, or a key without a certificate - a category, a title or a security declaration that cannot be written to the document (see [OpenAPI](/docs/fundamentals/openapi)), and, unless `DisableDocs` is set, a document that cannot be encoded as JSON, which a `doc`, summary, description or example that is not valid UTF-8 causes (it used to be logged, and `/openapi.json` silently left unserved) - a version declared while versioning is off, or a versioning policy missing the field its type depends on ## Where to go next [Request Data](/docs/getting-started/request-data) covers how the input type is filled in from the request, and [Responses](/docs/getting-started/responses) covers what happens to the value you return. -------------------------------------------------------------------------------- title: "Request Data" description: "How the input struct is filled in from the path, query string, headers, cookies, form and JSON body, which types can be bound, how to keep the body as it arrived for a signature check, and what happens when the client sends something else." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/request-data" -------------------------------------------------------------------------------- # Request Data A handler's input type is the request. Its fields carry struct tags that say where each value is read from, and the framework compiles that description into a binding plan once, when the route is registered. The per-request path walks a list of precompiled setters and never inspects a type. ```go type ItemParams struct { ID string `path:"item_id" doc:"The item to operate on"` } r.Get("/items/{item_id}", func(ctx *muzak.Context, in ItemParams) (ItemOut, error) { return ItemOut{ID: in.ID}, nil }) ``` A route that reads nothing declares `muzak.Empty`, and binding is skipped entirely for it. ## Where a field comes from | Tag | Source | Example | |---|---|---| | `path:"name"` | A `{name}` parameter in the route template | ``ID string `path:"item_id"` `` | | `query:"name"` | The query string | ``Limit int `query:"limit"` `` | | `header:"Name"` | A request header, matched case-insensitively | ``Host string `header:"Host"` `` | | `cookie:"name"` | A cookie sent by the client | ``SessionID string `cookie:"session_id"` `` | | `form:"name"` | A form value in a multipart or urlencoded body | ``Username string `form:"username"` `` | | `file:"name"` | A file in a multipart body | ``File muzak.File `file:"file"` `` | | *none of the above* | The JSON request body | ``Name string `json:"name"` `` | | a `muzak.Dep[T]` field | The route's provider of `T`, never the request | ``User muzak.Dep[CurrentUser]`` | A field with no location tag is a member of the JSON body, and its `json` tag names the member. That is the rule the whole binder rests on: you never say "this route takes a body", you declare fields that have nowhere else to come from. The one exception is a `muzak.Dep[T]` field, which receives a value a provider resolved. It is never read from the request: it is not a body member (a body naming it is refused as an unknown member, or ignored under `AllowUnknownFields`), it cannot carry a location tag, and it is left out of the OpenAPI document. See [Dependencies](/docs/getting-started/dependencies). ```go // Every field here comes from the body, because none of them carries a // location tag. type ItemCreateIn struct { ID string `json:"id" doc:"The identifier to create the item under"` Name string `json:"name" doc:"The item's display name"` Async bool `json:"async,omitzero" doc:"Queue the work instead of doing it inline"` } ``` The `doc` tag is the field's description in the generated OpenAPI document. It works on parameters and on body members alike. ## Types that can be bound A parameter arrives as text, so the binder converts it. These types work anywhere a `path`, `query`, `header`, `cookie` or `form` tag appears: - `string` - `bool`, parsed with `strconv.ParseBool`, so `true`, `1`, `t`, `false`, `0` and `f` all work - every signed and unsigned integer width, and `float32` / `float64`, written as JSON writes them: digits with an optional `-`, and for a float an optional fraction and exponent. Leading zeros are allowed. Hexadecimal, `1_000`, `.5` and a leading `+` are refused, so a proxy or a firewall reading the number by JSON's rules sees the value the handler does. `NaN`, `Inf` and `Infinity` are refused (`must be a valid number`), because they pass comparisons such as `amount > balance`. A value outside the type's range names the range: `?n=300` for an `int8` reads `must be between -128 and 127` - `time.Duration`, written the way Go writes it: `1500ms`, `2s`, `1h30m` - any type implementing `encoding.TextUnmarshaler`, which covers `time.Time` (RFC 3339) and `uuid.UUID` - a slice of any of the above, filled from a repeated parameter - a pointer to any of the above, which is what makes a field optional Anything else is a build-time error naming the field, rather than a request that fails mysteriously later. ```go type FilterIn struct { Since time.Time `query:"since" doc:"Only entries after this moment, as RFC 3339"` Within time.Duration `query:"within" default:"24h" doc:"How far back to look"` Tags []string `query:"tag" doc:"May be repeated"` OwnerID uuid.UUID `query:"owner_id" doc:"Restrict to one owner"` } ``` ```bash curl 'http://localhost:8080/entries?tag=go&tag=http&within=6h' ``` A repeated parameter fills a slice in the order the values arrived. A non-slice query, header or form parameter sent more than once is a `422`, `must be given only once`: taking the first value, as the binder used to, while a proxy in front takes the last, lets a request pass a check with one value and be served with another. A cookie still takes the first value. A `[]byte` parameter is a slice of numbers like any other: each value is one element, so `?data=65` is `[]byte{65}`, and `?data=aGk=` is a `422`. A `[]byte` in a JSON body is base64, as `encoding/json` has it. A rejected entry of a repeated parameter is named by its position (`entry 2 must be a valid integer`) and not quoted back, since the value is the client's own text and quoting it turned every rejection into an amplifier. ### Types the binder does not know Implement `encoding.TextUnmarshaler` and the binder accepts your type. A failing `UnmarshalText` reads `is not in the expected format` to the client, because the standard library's own types write errors that are no fit for one (`time.Time` names its layout and quotes the whole input back). To send your own wording, return an `*HTTPError`, whose message is written for the client by contract, and phrase it to read after the field name. ```go [schemas/feed.go] // HTTPDate is a time carried in the format HTTP dates use. // // It exists because If-Modified-Since is not RFC 3339, which is what a bare // time.Time parses. type HTTPDate struct { time.Time } // UnmarshalText parses an HTTP date, as RFC 9110 defines it. func (d *HTTPDate) UnmarshalText(text []byte) error { parsed, err := http.ParseTime(string(text)) if err != nil { return muzak.NewHTTPError(http.StatusUnprocessableEntity, "must be an HTTP date, such as Wed, 21 Oct 2026 07:28:00 GMT") } d.Time = parsed return nil } ``` ```go type ClientHeaders struct { IfModifiedSince HTTPDate `header:"If-Modified-Since" doc:"Answer 304 when nothing changed since this time"` } ``` ## Required, optional and defaults The rule differs by location, because the locations differ in what absence means. | Location | Required by default | How to change it | |---|---|---| | `path` | Always. The route matched, so the value exists | Not applicable | | `query`, `header`, `cookie` | No | `required:"true"` | | `form` | Yes, unless the field carries a `default` | `required:"false"` | | `file` | Yes | `required:"false"` | | JSON body | The body itself is required whenever any field binds it | Enforce individual members with validation rules | ```go type UserListQuery struct { // Optional, defaulting to 20 when the client sends nothing. Limit int `query:"limit" default:"20" doc:"Maximum number of users to return"` // Optional, and empty when absent. Cursor string `query:"cursor" doc:"Opaque cursor from a previous page"` } ``` ```go type SessionCookies struct { // Required: the request is refused with 422 when the cookie is missing. SessionID string `cookie:"session_id" required:"true" doc:"The reader's session"` } ``` A field that is both `required:"true"` and given a `default` is a build error, because the two contradict each other. `default` also applies to a member of a JSON body. A top-level member, or one promoted from an embedded struct, that the client omits takes the default before any rule runs, and one it sends wins. A default that does not parse as its field's type is a build error, and a member whose type cannot be written from text (a struct, a map) has no default. The generated document writes a default as the JSON type of the field (`10`, not `"10"`), and only where something applies it: not on a member of a nested model, and not on a response type. Because the default is written before the rules run, a `Required()` rule on a member that has one is not a failure and does not make the document list it as required. ### Absent against zero A pointer field stays `nil` when the client sent nothing, which is how an absent value is told from a zero one. ```go type WSItemIn struct { ItemID string `path:"item_id" doc:"The item being talked about"` // Stays nil when the client did not send one. Q *int `query:"q" doc:"An optional number echoed back with each reply"` } ``` ```go if in.Q != nil { fmt.Fprintf(w, "q is %d", *in.Q) } ``` `Context.LookupQuery` and `Context.LookupPath` answer the same question without a pointer, distinguishing `?token=` from a missing `token`. ## Sharing groups of fields Embedding a struct promotes its fields onto the input, so a set of parameters several routes share is declared once. ```go [schemas/feed.go] // ClientHeaders groups the request headers every read endpoint cares about. type ClientHeaders struct { Host string `header:"Host" doc:"The authority the request was addressed to"` SaveData string `header:"Save-Data" default:"off" doc:"Set to on by a client asking for a smaller payload"` IfModifiedSince HTTPDate `header:"If-Modified-Since" doc:"Answer 304 when nothing changed since this time"` Traceparent string `header:"traceparent" doc:"W3C trace context of the calling span"` Tags []string `header:"X-Tag" doc:"Restrict the feed to these tags; may be repeated"` } // SessionCookies groups the cookies the browser sends back. type SessionCookies struct { SessionID string `cookie:"session_id" required:"true" doc:"The reader's session"` FatebookTracker string `cookie:"fatebook_tracker" doc:"Third party analytics cookie, if the reader accepted one"` GoogallTracker string `cookie:"googall_tracker" doc:"Third party analytics cookie, if the reader accepted one"` } // FeedIn is two shared groups plus the one parameter this route owns. type FeedIn struct { ClientHeaders SessionCookies Limit int `query:"limit" default:"20" doc:"How many entries to return"` } ``` The handler then reads `in.SaveData`, `in.SessionID` and `in.Limit` as if they were declared on `FeedIn` itself, and the names, docs and defaults live in exactly one place. ## Tags the binder must be able to reach A location tag is read at the top level of the input and in structs embedded **by value**. A `path`, `query`, `header`, `cookie`, `form` or `file` tag anywhere the binder cannot reach is a build error: inside an embedded pointer, a named nested struct, or a slice, map or pointer of one, and on an unexported field. It used to be ignored, so the field silently became JSON body content and a request could set, for example, the user identifier a gateway puts in a header. ```go type Bad struct { Auth struct { UserID string `header:"X-User-Id"` // build error: not reachable from here } } ``` Embed the struct by value, move the field to the top level of the input, or export it. A located field is never a member of the JSON body, whatever its `json` tag says: a body naming it is refused as an unknown member, or ignored under `AllowUnknownFields`. `json:"-"` on a located field only keeps it out of the JSON body and the schema. The header or query value, its default and its required check are all still applied. ## The JSON body Bodies are decoded with `encoding/json/v2`, and the defaults are strict rather than forgiving. - **Unknown members are rejected.** A client's typo becomes an immediate `422` naming the member instead of a value that silently disappears. - **Duplicate members are rejected.** - **Invalid UTF-8 is rejected.** - **A body is required** when any field binds it. An empty body reports `{"field": "", "location": "body", "issue": "is required"}`. - **The media type is checked.** `application/json` and anything ending in `+json` are accepted. Anything else is `415`, and so is a body sent with **no** `Content-Type` at all. Accepting an untyped body let any page drive a JSON route cross-site without the CORS preflight that `application/json` triggers, by posting an untyped `Blob`. A request with no body and no `Content-Type` is unaffected. Send `Content-Type: application/json` with every JSON body. ```bash curl -X POST http://localhost:8080/items/ \ -H 'Content-Type: application/json' \ -d '{"id":"foo","nmae":"Foo"}' ``` ```json { "error": { "code": "validation_error", "message": "The request could not be validated.", "status": 422, "details": [ { "field": "nmae", "location": "body", "issue": "is not a field this endpoint accepts" } ] }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` A member of a kind its field cannot hold at all, a string for an integer, reports `has the wrong type, a string is not accepted here`. One of the right kind that does not fit is described as a parameter of the same type would be: `300` for an `int8` reads `must be between -128 and 127`, `1.5` for an `int` reads `must be a valid integer`, and a time that does not parse reads `is not in the expected format`. The `field` is the member's full path, `addr.zip` or `items[1].zip`, as a validation failure names it. A `time.Duration` member is a string such as `"1500ms"`, both in a request and in a response, as the generated document says. Where forward compatibility with clients that send extra members matters more than catching typos, relax it for the route or the router: ```go r.Post("/webhooks/stripe", handlers.StripeWebhook, muzak.AllowUnknownFields()) ``` Duplicate members and invalid UTF-8 stay rejected regardless. ### Mixing a body with parameters An input may take some fields from the request line and others from the body. ```go // The body can only ever reach Name: binding decodes it into a struct of the // body members alone, so a crafted body cannot write to ID, or even name it. type ItemRenameIn struct { ID string `path:"item_id" doc:"The item to rename"` Name string `json:"name" doc:"The item's new display name"` } ``` That protection is not advisory. When an input has any located field, the body is decoded into a struct of the body members alone, built when the route is registered, and those are copied into the input. A request carrying `{"ID": "somebody-elses-item", "name": "x"}` cannot overwrite the path parameter, and the `ID` member is an unknown member like any other: a `422`, rather than a value silently dropped, or ignored under `AllowUnknownFields`. ### Form and file fields exclude a JSON body An input that binds `form` or `file` fields reads a form body, so it cannot also declare JSON members. A field with no location tag on such an input is a build error that tells you to tag it with `form` or move it to the path, query, header or cookie. See [Forms and HTML](/docs/techniques/forms-and-html) and [File Uploads](/docs/techniques/file-uploads). ## Body size limits Request bodies are capped at `muzak.DefaultMaxBodySize`, one mebibyte, and a body over the limit is refused with `413`, so the server never buffers more than the limit. A body that **declares** a `Content-Length` over the limit is refused before any of it is read, for JSON, urlencoded and multipart bodies alike. That matters for a client that sent `Expect: 100-continue`, which would otherwise have been told to go ahead and only refused after the body had been read up to the limit. A body that declares no length, as a chunked one does not, is still bounded as it is read. ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", MaxBodySize: 4 << 20, // the application-wide default }) r.Post("/documents/", handlers.CreateDocument, muzak.MaxBodySize(16<<20)) // just this route ``` A limit that is not positive removes it, which is only appropriate behind a proxy that imposes its own. A route that declares a `file` field uses `MaxUploadSize` instead, which defaults to 32 mebibytes. A route that binds only `form` values takes its urlencoded **and** its multipart body under `MaxBodySize`: a login form held under the 32 MiB upload limit made `net/http` keep up to 20 MiB of text values (about 49 MiB of heap per request in flight) before any rule could run. A form-only route that legitimately receives more than a mebibyte must raise `MaxBodySize` for it. See [File Uploads](/docs/techniques/file-uploads). ## The body as it arrived Binding gives you the decoded value. A request whose authentication covers its **bytes** needs the bytes, and by the time a handler runs they are gone: the binder read them to decode the input. A route whose input binds nothing from the body (`muzak.Empty`, or only path, query, header and cookie fields) leaves the body to the handler, which may read `ctx.Request().Body` itself: a proxy, an upload streamed to disk, or a signature check over the raw bytes. What the handler leaves unread is dropped after it returns, up to 4 KiB, which is still what lets a keep-alive connection be reused when the body was not read. The drain used to run **before** the handler, so a handler that read the body itself lost its first 4 KiB without any error. A route that binds a body is unchanged, an event stream route is treated like one that binds none, and a WebSocket handshake still drains first: it is refused if it carries a body, and nothing may be left to be taken for frames. A guard or a provider runs before the handler, and before binding, so it cannot read the body without taking it from the handler. `CaptureBody` keeps the bytes for both. ```go r.Post("/webhooks/stripe", handlers.Stripe, muzak.CaptureBody(), muzak.Needs(core.VerifyStripeSignature)) ``` ```go func VerifyStripeSignature(ctx *muzak.Context) (Verified, error) { body, ok := ctx.RawBody() if !ok { // The route did not declare CaptureBody. Refuse rather than verify // against nothing. return Verified{}, muzak.NewHTTPError(500, "this route does not capture its body") } mac := hmac.New(sha256.New, secret) mac.Write(body) if !hmac.Equal(mac.Sum(nil), signature) { return Verified{}, muzak.Unauthorized("the signature does not match") } return Verified{}, nil } ``` This is not a niche case. Stripe, GitHub, Slack and Apple's SKAdNetwork postbacks all sign the raw body, and **a signature must be checked against the original**. Verifying a re-encoding verifies a different document: a decoder that reorders members, normalises a number or drops insignificant whitespace produces bytes the client never signed, so every signature fails and the reason looks like a bug in the client's signing code. Four things worth knowing: - **It runs before the dependencies**, so a guard or a provider sees the bytes and an unsigned request is refused before anything decodes it. It runs *after* the rate limit is counted, so a client past its budget never causes the server to buffer anything. - **It composes with binding.** The handler still receives its decoded input; the bytes are in addition to it, not instead of it. - **The size limit is the route's own.** `MaxBodySize` already bounds the body and there is no second knob. A body over it is refused with `413` rather than truncated, because a truncated body fails its signature check and that reads as an attack rather than as the oversized request it is. - **The second return value matters.** A helper shared between a route that captures and one that does not would otherwise verify a signature against an empty body and pass. It is a shared option, so a router of webhook receivers declares it once: ```go r := muzak.NewRouter(muzak.CaptureBody(), muzak.WithTags("webhooks")) ``` Do not reach for it application-wide. Buffering costs memory for every request in flight, and a route with nothing to verify has nothing to gain. The bytes live as long as the handler does and are released with the pooled `Context`, so anything that outlives the request has to copy them. ## Reading the request directly Binding covers the typed case. When you need the raw request, the context has it. ```go func Handler(ctx *muzak.Context, _ muzak.Empty) (Out, error) { ctx.Query("token") // first value, or "" ctx.LookupQuery("token") // value and whether it was present at all ctx.QueryValues("tag") // every value, in order ctx.Header("X-Request-Id") // first value, matched case-insensitively ctx.Cookie("session_id") // (*http.Cookie, error) ctx.PathValue("item_id") // captured path parameter, percent-decoded once ctx.LookupPath("item_id") // value and whether the template declares it ctx.RawBody() // the body as it arrived, with CaptureBody ctx.Request() // the underlying *http.Request ctx.ClientIP() // the address the request is attributed to ctx.RequestID() // the identifier assigned to this request ctx.Route() // the route being executed ctx.Logger() // the request-annotated logger ctx.Context() // the request's context.Context return Out{}, nil } ``` A path parameter is percent-decoded once, so `/files/a%2F..%2Fb` binds `a/../b`: the value can hold `/`, `..` and a NUL. Validate one before using it as a file name. `ctx.Context()` is cancelled when the client disconnects or the request ends. Starting a shutdown does **not** cancel it: an ordinary request is left to finish within `ShutdownTimeout`, and only when that deadline passes are its connection closed and its context cancelled. A handler that waits on the context alone therefore holds the shutdown for the whole timeout. A long-lived response should be an [event stream](/docs/realtime/server-sent-events) or a [WebSocket](/docs/realtime/websockets) route, whose own contexts and connections are ended when shutdown begins. A query parameter and a body member with the same name are two fields. A query value that failed to parse no longer hides the failure of a body member called the same, and the other way round. A `Context` is pooled and reused across requests. It must not be retained or used after the handler returns; pass `ctx.Context()` to anything that outlives the handler. ## Where to go next [Validation](/docs/fundamentals/validation) turns a bound value into a checked one, and [Responses](/docs/getting-started/responses) covers what happens to what you return. Each location has a page of its own for the details: [Headers](/docs/techniques/headers), [Cookies](/docs/techniques/cookies), [JSON](/docs/techniques/json), [Forms and HTML](/docs/techniques/forms-and-html) and [File Uploads](/docs/techniques/file-uploads). [Typed Endpoints](/docs/techniques/typed-endpoints) runs the same binding plan the other way: a Go client writes an input value as the request this binder reads back. -------------------------------------------------------------------------------- title: "Responses" description: "The handler's return type is the response model. How status codes, headers, cookies, HTML and empty responses are decided, and when to write the response yourself." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/responses" -------------------------------------------------------------------------------- # Responses The second type parameter of a handler is the response body, serialized exactly as returned. ```go type ItemOut struct { ID string `json:"id" doc:"The item's identifier"` Name string `json:"name" doc:"The item's display name"` Owner string `json:"owner,omitzero" doc:"The user the item belongs to"` } r.Get("/items/{item_id}", func(ctx *muzak.Context, in ItemParams) (ItemOut, error) { return ItemOut{ID: in.ID, Name: "Foo"}, nil }) ``` There is no wrapper type, no `response_model=` and no filtering pass at run time. The handler's return type **is** the response model, which means a field you did not declare cannot leak: it is not part of the type, and the compiler is what tells you, not a bug report. That is why response models are kept separate from storage types: ```go [core/store.go] // Item is the store's own type, deliberately separate from the response // models. A field added here does not become visible to clients until a // handler puts it on a response type. type Item struct { ID string Name string } ``` The body is encoded fully into a buffer before anything is written, so a failure part way through encoding still produces a clean error response instead of a truncated body. `Content-Type: application/json; charset=utf-8` and `Content-Length` are set for you. A `time.Duration` member is written as a string such as `"1500ms"`. A failure **after** the response has started, whether a panic or an error returned once the first byte was written, cannot be turned into an error response. Muzak logs it once and aborts the connection, so the client sees a failed transfer and not a body that ends cleanly and looks complete. The access log marks the request `aborted=true`. See [Error Handling](/docs/getting-started/error-handling#a-failure-after-the-response-started). ## Status codes A status that never changes for a route is part of the route. ```go r.Post("/items/", handlers.CreateItem, muzak.Status(http.StatusCreated)) ``` A status that depends on what happened is set in the handler. ```go func CreateItem(ctx *muzak.Context, in schemas.ItemCreateIn) (schemas.ItemOut, error) { store := muzak.From[*core.ItemStore](ctx) if err := store.Create(core.Item{ID: in.ID, Name: in.Name}); err != nil { return schemas.ItemOut{}, asHTTPError(err) } if in.Async { ctx.SetStatus(http.StatusAccepted) } return schemas.ItemOut{ID: in.ID, Name: in.Name}, nil } ``` The two are orthogonal. You never have to choose between returning a value and setting a status, and you never declare something dynamic as if it were fixed. When both are used the imperative call wins. Details worth knowing: - Without either, the status is `200`. - `ctx.Status()` reports what will be written, which is the route's declared default until `SetStatus` changes it. - Codes outside 100 to 599 are clamped to 500. - Calling `SetStatus` after the body has started writing has no effect and is logged at warn level, because the status line has by then been sent. A `Flush` counts as starting the response even before the first `Write`, because it sends an implicit `200`. - `204` and `304` write no body at all, whatever the handler returned. ```go func Feed(ctx *muzak.Context, in schemas.FeedIn) (schemas.FeedOut, error) { if !in.IfModifiedSince.IsZero() && !feedUpdatedAt.After(in.IfModifiedSince.Time) { ctx.SetStatus(http.StatusNotModified) return schemas.FeedOut{}, nil } // ... } ``` ## Headers ```go ctx.SetHeader("Last-Modified", feedUpdatedAt.UTC().Format(http.TimeFormat)) ctx.SetHeader("Vary", "Save-Data, X-Tag, Cookie") ctx.AddHeader("Vary", "Accept-Encoding") // appends rather than replacing ``` `SetHeader` replaces any value already set; `AddHeader` appends, which is what repeated headers such as `Set-Cookie` and `Vary` require. Headers must be set before the handler returns: once the response has begun, `net/http` ignores further changes. A handler's own `Vary` **adds to** the fields the framework recorded for the response; it does not erase them. CORS (`Origin`, and the preflight request headers), the locale middleware (`Accept-Language`, `Cookie` or the locale header, whichever sources are configured) and header or media-type versioning each record the field their answer depends on, and the fields are merged into `Vary` when the response is written. So a handler doing ordinary content negotiation with `ctx.SetHeader("Vary", "Accept")` no longer replaces a `Vary: Origin` and leaves a shared cache free to store a reflected `Access-Control-Allow-Origin` for every client. ### Caching what is behind a guard A route or a file mount behind a guard, behind a request-scoped provider (`Needs`), or behind a security scheme it enforces (one built by `JWTBearer` or `APIKeyVerifier`) is sent `Cache-Control: private, no-cache`, so a CDN cannot hand one user's response to another. An enforced scheme also adds to `Vary` the header its credential is read from: `Authorization`, or an API key's own header, or `Cookie`. A route keeps a `Cache-Control` its handler or a middleware set; a mount replaces one. A singleton alone does not count as a reason, and event streams and WebSocket upgrades are unchanged. A handler serving public data from behind such a route sets its own `Cache-Control`: ```go ctx.SetHeader("Cache-Control", "public, max-age=300") ``` A handler that reads its [session](/docs/security/sessions) is treated the same way: the response varies on `Cookie` and is sent `private, no-cache` unless the handler set its own `Cache-Control`. A response that carries the session cookie is the exception to that exception. It is kept out of shared caches whatever sets `Cache-Control`, before or after the cookie, the handler and the error renderer included, because a cache that stored it would hand one user's session to the next. ## Cookies ```go ctx.SetCookie(&http.Cookie{ Name: "theme", Value: in.Theme, Path: "/", // HttpOnly keeps the value out of reach of scripts, and SameSite keeps it // off cross-site requests. Secure belongs here too once this is served // over TLS, which is why it is named rather than omitted. HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: false, MaxAge: int((365 * 24 * time.Hour).Seconds()), }) ``` Muzak does not modify the cookie. Setting `Secure`, `HttpOnly` and `SameSite` appropriately is the caller's job, which is covered in [Cookies](/docs/techniques/cookies). Do not build a session out of a cookie like this one. `AppOptions.Sessions` gives every request a session through `ctx.Session()`, kept in an encrypted cookie or a server-side store, written once when the request succeeds, and given a new identity on sign-in with `Regenerate`. See [Sessions](/docs/security/sessions). ## Responses with no body `muzak.Empty` is also usable as an output type. The generated document then describes no response content at all, which is the honest description of a route that answers with nothing. ```go r.Delete("/items/{item_id}", func(ctx *muzak.Context, in ItemParams) (muzak.Empty, error) { store := muzak.From[*core.ItemStore](ctx) if _, err := store.Get(in.ID); err != nil { return muzak.Empty{}, muzak.NotFound("Item not found") } return muzak.Empty{}, nil }, muzak.Status(http.StatusNoContent)) ``` Pair it with `muzak.Status(http.StatusNoContent)`. At `200` an `Empty` value still encodes as `{}`, since that is what it serializes to; at `204` nothing is written. ## HTML A handler returning `muzak.HTML` bypasses JSON encoding entirely. The string is written verbatim under `text/html`, and the generated document describes the response as `text/html` rather than as a JSON schema. ```go func LoginForm(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) { return muzak.HTML(`
`), nil } ``` Everything else about the route is unchanged, so its status, headers and errors work exactly as they do for a JSON route. The value is written as given: Muzak does not escape it, because it cannot tell markup the handler meant from text it did not. Interpolating anything a client supplied is the handler's job to escape, with `html/template` or `html.EscapeString`. ## Documenting other outcomes A handler that reports a failure through an error does not describe that outcome in its return type, so declare it at registration. ```go r.Get("/items/{item_id}", handlers.ReadItem, muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist")) ``` `WithResponseDoc` is recorded in the OpenAPI document and has no effect at runtime. Applied to a router, it documents the response for every route beneath it, which is how `app.Include(admin, muzak.WithResponseDoc(418, "I'm a teapot"))` reads. The body is described as Muzak's error envelope, because that is what a returned error produces. When the outcome carries a body of its own instead, name the model: ```go r.Post("/users/", handlers.CreateUser, muzak.Status(http.StatusCreated), muzak.WithResponseModel[schemas.ErrorOut](http.StatusBadRequest, "The request was malformed")) ``` One operation can carry a different schema at every status code that way. [Response Models](/docs/techniques/response-models) covers it in full, including inheritance, `Empty` and `HTML` models, and what reaches the document. ## Files, streams and redirects A body that is not JSON has an output type of its own: `muzak.Bytes` for a body held in memory, `muzak.Stream` for one copied to the client as it is read, `muzak.FileResponse` for a file, and `muzak.Redirect` for a redirect. A download is a `FileResponse`: ```go var reports, _ = os.OpenRoot("/var/lib/app/reports") func Download(ctx *muzak.Context, in FileParams) (muzak.FileResponse, error) { return muzak.FileResponse{FS: reports.FS(), Name: in.Name, Download: true}, nil } ``` The route keeps everything a JSON route has: its status follows `Status` and `SetStatus`, a returned error is rendered as usual, and the generated document describes the body. `FileResponse` answers ranges and conditional requests with `http.ServeContent`, answers a `Name` that would leave the filesystem or names a dotfile with `404`, as it would a missing file, and sends `X-Content-Type-Options: nosniff`. `Bytes` and `Stream` take a `ContentType`, and all three take a `Filename` and `Download` to offer the body as a file to save. [Files, Streams and Redirects](/docs/techniques/files-streams-redirects) covers the four types, `Produces` for documenting their media types, and `AutoETag`. ## Writing the response yourself For a body none of those types describes, such as one written by a library that takes an `http.ResponseWriter`, take the writer. ```go func Chart(ctx *muzak.Context, in ChartParams) (muzak.Empty, error) { ctx.SetHeader("Content-Type", "image/png") if err := charts.Render(ctx.ResponseWriter(), in.Series); err != nil { return muzak.Empty{}, err } return muzak.Empty{}, nil } ``` Writing to `ctx.ResponseWriter()` bypasses Muzak's response encoding: the returned value is not serialized and `SetStatus` stops having an effect, so return the zero `Out` value with a nil error afterwards. The writer supports `http.ResponseController`, so flushing and hijacking work as usual. A handler that hijacks owns the connection from then on: nothing is written after it returns, a returned error is logged but not rendered, and the access log records status 101. An informational response such as `103 Early Hints`, written with `ctx.ResponseWriter().WriteHeader(http.StatusEarlyHints)` before the final response, no longer swallows the response that follows it. For streaming a sequence of events rather than one body, reach for [Server-Sent Events](/docs/realtime/server-sent-events) instead, which keeps the typed contract and the generated documentation. ## Where to go next [Dependencies](/docs/getting-started/dependencies) covers how services and resolved values reach a handler, [Error Handling](/docs/getting-started/error-handling) covers what happens when a handler returns an error, and [JSON](/docs/techniques/json) covers the encoding in detail. [Typed Endpoints](/docs/techniques/typed-endpoints) shows how a Go client calling the route gets the handler's response type back, decoded, instead of reading the JSON itself. -------------------------------------------------------------------------------- title: "Dependencies" description: "Guards that validate a request, providers that hand a typed value to a handler, Dep fields that declare it on the input, Acquire for values that need cleaning up, request transactions, singletons, and overrides for tests." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/dependencies" -------------------------------------------------------------------------------- # Dependencies Dependencies come in exactly two shapes. A **guard** validates and produces nothing. A **provider** produces a typed value. Both run before the handler, and both can refuse the request by returning an error. A provider's value is read with `From` or, declared on the input type, with a `Dep` field. A provider that has to clean up after the request, such as a database transaction, is declared with `Acquire`. Tests replace any provider with `Override`. ## Guards A guard is `func(ctx *muzak.Context) error`. ```go [core/dependencies.go] // GetQueryToken is a guard dependency applied to the whole application. // // It rejects any request that does not carry a token query parameter. A guard // produces no value; it either lets the request through or returns the error // that becomes the response. func GetQueryToken(ctx *muzak.Context) error { if ctx.Query("token") == "" { return muzak.BadRequest("token is required") } return nil } ``` Attach it with `WithDependencies`, which works on an application, a router, or a single route. ```go // Every route in the application sits below this guard. app := muzak.New(muzak.AppOptions{Title: "Awesome API"}, muzak.WithDependencies(core.GetQueryToken)) // Only the admin subtree sits below this one. app.Include(routers.Admin(), muzak.WithPrefix("/admin"), muzak.WithDependencies(core.GetTokenHeader(settings))) // Only this route. r.Post("/reindex", handlers.Reindex, muzak.WithDependencies(RequireMaintenanceWindow)) ``` Guards run in declaration order, outermost first: those declared on the application run before those declared when including a router, which run before the route's own. The first guard to return an error stops the chain and produces the response. Two shared-secret guards ship with the framework, both comparing in constant time: ```go muzak.WithDependencies(muzak.RequireBearerToken(settings.AdminToken)) muzak.WithDependencies(muzak.RequireHeaderToken("X-Token", "coneofsilence")) ``` They are meant for the shared-secret case, such as an internal service or a webhook receiver. Anything involving per-user credentials wants a provider that resolves the user instead. ## Providers A provider is `func(ctx *muzak.Context) (T, error)`. Declare it with `Needs`. ```go [core/dependencies.go] // CurrentUser is the authenticated caller. type CurrentUser struct { Username string } // GetCurrentUser resolves the caller from the Authorization header. func GetCurrentUser(ctx *muzak.Context) (CurrentUser, error) { token, present := muzak.BearerToken(ctx) if !present { return CurrentUser{}, muzak.Unauthorized("unauthorized") } user, err := lookUp(token) if err != nil { return CurrentUser{}, muzak.Unauthorized("unauthorized") } return user, nil } ``` A provider can resolve the caller from anything the request carries: a header, as here, or the session a browser signed in with, read through `ctx.Session()` (see [Sessions](/docs/security/sessions)). The handler reads the value in one of two ways, and both are checked by the compiler. There is no `interface{}`, no type assertion, no service locator and no string key to mistype. The type parameter of `Needs` is inferred from the provider, so it is never written at the call site either. ### On the input, with Dep A `muzak.Dep[T]` field on the input type declares what the handler depends on, and `Get` reads it: ```go [schemas/items.go] type ReadItem struct { ID string `path:"item_id"` User muzak.Dep[CurrentUser] } ``` ```go [handlers/items.go] func ReadItem(ctx *muzak.Context, in schemas.ReadItem) (schemas.ItemOut, error) { return schemas.ItemOut{ID: in.ID, Owner: in.User.Get().Username}, nil } ``` ```go [routers/items.go] r.Get("/items/{item_id}", handlers.ReadItem, muzak.Needs(core.GetCurrentUser)) ``` What a `Dep` buys over `From` is when a mistake surfaces. Every `Dep[T]` must be satisfied by a provider of **exactly** type `T` declared on the route or a router above it, with `Needs`, `Acquire`, `Transaction`, `Singleton` or `WithSingleton`. A route that forgot one is not built, and the build error names the route, the field and the type: ```text muzak: GET /items/{item_id}: field User is a muzak.Dep[core.CurrentUser], but no provider of exactly core.CurrentUser is declared on the route or a router above it; declare one with muzak.Needs, muzak.Acquire, muzak.Transaction, muzak.Singleton or muzak.WithSingleton ``` A provider of `*CurrentUser` does not satisfy a `Dep[CurrentUser]`, and the error says so when that is the mix-up. The verified caller is the one value no provider is needed for. A route whose `WithSecurity` names a `JWTBearer` or `APIKeyVerifier` scheme fills a `Dep[*muzak.Claims]` or a `Dep[*muzak.APIKeyPrincipal]` from the credential it verified, and the build refuses such a field unless every way into the route goes through that scheme. See [Authentication](/docs/security/authentication). A `Dep` is never read from the request: - It is not a JSON body member. A body that names it is refused as an unknown member, or ignored under `AllowUnknownFields`, so a client can never set or null the value. - It cannot carry a `path`, `query`, `header`, `cookie`, `form` or `file` tag; one that does is a build error. - It does not appear in the OpenAPI document, as a parameter or as a body member. It is filled after the dependencies have resolved and before the input is bound and validated, so a `Validate` method may read it. Muzak also calls `Validate` once at build time on a zero input, to learn its rules, and there `Get` returns the zero value, so code that reads a `Dep` in `Validate` must cope with it. A `Dep` must be a named, exported field at the top level of the input, or of a struct embedded in it by value, which is how a set of dependencies is shared between inputs: ```go type Authenticated struct { User muzak.Dep[CurrentUser] } type UpdateItem struct { Authenticated ID string `path:"item_id"` Title string `json:"title"` } ``` A `Dep` behind a pointer, inside a slice, an array or a map, in a nested struct, embedded directly, or unexported would never be filled, so each of those is a build error rather than an empty value at run time. A `Dep` is filled in place and costs no allocation, and an input without one pays nothing for the feature. `Dep` works on event stream and WebSocket routes too. ### In code, with From `muzak.From[CurrentUser](ctx)` returns the same value. It remains the way to read a dependency where there is no input type: in a guard, a provider, or a helper shared between handlers. ```go r.Get("/items/{item_id}", func(ctx *muzak.Context, in ItemParams) (ItemOut, error) { user := muzak.From[CurrentUser](ctx) return ItemOut{ID: in.ID, Owner: user.Username}, nil }, muzak.Needs(core.GetCurrentUser)) ``` `From` panics when the route never declared the type, because that is a bug in the wiring rather than a condition to handle. The panic is caught by the recovery middleware and reported as a 500 with the details logged, but it is a mistake to fix rather than an error to recover from. A `Dep` catches the same mistake when the application is built instead. A provider of an interface type may return `nil, nil`, which is the natural encoding of an anonymous caller. `From`, `TryFrom` and `Dep.Get` return the nil the provider produced, rather than failing the request. Where the absence of a dependency is a legitimate state, use `TryFrom`. ```go [core/ratelimit.go] // UserOrIPTracker spends a request from the caller's budget when there is a // caller, and from the address's otherwise. // // The identity is read with TryFrom rather than From, because most routes here // resolve no user at all and an absent dependency is a legitimate state for // this tracker rather than a programming error. func UserOrIPTracker(ctx *muzak.Context) (string, error) { if user, ok := muzak.TryFrom[CurrentUser](ctx); ok { return "user:" + user.Username, nil } return muzak.IPTracker(ctx) } ``` Resolved values live on the request's `Context` and are cleared when it returns to the pool, so two concurrent requests can never see each other's values. ### Declaring a provider for a whole router `Needs` is a shared option, so a router can declare it once for everything beneath it. ```go r := muzak.NewRouter(muzak.WithTags("items"), muzak.Needs(core.GetCurrentUser)) ``` Declaring the same type more than once along the chain is allowed, and the most recent declaration overrides **the value, not the check**. Every provider on the chain runs, outermost first, and the first error aborts the request; `From`, `TryFrom` and `Dep` then return the value of the innermost declaration. That matters because a provider is often also an authorization step: a router included with `Needs(RequireAdmin)` keeps rejecting non-admins even when a route inside it declares `Needs(CurrentUser)` of the same type, rather than the nearer declaration silently removing the outer one and the check with it. A provider that calls `From` for its own type sees the value produced by the nearest enclosing declaration of that type, since its own value does not exist yet, which lets an inner provider refine an outer one instead of repeating its work. If an outer provider was being deliberately replaced, it now also runs and can fail the request. Declare it only where it applies, or give the two values distinct types. ## Dependencies that clean up: Acquire Some values have to be released when the request is over: a transaction committed or rolled back, a connection returned to its pool, a lock released, a temporary file removed. FastAPI writes these as a dependency that yields; Muzak writes them with `Acquire`, whose provider returns a `Release` beside the value: ```go [core/dependencies.go] // OpenSession checks a session out of the pool for the request. func OpenSession(ctx *muzak.Context) (*Session, muzak.Release, error) { s, err := pool.Open(ctx.Context()) if err != nil { return nil, nil, err } return s, func(failure error) error { return s.Close() }, nil } ``` ```go r.Post("/orders", handlers.PlaceOrder, muzak.Acquire(core.OpenSession)) ``` The value is read like any other, with a `Dep[*Session]` field or `From`, and the provider runs where a `Needs` provider would. A `Release` is `func(failure error) error`. `failure` is nil when the request succeeded and otherwise says why it did not, which is what makes "keep the work only if everything succeeded" a single `if`. ### The contract - **Exactly once.** A release runs once for every provider call that returned a nil error, and never for one that returned an error. A provider that fails part way through undoes what it had acquired itself; a `Release` returned alongside an error is not called. A nil `Release` means there is nothing to release. - **Last acquired, first released.** Releases run in the reverse of the order their values were acquired, across the whole chain: the application's, then the router's, then the route's are acquired in that order and released the other way round. - **On every path.** The table below lists what each release is handed. - **Before the response is written.** For an ordinary route, releases run after the handler has returned and its result has been encoded, and before a byte of the response is written. A release that fails can therefore still turn the success into an error. - **No leaks between requests.** Releases are held on the request's pooled `Context` and cleared with it, so a request can never run a release another request acquired. | How the request ended | `failure` handed to each release | |---|---| | The handler succeeded and the response encoded | `nil` | | The handler returned an error | That error | | The handler, a guard or a provider panicked | An error describing the panic; the panic is still the usual 500, logged once | | A later provider, or a later `Acquire`, refused | Its error | | The rate limit counted after the dependencies refused | The 429 | | Binding or validation failed | The `*muzak.ValidationError` (a 422) | | The response could not be encoded | The encoding error (a 500) | | The client went away and the handler returned the context's error | That error, such as `context.Canceled` | | An event stream or a WebSocket handler returned | Whatever it returned | | An earlier release (acquired later) failed | That release's error | A guard runs before every provider, so a guard that refuses acquires nothing and has nothing to release. An `Acquire` given a nil provider is a build error. Tasks registered with `ctx.AfterResponse` follow the same verdict. They run only when the handler returned nil, and on a route with an `Acquire` provider they are also dropped when the value fails to encode, because the releases were told the request failed and a `Transaction` has been rolled back. See [Background Tasks](/docs/techniques/background-tasks). ### When a release fails The error a `Release` returns matters only when `failure` is nil. - **The response has not started.** This is every buffered response, JSON, HTML or `204 No Content`. The release error replaces the success and is rendered exactly as a handler's error would be: a `muzak.HTTPError` chooses its own status (a release may return `muzak.Conflict(...)` for a serialization failure), and any other error becomes an opaque 500 with the cause logged and never sent. Headers the handler set for the success, such as an `ETag`, are dropped as they are for any error. - **The response has started.** The handler wrote to `ctx.ResponseWriter()`, streamed events, hijacked or upgraded the connection. The status is gone, so the error is treated the way the handler returning it at that point would be: it is logged, a response the handler started is aborted rather than completed, an event stream logs it as the stream's failure, and a WebSocket closes with `1011` rather than `1000`. - **The releases still to run** are handed the failed release's error, so a second transaction is rolled back when the first one's commit failed. When `failure` is not nil the response is already the failure's, so a release error is logged against its dependency type and changes nothing. A `Release` that panics is recovered, logged with its stack, and counted as having returned an error; the remaining releases still run. A `Release` receives no `Context`. It runs on the request's goroutine before the `Context` returns to the pool, so it may use what the provider captured, but must not keep the `Context`. ### Event streams and WebSockets On an SSE or WebSocket route the releases run as soon as the handler returns, which is the end of the stream or the connection, and are handed what the handler returned. A client that leaves usually makes the handler return an error (`muzak.ErrSSEStreamEnded`, a `*muzak.WSCloseError` or the context's error), so a value acquired for a stream is released as failed unless the handler returns nil. A `Transaction` spanning a stream or a connection therefore commits only when the handler returns nil: return nil to keep the work. ### Mounts and the documentation A file mount, a handler served with `Router.Mount` and the documentation run the providers they inherit, for their verdict, so an `Acquire` declared on the whole application acquires and releases for every request they answer. None of them has a handler error to report: - A file mount's releases are handed nil once the file is served, and the refusal otherwise, including when the file mount answers beneath a handler mount. - A mounted handler's are handed nil once it returns without panicking, whatever status it wrote, and an error describing the panic otherwise. A release that fails after the handler has written aborts the response. - The documentation's are handed nil once the page or the document is served, and the refusal otherwise. Declare `Acquire` on the routers that need it, not on `New`. ### Not for singletons `Acquire` is request-scoped by construction. A value shared by every request has no request to be released at the end of, so there is no singleton that releases: a resource that lives as long as the application is published with `WithSingleton` and closed through [Lifecycle](/docs/getting-started/lifecycle). ## Transactions `muzak.Transaction` is the `Acquire` most applications need: a `*sql.Tx` begun for the request and ended with it. ::code-group ```go [schemas/orders.go] type PlaceOrder struct { Tx muzak.Dep[*sql.Tx] Item string `json:"item"` } ``` ```go [handlers/orders.go] func PlaceOrder(ctx *muzak.Context, in schemas.PlaceOrder) (schemas.OrderOut, error) { tx := in.Tx.Get() if _, err := tx.ExecContext(ctx.Context(), insertOrder, in.Item); err != nil { return schemas.OrderOut{}, err } if _, err := tx.ExecContext(ctx.Context(), decrementStock, in.Item); err != nil { return schemas.OrderOut{}, err } return schemas.OrderOut{Item: in.Item}, nil } ``` ```go [routers/orders.go] r := muzak.NewRouter(muzak.WithTags("orders"), muzak.Transaction(db, nil)) r.Post("/orders", handlers.PlaceOrder, muzak.Status(http.StatusCreated)) ``` :: The transaction is begun with the request's context and the given `*sql.TxOptions`, which may be nil. It is **committed** when the handler, and everything after it, succeeded: the handler returned no error, the response encoded, and no release acquired after it failed. It is **rolled back** otherwise, including when the handler panicked, a later provider or the rate limit refused the request, binding or validation failed, or the client went away. | What happened | Database sees | Client sees | |---|---|---| | Success | `COMMIT` | The handler's response | | Handler error, panic, 422, later refusal | `ROLLBACK` | That error | | A guard refused | Nothing: no transaction was begun | The guard's error | | `BeginTx` failed | Nothing | An opaque 500, cause logged | | `COMMIT` failed | The failed commit | An opaque 500, cause logged, never the driver's text | | The client went away | `ROLLBACK`, by `database/sql` when the context ended | Nothing; it left | | The route's `Timeout` passed, even if the handler then succeeded | `ROLLBACK`, by `database/sql` when the deadline passed | `503` with `Retry-After: 1` | A commit that fails turns the response into a 500, because the client must not be told that work it asked for was done when it was not. A rollback that finds the transaction already ended, which `database/sql` does itself when the request's context is cancelled, is not an error. Even a handler that ignores the cancellation and returns success is not committed: `database/sql` has already rolled the transaction back once the context ended. When it ended because the route's [Timeout](/docs/techniques/timeouts) passed, that success is answered with the 503 the deadline calls for. Declare `Transaction` where the writes are, on the routers or routes that need it. Declared on `New`, it would also begin a transaction for every file a mount serves. A `Transaction` given a nil `*sql.DB` is a build error. ## Singletons A singleton is a value shared by every request rather than resolved per request. There are two ways to publish one. `WithSingleton` publishes a value that already exists, which is the usual case: ```go [cmd/main.go] settings := muzak.MustLoadConfig[core.Settings](muzak.EnvFile(".env")) store := core.NewItemStore() models := core.NewModelRegistry() app := muzak.New(muzak.AppOptions{Title: settings.AppName, Addr: settings.Addr}, muzak.WithSingleton(settings), muzak.WithSingleton(models), muzak.WithSingleton(store, store.Lifecycle()), ) ``` Handlers read it by type, with no cast, through `From` or a `Dep` field: ```go [handlers/items.go] func ListItems(ctx *muzak.Context, _ muzak.Empty) (schemas.ItemListOut, error) { store := muzak.From[*core.ItemStore](ctx) settings := muzak.From[core.Settings](ctx) stored := store.List() items := make([]schemas.ItemOut, 0, len(stored)) for _, item := range stored { items = append(items, schemas.ItemOut{ID: item.ID, Name: item.Name}) } return schemas.ItemListOut{Items: items, Limit: settings.ItemsPerUser}, nil } ``` `Singleton` is the lazy counterpart, for a value expensive enough that building it at start-up is not worth it: ```go muzak.Singleton(func(ctx *muzak.Context) (*template.Template, error) { return template.ParseGlob("templates/*.html") }) ``` The provider runs on the first request that needs the value, receiving that request's `Context`. It must not retain that context, read request-specific state from it, or return a value that is unsafe for concurrent use, because every later request shares the same value. **Only a successful value is cached.** An error, or a panic, fails the request that triggered it and the requests that waited on the same attempt, and the next request runs the provider again. Caching the failure meant a provider that panicked once, or that failed because the first client disconnected and cancelled its request, failed every later request until restart, and one aborted request could trigger it. One attempt is in flight at a time, so a slow failure does not multiply into queued latency: waiting requests share its outcome and stop waiting when their own request ends. While it runs, `ctx.Context()` is a context that keeps the triggering request's values but not its cancellation or its deadline, because the value outlives that request. A provider that relied on the request deadline to bound its I/O should apply its own: ```go muzak.Singleton(func(ctx *muzak.Context) (*Pool, error) { c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) defer cancel() return dial(c) }) ``` Whichever form you use, the value is shared, so it must be safe for concurrent use. ### Singletons and lifecycle If a published value implements `muzak.Lifecycle`, or a `LifecycleFunc` option is supplied alongside it, the value is also registered as a lifecycle component: started before the server accepts traffic and stopped after the server has drained. That is what `store.Lifecycle()` is doing above, and it is covered in [Lifecycle](/docs/getting-started/lifecycle). ## Overriding dependencies in tests A test swaps a real provider for a fake with `App.Override`, without touching the routes. With the test client it is an option: ```go [handlers/items_test.go] func TestReadItemAsAlice(t *testing.T) { client := testclient.New(t, buildApp(), testclient.Override(func(*muzak.Context) (core.CurrentUser, error) { return core.CurrentUser{Username: "alice"}, nil })) client.Get("/items/foo").AssertJSON(`{"id":"foo","owner":"alice"}`) } ``` Without it, call the method on the application before it is built: ```go app := buildApp() app.Override(func(*muzak.Context) (core.CurrentUser, error) { return core.CurrentUser{Username: "alice"}, nil }) ``` | Rule | Why | |---|---| | Every provider of exactly type `T` is replaced, on every route, file mount, handler given to `Router.Mount` and the documentation | One line covers the whole application, however many routers declare the provider | | `From`, `TryFrom` and `Dep` all receive the override's value | The handler cannot tell, which is the point | | Guards are left alone | A test still passes or fails them the way a client would | | The value is replaced, the lifetime kept | A request-scoped provider's override runs per request; a singleton's runs once, lazily; `Cache-Control` does not change | | An override replaces but never adds a provider | A route that forgot to declare one fails to build under test exactly as it does in production | | An override for a type nothing provides is a build error | Catches overriding `CurrentUser` where the application provides `*CurrentUser` | | Overrides belong to one `App` | Two applications in one test binary never see each other's, even when they share a provider option | | The last override of a type wins | A helper can set a default and a test refine it | | `Override` after the application is built panics | It could no longer apply, and it would race with the requests reading the chains | | An override given a nil function is a build error | It could only ever fail the requests it reached | The overridden provider never runs, and neither does its `Release` if it was an `Acquire`. A fake that needs a release of its own uses `App.OverrideAcquire`, or `testclient.OverrideAcquire`, with the same contract as `Acquire`. It cannot replace a singleton, which is a build error: a fake released after every request would be tested under a lifetime production never has. An override may return an error too, which is how a test exercises the refusal path: ```go testclient.Override(func(*muzak.Context) (core.CurrentUser, error) { return core.CurrentUser{}, muzak.Forbidden("suspended") }) ``` ## The order things run in For every request: 1. The middleware chain: request identifier, tracing and the request observer when they are configured, security headers, panic recovery, the health probes (answered there and going no further), locale and the access log, then the allowed-hosts check and the HTTPS redirect, the CORS policy and cross-origin protection when each is configured, then anything installed with `App.Use`. See [Middleware](/docs/getting-started/middleware). 2. The rate limit count, unless the policy asked for it after the dependencies. 3. The route's [Timeout](/docs/techniques/timeouts) starts, when it has one. 4. The verifying security schemes the route's `WithSecurity` names, such as `JWTBearer`. 5. The body a `CaptureBody` route keeps is read. 6. Guards, outermost first. 7. Providers, `Acquire` and `Transaction` included, in declaration order. 8. The rate limit count, if the policy asked for it after the dependencies. 9. `Dep` fields are filled. 10. Binding, which fills the rest of the input type from the request. 11. Validation, which runs the input model's `Validate` method. 12. The handler. 13. The response is encoded. 14. Releases, last acquired first, then the session cookie when there is one to write. 15. The response is written. 16. Tasks registered with `ctx.AfterResponse` are handed to the background workers. Guards running before validation is deliberate: an unauthenticated caller gets `401`, not a map of your schema. Three consequences are worth knowing: - **Every guard runs before any provider**, whatever scope each was declared on. A guard cannot read a value a provider produced, including a value published with `WithSingleton`, because none of them has resolved yet. Give the guard what it needs by closing over it, which is what `core.GetTokenHeader(settings)` does above, or move the check into a provider of its own. - **Binding happens after the dependencies**, so a guard and a provider see the raw request through the context rather than the typed input. - **Releases run before the response is written**, so what a release does (a commit, say) has happened by the time the client reads the answer, and a release that fails changes it. ## Errors from a dependency A guard or a provider that returns an error abandons the request, and the error becomes the response exactly as one returned from a handler would. Returning `muzak.Unauthorized("")` is the idiomatic way to reject, and there is a constructor like it for every status worth naming; any other error type becomes an opaque 500 with the real cause logged. The same holds for a `Release` that fails after a success. See [Error Handling](/docs/getting-started/error-handling). ## A worked example Here is the caller of a WebSocket route resolved before the handshake completes, which is what lets an unauthenticated peer receive a readable JSON error rather than a socket that closes a moment after it opened. ::code-group ```go [core/dependencies.go] // Caller is who is on the other end of a WebSocket route. type Caller struct { Username string FromToken bool } // GetCaller resolves the caller from the session a sign-in stored, or from a // token query parameter. // // A browser cannot set headers on a WebSocket handshake, so the two places a // credential can arrive are the session cookie the browser attaches itself and // a query parameter the page puts in the URL. func GetCaller(ctx *muzak.Context) (Caller, error) { if user, ok := muzak.SessionGet[string](ctx.Session(), "user"); ok { return Caller{Username: user}, nil } if token := ctx.Query("token"); token != "" { user, err := tokens.Verify(ctx.Context(), token) if err != nil { return Caller{}, muzak.Unauthorized("the token is not valid") } return Caller{Username: user, FromToken: true}, nil } return Caller{}, muzak.Unauthorized("sign in, or pass a token query parameter") } ``` ```go [schemas/items.go] type WSItemIn struct { ItemID string `path:"item_id"` Caller muzak.Dep[core.Caller] } ``` ```go [routers/items.go] r.WS("/items/{item_id}/ws", handlers.ItemSocket, muzak.Summary("Talk to an item over a WebSocket"), muzak.Needs(core.GetCaller)) ``` ```go [handlers/items.go] func ItemSocket(ctx *muzak.Context, in schemas.WSItemIn, conn *muzak.WSConn) error { caller := in.Caller.Get() // ... } ``` :: `ctx.Session()` needs `AppOptions.Sessions`, and reads the session the first time it is asked, here during the handshake. Reading is all a WebSocket route should do with it: the connection's response has started by the time the request ends, so a change is not saved then, and a warning is logged instead. Because the session cookie travels with any request the browser makes, the handshake's `Origin` is what keeps another site's page from opening the socket as the visitor; `WSOptions.AllowedOrigins` admits the server's own origin and the ones it lists. See [Sessions](/docs/security/sessions) and [WebSockets](/docs/realtime/websockets). ## Where to go next [Lifecycle](/docs/getting-started/lifecycle) covers resources that must be opened before traffic and closed after it, [Error Handling](/docs/getting-started/error-handling) covers the errors a dependency returns, [Timeouts](/docs/techniques/timeouts) covers the deadline the dependencies and the handler share, and [Testing](/docs/fundamentals/testing) covers the test client the overrides above plug into. -------------------------------------------------------------------------------- title: "Error Handling" description: "One error envelope for every failure, errors that carry their own status, errors that stay opaque, and how to replace the shape entirely." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/error-handling" -------------------------------------------------------------------------------- # Error Handling Every failure renders as one envelope, carrying a machine-readable code, a message safe to disclose, the status, per-field details and the request identifier that ties the response to the server's log. ```json { "error": { "code": "validation_error", "message": "The request could not be validated.", "status": 422, "details": [ { "field": "name", "location": "body", "issue": "is required" }, { "field": "limit", "location": "query", "issue": "must be a valid integer" } ] }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` The Go types are `muzak.ErrorResponse`, `muzak.ErrorBody` and `muzak.ErrorDetail`, so a client written in Go can decode the envelope with the same definitions the server writes it from. The [HTTP client](/docs/techniques/http-client)'s JSON helpers and [typed endpoint](/docs/techniques/typed-endpoints) calls do that for you, reporting a failure as a `*muzak.RemoteError` whose `Code`, `Message`, `Details` and `RequestID` come from the envelope. ## Two kinds of error Muzak divides every error a handler or a dependency returns into two groups. **An error that describes itself** reaches the client as written. That means an `*HTTPError`, a `*ValidationError`, or a `StatusCoder` below `500`, whose own `Error()` text is what is sent (see [Your own error types](#your-own-error-types)). **Everything else** becomes an opaque `500` whose message and code are fixed constants, with the real cause written to the log and never transmitted. A nil dereference, a database driver error or a wrapped file path cannot leak internal state through a response. ```json { "error": { "code": "internal_error", "message": "The server could not complete the request.", "status": 500 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` Both the code and the message are fixed constants. Nothing derived from the underlying error appears anywhere in the response. ## Returning an HTTP error `NewHTTPError` takes the status and the message the client will read. ```go return schemas.ItemOut{}, muzak.NewHTTPError(http.StatusNotFound, "Item not found") ``` ```json { "error": { "code": "not_found", "message": "Item not found", "status": 404 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` The status decides the classifier, `404` becoming `not_found`, and the message is transmitted verbatim. `NewHTTPErrorf` builds that message with a format string: ```go return schemas.ItemOut{}, muzak.NewHTTPErrorf(http.StatusBadRequest, "%q is not a supported currency", in.Currency) ``` ```json { "error": { "code": "bad_request", "message": "\"XBT\" is not a supported currency", "status": 400 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` Because the formatted result is sent to the client, keep internal state out of it. Use `Wrap` for the parts that should stay server-side. `*HTTPError` composes: ```go return Out{}, muzak.PaymentRequired("the card was declined"). WithCode("card_declined"). WithDetails(muzak.ErrorDetail{ Field: "payment_method", Location: "body", Issue: "was declined by the issuer", }). Wrap(err) ``` ```json { "error": { "code": "card_declined", "message": "the card was declined", "status": 402, "details": [ { "field": "payment_method", "location": "body", "issue": "was declined by the issuer" } ] }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` What `Wrap` holds appears nowhere in that response. It is written to the log, next to the same request identifier the client was given. | Method | What it does | |---|---| | `WithCode(code)` | Overrides the classifier, for a status too coarse on its own | | `WithDetails(...)` | Attaches per-field details, which appear in `details` | | `Wrap(err)` | Records a cause that is logged and visible to `errors.Is` and `errors.As`, and never sent | | `WithMessageKey(key, args...)` | Names a translation for the message, keeping the one already written as the fallback | `WithDetails`, `WithCode`, `WithMessageKey` and `Wrap` each return a modified **copy** and leave the receiver untouched. They used to edit the error in place, so a package-level error decorated per request handed one request's details to the next response, grew them with every request and raced between concurrent ones. Use the returned value, which every use above already does. A call whose result is discarded, such as `err.WithCode("x")` on a line of its own, no longer has any effect. Returning one from a handler, a guard or a provider short-circuits the request: nothing further down the chain runs, and the status, code and message become the response. ## Errors in another language Every sentence above is translated when the application has a locale configured, and none of the constructors change. `muzak.Forbidden("")` takes the standard sentence for its status, and that sentence is what gets looked up. ```yaml [core/locales/es.yml] es: muzak: http: 403: "No tienes acceso a este recurso." ``` A message you write yourself is your own words, and is never replaced by a translation of something else. Name a translation for it explicitly when you want one: ```go return muzak.Forbidden("only editors may do that"). WithMessageKey("errors.access.denied") ``` The code is never translated. Clients branch on it, so a locale file that reworded one would break every client reading it. See [Internationalization](/docs/fundamentals/internationalization). ## Errors by name There is a constructor for each outcome worth a name of its own, so returning the right response is not a matter of remembering the right number: ```go return schemas.UserOut{}, muzak.NotFound("no user goes by that name") ``` ```json { "error": { "code": "not_found", "message": "no user goes by that name", "status": 404 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` | Constructor | Status | Code | |---|---|---| | `BadRequest(message)` | `400` | `bad_request` | | `Unauthorized(message)` | `401` | `unauthorized` | | `PaymentRequired(message)` | `402` | `payment_required` | | `Forbidden(message)` | `403` | `forbidden` | | `NotFound(message)` | `404` | `not_found` | | `MethodNotAllowed(message)` | `405` | `method_not_allowed` | | `NotAcceptable(message)` | `406` | `not_acceptable` | | `RequestTimeout(message)` | `408` | `request_timeout` | | `Conflict(message)` | `409` | `conflict` | | `Gone(message)` | `410` | `gone` | | `PreconditionFailed(message)` | `412` | `precondition_failed` | | `PayloadTooLarge(message)` | `413` | `payload_too_large` | | `UnsupportedMediaType(message)` | `415` | `unsupported_media_type` | | `UnprocessableEntity(message)` | `422` | `validation_error` | | `TooManyRequests(message)` | `429` | `too_many_requests` | | `InternalServerError(message)` | `500` | `internal_error` | | `NotImplemented(message)` | `501` | `not_implemented` | | `BadGateway(message)` | `502` | `bad_gateway` | | `ServiceUnavailable(message)` | `503` | `service_unavailable` | | `GatewayTimeout(message)` | `504` | `gateway_timeout` | Every one of them returns an `*HTTPError`, so `Wrap`, `WithCode` and `WithDetails` chain onto any of them, and a status without a name of its own is still `NewHTTPError`'s to produce. ### The standard sentence Passing an empty message uses the standard wording for that status, which is what a framework with one exception per status gives you when you raise it with no argument: ```go return schemas.UserOut{}, muzak.Forbidden("") ``` ```json { "error": { "code": "forbidden", "message": "You do not have access to this resource.", "status": 403 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` ### Server errors say what happened, not why Returning a `5xx` by name is a deliberate act, so unlike an unexpected fault its message reaches the client. The cause belongs in `Wrap`, which does not: ```go return schemas.PriceOut{}, muzak.BadGateway("the pricing service is not answering"). Wrap(err) // dial tcp 10.0.0.7:5432: connect: connection refused ``` ```json { "error": { "code": "bad_gateway", "message": "the pricing service is not answering", "status": 502 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` The address, the port and the driver's wording stay in the log. ## Translating domain errors Keep HTTP out of your store or service layer, and translate at the edge. ::code-group ```go [core/store.go] var ( // ErrItemNotFound reports a lookup for an item that does not exist. ErrItemNotFound = errors.New("item not found") // ErrItemExists reports a create that collides with an existing item. ErrItemExists = errors.New("item already exists") ) ``` ```go [handlers/items.go] // asHTTPError translates a store error into the response it deserves. // // The translation lives here rather than in the store, which keeps the store // free of any knowledge about HTTP, and it is exhaustive rather than a // fall-through: an error the service does not recognise becomes an opaque 500 // with the real cause logged and never sent. func asHTTPError(err error) error { switch { case errors.Is(err, core.ErrItemNotFound): // Wrap keeps the store's error in the log without sending it. return muzak.NotFound("Item not found").Wrap(err) case errors.Is(err, core.ErrItemExists): return muzak.Conflict("Item already exists").Wrap(err) default: return err } } ``` :: ```go func ReadItem(ctx *muzak.Context, in schemas.ItemParams) (schemas.ItemOut, error) { store := muzak.From[*core.ItemStore](ctx) item, err := store.Get(in.ID) if err != nil { return schemas.ItemOut{}, asHTTPError(err) } return schemas.ItemOut{ID: item.ID, Name: item.Name}, nil } ``` ## Your own error types Implement `StatusCoder` and your type becomes a first-class citizen of the error pipeline without depending on `*HTTPError`. ```go type QuotaExceeded struct { Plan string } func (e *QuotaExceeded) Error() string { return "quota exceeded for plan " + e.Plan } func (e *QuotaExceeded) HTTPStatus() int { return http.StatusPaymentRequired } ``` ```json { "error": { "code": "payment_required", "message": "quota exceeded for plan free", "status": 402 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` Three things follow from that. The code is derived from the status with `CodeForStatus`, so reach for `*HTTPError` and `WithCode` when you want a classifier of your own. Below `500`, the message is the `Error()` text of the `StatusCoder` value itself, so a type that implements it has declared that text safe to disclose. Keep file paths, driver messages and query fragments out of it. It is the value `errors.As` finds in the chain, never the text of an error wrapping it: context added on the way up with `fmt.Errorf("...: %w", err)` (a query, a connection string, a token) does not reach the response. From `500` up, the client gets the same fixed sentence as for any other error, and the error is logged. A `5xx` whose message is meant for the client is an `*HTTPError`, as [Server errors say what happened, not why](#server-errors-say-what-happened-not-why) shows. ## Error codes The `code` member is what clients should branch on, rather than on the status or the message text. | Constant | Value | |---|---| | `CodeBadRequest` | `bad_request` | | `CodeUnauthorized` | `unauthorized` | | `CodePaymentRequired` | `payment_required` | | `CodeForbidden` | `forbidden` | | `CodeNotFound` | `not_found` | | `CodeMethodNotAllowed` | `method_not_allowed` | | `CodeNotAcceptable` | `not_acceptable` | | `CodeRequestTimeout` | `request_timeout` | | `CodeConflict` | `conflict` | | `CodeGone` | `gone` | | `CodePreconditionFailed` | `precondition_failed` | | `CodePayloadTooLarge` | `payload_too_large` | | `CodeUnsupportedMediaType` | `unsupported_media_type` | | `CodeValidationError` | `validation_error` | | `CodeTooManyRequests` | `too_many_requests` | | `CodeNotImplemented` | `not_implemented` | | `CodeBadGateway` | `bad_gateway` | | `CodeServiceUnavailable` | `service_unavailable` | | `CodeGatewayTimeout` | `gateway_timeout` | | `CodeInternalError` | `internal_error` | | `CodeClientError` | `client_error` | `muzak.CodeForStatus(status)` returns the default code for a status: a recognised status gets its specific classifier, any other 4xx becomes `client_error`, and everything else becomes `internal_error`. ## Validation failures A `*ValidationError` renders as `422` classified `validation_error`, with one entry in `details` per offending field, so a client learns about every mistake at once instead of one per round trip. Binding failures and rule failures merge into the same envelope, each carrying the location the binder established. ```json { "error": { "code": "validation_error", "message": "The request could not be validated.", "status": 422, "details": [ { "field": "username", "location": "body", "issue": "is reserved" }, { "field": "email", "location": "body", "issue": "must be a valid email address" }, { "field": "password", "location": "body", "issue": "must be at least 12 characters" }, { "field": "limit", "location": "query", "issue": "must be between 1 and 100" } ] }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` `location` is one of `path`, `query`, `header`, `cookie` or `body`. See [Validation](/docs/fundamentals/validation) for the rules that produce these. A `422` never quotes the client's own value, or a parser's own error, back: an entry of a repeated parameter is named by its position (`entry 2 must be a valid integer`), and a value a `TextUnmarshaler` refuses reads `is not in the expected format` unless it returns an `*HTTPError` with wording of its own. A report holds at most 100 failures and ends with a detail of kind `too_many_problems` when there were more, and a model nested past 32 levels is refused with a detail of kind `too_deep`. A body member is named by its full path, `addr.zip` or `items[1].zip`, the way a validation failure names it. An integer outside its type's range reads `must be between -128 and 127` rather than `must be a valid integer`, in a parameter and in a body alike, and a query, header or form parameter that holds one value but was sent more than once reads `must be given only once`. A body type the decoder cannot use at all is the application's fault rather than the client's, so it is a `500` with the reason logged, not a `422`. ## Failures the framework reports on its own | Status | When | |---|---| | `401` | A route that enforces a security scheme built by `JWTBearer` or `APIKeyVerifier` received no valid credential. A bearer token scheme sends a `WWW-Authenticate` challenge. See [Authentication](/docs/security/authentication) | | `403` | The credential was valid but lacks a scope the route requires, with an `insufficient_scope` challenge for a bearer token; or `AppOptions.CrossOriginProtection` refused a state-changing request a browser sent from another origin, with the code `cross_origin_request` (`muzak.CodeCrossOriginRequest`) | | `404` | No route matched the path, and no frontend claimed it | | `405` | The path exists but not for that method. The response carries `Allow`. The documentation and the health endpoints answer a method other than GET or HEAD the same way, with `Allow: GET, HEAD` | | `413` | The request body exceeded the route's limit: refused from its declared `Content-Length` before any of it is read, or while it was being read when no length was declared | | `415` | A body arrived under a media type the route cannot decode, or with no `Content-Type` at all | | `421` | `AppOptions.AllowedHosts` does not list the request's host, with the code `misdirected_request` (`muzak.CodeMisdirectedRequest`) | | `422` | Binding or validation failed | | `429` | A rate limit was exceeded. The response carries `Retry-After` and the `RateLimit` headers | | `503` | The connection or stream budget is full, a rate limit storage could not answer, a security scheme could not judge a credential yet (a JWT key set not fetched, an API key `Lookup` that failed), or the route's `Timeout` passed before the response started, which also sends `Retry-After: 1` (see [Timeouts](/docs/techniques/timeouts)) | | `500` | A panic, or any error that does not describe itself. An application that failed to build answers every request with it, and logs the reason once | Each of these is rendered by the error renderer, as an error a handler returned is. Two `500`s cannot reach a renderer of your own: the one an application that failed to build answers, and the one a panic in middleware outside the router produces. Both are written in the envelope above, or as a problem when `AppOptions.ProblemDetails` is set. ## Panics A panic escaping a handler or a dependency is caught, logged with its stack trace, the request identifier, the method and the route, and answered with the generic `500` above. Nothing derived from the panic value reaches the client: a panic often carries a pointer address, a SQL fragment or a file path, and a stack trace maps out the server's internals. `http.ErrAbortHandler` is re-panicked rather than swallowed, because `net/http` uses it to abort a response deliberately. The panic value is rendered with `fmt` and cut to 4 KiB in the log, with a `...(truncated)` marker, so a panic message built from a request's input cannot make a log line as large as the input. The `panic` attribute is a string in the JSON format for every kind of value. A panic in a middleware is logged as a `500` in the access log too. ## A failure after the response started Once the first byte of a response has been written, the status line is gone and there is no error response left to send. A panic, or an error returned after that point, is logged once and the connection is **aborted**: Muzak panics with `http.ErrAbortHandler`, which `net/http` turns into a failed transfer without logging it again. Before, the response ended cleanly and a truncated export looked complete. The access log marks such a request `aborted=true`. A `Flush` counts as the start of the response even when it came before the first `Write`, because it sends an implicit `200`. A handler or middleware that flushes early and then fails therefore aborts the connection, and `SetStatus` after the flush is refused and logged. Hijacked connections, WebSocket routes and event stream routes are unchanged. Code that calls `App.ServeHTTP` or `Recovery` directly, for instance in a test, should expect `http.ErrAbortHandler` in this case. ## Cleaning up after a failure A provider declared with `muzak.Acquire` returns a `Release` beside its value, and the release runs exactly once on every way the request ends (success, a returned error, a panic, a later refusal, the client going away) and is told whether the request failed. `muzak.Transaction` is built on it: the request's `*sql.Tx` is committed only when the handler and everything after it succeeded, and rolled back otherwise. For a buffered response the releases run after the result is encoded and before anything is written, so a release that fails, such as a commit, replaces the success with its error, rendered like a handler's. Once the response has started, a release error is treated as a failure after the response started, above. See [Dependencies](/docs/getting-started/dependencies). ## What an error response carries An error response replaces whatever the handler was building, so it drops the headers set for the response it replaces: `Content-Type`, `Content-Length`, `Content-Disposition`, `Content-Encoding`, `Content-Range`, `ETag`, `Last-Modified`, `Cache-Control` and `Expires`, and the ones that describe a body that was never sent (`Content-Digest`, `Repr-Digest`, `Digest`, `Content-MD5`, `Accept-Ranges`, `Trailer` and `Content-Location`). `Location` goes too, and is kept only when the error itself answers with a `3xx`. `Content-Language` is reset to the request's locale. `Vary`, `Retry-After`, `Allow`, `WWW-Authenticate`, `Set-Cookie` and the security headers are kept. The response is sent with `Cache-Control: no-store`, because an error is never worth storing. A handler that set `Content-Type: text/html` or a public `Cache-Control` and then failed used to send its JSON error, reflected input included, under those headers. A custom `ErrorRenderer` runs after this and may set any header it wants, including a `Cache-Control` of its own, with one exception: a response that carries the [session](/docs/security/sessions) cookie, which `Session.Save` can put on an error, is kept `private` whatever the renderer sets. ## Replacing the envelope For RFC 9457 problem details you need no renderer of your own: `AppOptions.ProblemDetails` renders every error as a `muzak.Problem`, sent as `application/problem+json`, with the same codes, details, translations and opacity as the default envelope, and describes errors that way in the OpenAPI document. See [Problem Details](/docs/techniques/problem-details). For any other shape, `AppOptions.ErrorRenderer` replaces it entirely. The renderer below builds a problem by hand, to show what one is given and what it returns. ```go func renderError(ctx *muzak.Context, err error) (int, any) { var verr *muzak.ValidationError if errors.As(err, &verr) { return http.StatusUnprocessableEntity, problemDetails{ Type: "https://example.com/probs/validation", Title: "Your request is not valid.", Status: http.StatusUnprocessableEntity, Instance: ctx.RequestID(), Errors: verr.Details, } } var coder muzak.StatusCoder if errors.As(err, &coder) { status := coder.HTTPStatus() return status, problemDetails{ Type: "about:blank", Title: err.Error(), Status: status, Instance: ctx.RequestID(), } } // Anything unrecognised stays opaque, exactly as the default renderer does. return http.StatusInternalServerError, problemDetails{ Type: "about:blank", Title: "An internal error occurred.", Status: http.StatusInternalServerError, Instance: ctx.RequestID(), } } app := muzak.New(muzak.AppOptions{ Title: "Awesome API", ErrorRenderer: renderError, }) ``` A renderer receives the request `Context`, so the route, the request identifier and the headers are all available. Returning a nil body writes the status with no content. A status outside 100 to 599 is written as 500, with or without a body. A renderer must not leak internal state. The error it receives may be anything a handler returned, including a wrapped database or filesystem error, so classify the errors you recognise and fall back to an opaque response for the rest, exactly as `muzak.DefaultErrorRenderer` does. That function is exported, so a renderer that only wants to special-case one thing can delegate the rest to it. ## Correlating a response with the log Every response carries `X-Request-Id`, and every error body repeats it as `request_id`. The same value appears in the access log line and in every log record the request produced. ``` 14:32:10.114 WARN [Request] POST /items/ status=409 duration=412µs bytes=181 request_id=0611f4b2 ``` An error carrying a cause through `Wrap`, and every error that does not describe itself, also produces a record of its own at error level with the real reason in it. The reason stays in the log; only the envelope above reaches the client. A deliberate 4xx that carries no cause is fully described by its response, so it gets no extra line. Identifiers are generated per request and are not taken from the client by default, because an attacker-controlled identifier is an attacker-controlled log field. See [Logging](/docs/fundamentals/logging) for how to change that. ## Where to go next [Middleware](/docs/getting-started/middleware) covers the chain an error travels back out through, and [Validation](/docs/fundamentals/validation) covers the rules behind a `422`. -------------------------------------------------------------------------------- title: "Middleware" description: "The chain that is already installed, the two you can add, where tracing and request metrics hook in, how to write your own, and the one thing that fails quietly in Go." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/middleware" -------------------------------------------------------------------------------- # Middleware Middleware in Muzak is an ordinary `func(http.Handler) http.Handler`, so anything written for the standard library works unchanged. ```go type Middleware func(next http.Handler) http.Handler ``` It operates below the typed layer, on the raw `net/http` types. Everything typed happens inside `dispatch`, at the end of the chain. ## What is already installed The middleware below is installed for you, in this order, outermost first. `RequestID`, `SecurityHeaders`, `Recovery` and `AccessLog` are in every application unless you turn them off; the rest are installed only when the option that asks for them is set, and cost nothing otherwise. | Middleware | What it does | Installed | |---|---|---| | `RequestID` | Assigns an identifier, records it in the request context, echoes it in `X-Request-Id` | Always | | Tracing and the request observer | Starts the request's server span and tells the `RequestObserver` about the request once its response is written. See [Observability](/docs/fundamentals/observability) | When `AppOptions.Tracing` names a `Tracer` or `AppOptions.Observer` is set | | `SecurityHeaders` | Sets `X-Content-Type-Options`, `X-Frame-Options` and a referrer policy, and `Strict-Transport-Security` on a response over TLS (not for localhost or an address), never overwriting a value already set. No `Content-Security-Policy`: a page sets its own | Unless `DisableSecurityHeaders` is set | | `Recovery` | Catches a panic, logs it with its stack, answers a generic 500 | Always | | Health probes | Answers the liveness and readiness endpoints and passes every other request on. See [Health Checks](/docs/deployment/health-checks) | When `AppOptions.Health.Enabled` is set | | `Locale` | Resolves the request's locale, records it in the request context, echoes it in `Content-Language` | When `I18n` names a store | | `AccessLog` | One line per request with method, path, status, duration, bytes and identifier | Unless `DisableAccessLog` is set | | Allowed hosts and the HTTPS redirect | Refuses a request for a host `AllowedHosts` does not list with `421`, and redirects plain HTTP to https. See [Allowed Hosts and HTTPS](/docs/security/hosts-and-https) | When `AllowedHosts` or `RedirectHTTPS` is set | Anything you add with `App.Use` runs inside that chain, so it already has an identifier available and is already covered by panic recovery. It also runs **inside the CORS policy** when one is configured, and inside cross-origin protection when `AppOptions.CrossOriginProtection` is set, which sit between the built-in layers and your middleware, in front of the documentation routes and the router. ``` RequestID -> tracing -> SecurityHeaders -> Recovery -> health probes -> Locale -> AccessLog -> AllowedHosts and RedirectHTTPS -> CORS -> CrossOriginProtection -> your middleware -> /docs and /openapi.json -> routes ``` The health endpoints are the exception to "every request reaches your middleware". They are answered just inside `Recovery`, before the locale, the access log, CORS and anything installed with `App.Use`, so a middleware that requires credentials cannot take every replica out of rotation. A probe writes an access-log line of its own instead. That order is what makes an authenticating middleware work for a browser. A browser never sends credentials on a preflight, so a `Use` middleware that required them refused every one, and its own `401` or `429` carried no `Access-Control-Allow-Origin`, which the browser reports as a network error rather than the status. Now a preflight is answered by the policy and never reaches your middleware, and a response your middleware writes itself carries the policy's headers for an allowed origin and none for a denied one. A `Use` middleware that relied on seeing an `OPTIONS` preflight, to log it or to answer CORS itself, no longer does when `AppOptions.CORS` names origins. Without a CORS policy nothing changes. `Locale` sits where it does for two reasons, and it is only there at all when [internationalization](/docs/fundamentals/internationalization) is configured. It is inside `Recovery` because a custom extractor is your code and can panic. It is outside `AccessLog` because middleware further out holds the request as it arrived, so the log can only record a locale that was resolved before it ran. `Use` installs in order, so the first one installed is the outermost. The chain is assembled once, so a call made after the application has been built **panics** rather than leave a middleware, possibly one that denies access, silently uninstalled. Call it before `Build`, `ServeHTTP`, `Document` or a run method. ## Compression ```go app.Use(muzak.Compress(muzak.CompressionOptions{})) ``` One line. It negotiates gzip or deflate from `Accept-Encoding` (`deflate` is the zlib format RFC 9110 defines, not a bare DEFLATE stream), records `Vary` on every response either way, and declines what compressing would not help. See [Compression](/docs/techniques/compression) for the options and the security note that goes with it. ## CORS CORS is configured rather than installed, and the zero value denies everything. ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", CORS: muzak.CORSOptions{ AllowedOrigins: []string{"https://app.example.com"}, AllowCredentials: true, MaxAge: 10 * time.Minute, }, }) ``` No CORS header is emitted until a policy names an origin or supplies an `AllowOriginFunc`, and a wildcard origin combined with credentials is refused as a build error rather than served. See [CORS](/docs/security/cors). ## Instrumenting a service Middleware is the seam for tracing and metrics, and it runs below the typed layer, before routing. So the one thing an instrument needs -- which route matched -- is not something it can see for itself. `RouteFromContext` publishes it. ```go func Tracing(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) route, ok := muzak.RouteFromContext(r.Context()) if !ok { route = "unmatched" // a 404 is a 404, not traffic to a route named "" } status := 0 if recorder, ok := w.(muzak.StatusRecorder); ok { status = recorder.Status() } record(route, status, time.Since(start)) }) } ``` **Use the template, never the path.** `r.URL.Path` is the concrete request: ``` /orgs/01a06391-0a5b-7695-af40-babc7981a63e/apps/01a063ad-d99f-7a05-98b6-d12f11b24ea3/keys ``` As a span name or a metric label that is one time series per organization per app. Unbounded cardinality is how a tracing backend falls over, and it makes the only question worth asking -- how slow is this endpoint -- unanswerable, because every request is its own endpoint. The template is bounded by the number of routes you wrote: ``` /orgs/{org_id}/apps/{app_id}/keys ``` Two rules about when to read it. **After `next.ServeHTTP` returns**, because before that routing has not happened. And a request that matched nothing reports `false` rather than an empty string, so a miss is counted as a miss. `StatusRecorder` is the response writer the access log installs. The assertion can fail -- `DisableAccessLog` means there is no wrapper -- so middleware that needs the status either way should fall back to wrapping the writer itself. For tracing and request metrics you rarely need to write one. `AppOptions.Tracing` gives every request a server span named after its route template and continues the W3C trace context a request carries, and `AppOptions.Observer` takes a `RequestObserver`, which is called once for every request after its response with the method, the route template, the status, the duration and the sizes: the hook a Prometheus histogram or an OpenTelemetry meter is built on. Neither adds a dependency. A `Tracer` is an interface, which the framework's `otlp` package implements, and an observer is a type with one method. See [Observability](/docs/fundamentals/observability). A middleware like the one above is for whatever those two do not record. ## The access log ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", AccessLogOptions: muzak.AccessLogOptions{ Level: slog.LevelInfo, SkipPaths: []string{"/healthz"}, }, }) ``` ``` 14:32:10.114 INFO [Request] GET /items/ status=200 route=/items/ duration=412µs bytes=181 request_id=0611f4b2 ``` Successful responses are logged at `Level`. Server errors are always logged at error level and client errors at warn level, so a quiet production level still surfaces failures. `SkipPaths` lists exact paths that produce no line, which keeps a health check polled every second from drowning out real traffic. The liveness and readiness endpoints of `AppOptions.Health` need no entry here. They never reach middleware installed with `App.Use`, and each probe writes its own line at `HealthOptions.AccessLogLevel`, debug by default, so a production level already leaves them out; listing their paths in `SkipPaths` silences them entirely. See [Health Checks](/docs/deployment/health-checks). A request that failed after its response had started, and so aborted the connection, is marked `aborted=true`. A panic in a middleware is recorded as a `500`. A path or a method is quoted at most 1 KiB long in the log and in the `404` and `405` messages, so a request of invalid bytes cannot write several times its size to the client and the log store. `route` is the template the request matched, beside the concrete path in the message. The path is what somebody reading a log wants; the template is what a query groups by, and what joins a log line to the trace and the metric for the same endpoint. Only fixed, non-sensitive fields are recorded. Query strings, request bodies and headers are deliberately omitted, because each of them routinely carries credentials or personal data that should not be duplicated into a log store. ## Request identifiers ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", TrustRequestIDHeader: true, }) ``` By default an inbound `X-Request-Id` is ignored and a fresh UUID version 7 is generated, because an attacker-controlled identifier is an attacker-controlled log field. With the option on, an inbound value is honoured only if it parses as a UUID, so it can never carry newlines or control characters into a log line. Inside a handler the identifier is `ctx.RequestID()`. In code that has only a `context.Context`, such as a repository or a client wrapper, it is `muzak.RequestIDFromContext(ctx)`. ## Writing your own ::code-group ```go [core/middleware.go] // ProcessTime reports how long the service spent on a request in an // X-Process-Time header, in seconds. func ProcessTime() muzak.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(&processTimer{ResponseWriter: w, start: time.Now()}, r) }) } } // processTimer stamps the elapsed time onto the response as it starts. type processTimer struct { http.ResponseWriter start time.Time stamped bool } // WriteHeader records the duration and forwards the status. func (w *processTimer) WriteHeader(status int) { if !w.stamped { w.stamped = true elapsed := time.Since(w.start).Seconds() w.Header().Set("X-Process-Time", strconv.FormatFloat(elapsed, 'f', 6, 64)) } w.ResponseWriter.WriteHeader(status) } // Write stamps a response whose handler never set a status, which net/http // treats as a 200. func (w *processTimer) Write(b []byte) (int, error) { if !w.stamped { w.WriteHeader(http.StatusOK) } return w.ResponseWriter.Write(b) } // Flush passes a flush through to whatever is underneath, so that installing // this middleware cannot stop a handler from streaming. func (w *processTimer) Flush() { if flusher, ok := w.ResponseWriter.(http.Flusher); ok { flusher.Flush() } } // Unwrap exposes the underlying writer to http.ResponseController. func (w *processTimer) Unwrap() http.ResponseWriter { return w.ResponseWriter } ``` ```go [cmd/main.go] app.Use(core.ProcessTime()) app.Use(muzak.Compress(muzak.CompressionOptions{})) ``` :: ### The one thing that fails quietly The obvious shape for that middleware does not work, and it fails without a word: ```go start := time.Now() next.ServeHTTP(w, r) w.Header().Set("X-Process-Time", elapsed(start)) // too late ``` That reads correctly and does nothing. Go puts the header block on the wire at the first `WriteHeader`, which the handler has already called by the time control comes back, so the value is written to a map nobody will read again and no error is reported. Middleware that reports something only known at the end has to wrap the writer and fill the value in as the response starts, which is what `processTimer` above does. Two details make a wrapper well behaved: - Implement `Write` as well as `WriteHeader`, because a handler that writes a body without setting a status never calls `WriteHeader` itself. - Implement `Unwrap() http.ResponseWriter`, so `http.ResponseController` can still reach the real writer. Without it, a wrapper breaks flushing, hijacking and the deadline handling that WebSockets and event streams depend on. A wrapper that forwards `Flush` and has no `Unwrap` hides the connection's deadlines, so an event stream's `WriteTimeout` is silently not enforced and a client that stopped reading holds its stream indefinitely. The stream is still served, but the first one on each route logs a warning naming the writer that stopped the chain. ## Middleware and the typed layer Middleware runs before routing, so it cannot see the matched route, the bound input or the resolved dependencies. Anything that needs those belongs in a guard or a provider, which run per route and can refuse the request with a typed error. See [Dependencies](/docs/getting-started/dependencies). A whole `http.Handler`, such as `net/http/pprof` or a metrics handler, is not middleware either. `Router.Mount` serves one at a prefix, inside this chain and behind the guards, providers and rate limit of its router. See [Mounting Handlers](/docs/techniques/mounting-handlers). ## Where to go next [Lifecycle](/docs/getting-started/lifecycle) covers the resources a request depends on, and [Compression](/docs/techniques/compression) covers the one piece of middleware most applications add. -------------------------------------------------------------------------------- title: "Lifecycle" description: "Open a database pool, a cache client or a loaded model before traffic arrives, and release it after the server has drained." version: "0.3.0" last_updated: "2026-10-10T06:43:18.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/lifecycle" -------------------------------------------------------------------------------- # Lifecycle Anything expensive to create, shared by every request and needing an orderly release fits one shape. ```go type Lifecycle interface { // Name identifies the component in start-up and shutdown logs. Keep it // short and lowercase, such as "redis" or "database". Name() string // Start acquires the resource. It is called once, before the server begins // accepting requests, and must return only when the resource is ready to // use. The context is cancelled when a sibling component fails, so a // long-running dial should honour it and give up. On a successful start // it stays live until the components are stopped, so a component may // keep it for a background worker. Start(ctx context.Context) error // Stop releases the resource. It is called once, after the HTTP server has // finished draining in-flight requests, and is called even for a start-up // that failed part way, for every component that did start. Every // component is stopped at once, in no particular order. Stop(ctx context.Context) error } ``` Publish the value with `WithSingleton` and, if it implements `Lifecycle`, Muzak takes it from there. A value that lives for one request and needs releasing at its end, such as a connection taken from a pool or a request's `*sql.Tx`, is not a component. Declare it with `muzak.Acquire` or `muzak.Transaction`, whose release runs once the request is over, however it ended. See [Dependencies](/docs/getting-started/dependencies). ## A component that implements the interface ::code-group ```go [core/models.go] // ModelRegistry holds the prediction models the service serves. // // It implements muzak.Lifecycle, so publishing it with muzak.WithSingleton is // enough for Muzak to load it before the server accepts traffic and release it // after the server has drained. type ModelRegistry struct { mu sync.RWMutex models map[string]func(float64) float64 } // NewModelRegistry returns an empty registry. The models themselves are loaded // in Start, not here, so that construction stays cheap and failure has a place // to be reported. func NewModelRegistry() *ModelRegistry { return &ModelRegistry{models: map[string]func(float64) float64{}} } // Name identifies the component in start-up and shutdown logs. func (r *ModelRegistry) Name() string { return "ml-model" } // Start loads the models. A real implementation would read weights from disk // or object storage and should honour ctx, which Muzak cancels as soon as a // sibling component fails. func (r *ModelRegistry) Start(ctx context.Context) error { r.mu.Lock() defer r.mu.Unlock() r.models["answer_to_everything"] = func(x float64) float64 { return x * 42 } return nil } // Stop releases the models. It runs only after the HTTP server has finished // draining, so a request that is still predicting keeps working right up to the // end. func (r *ModelRegistry) Stop(ctx context.Context) error { r.mu.Lock() defer r.mu.Unlock() clear(r.models) return nil } // Predict runs the named model and reports whether it was loaded. func (r *ModelRegistry) Predict(name string, x float64) (float64, bool) { r.mu.RLock() defer r.mu.RUnlock() model, ready := r.models[name] if !ready { return 0, false } return model(x), true } ``` ```go [cmd/main.go] models := core.NewModelRegistry() app := muzak.New(muzak.AppOptions{Title: "Awesome API"}, muzak.WithSingleton(models), ) ``` ```go [handlers/meta.go] func Predict(ctx *muzak.Context, in schemas.PredictParams) (schemas.PredictOut, error) { models := muzak.From[*core.ModelRegistry](ctx) result, ready := models.Predict("answer_to_everything", in.X) if !ready { return schemas.PredictOut{}, muzak.ServiceUnavailable("the model is not loaded") } return schemas.PredictOut{Result: result}, nil } ``` :: ## A component built from two closures A type you do not own cannot implement the interface, and a small resource does not warrant a type of its own. `LifecycleFunc` attaches start and stop functions to a value published with `WithSingleton`. ::code-group ```go [core/store.go] // Lifecycle returns the option that registers the store's seeding and teardown // with the application. // // The closures capture the store by pointer, so what they mutate is what // handlers later read. func (s *ItemStore) Lifecycle() muzak.SingletonOption { return muzak.LifecycleFunc("item-store", func(ctx context.Context) error { s.mu.Lock() defer s.mu.Unlock() s.items["foo"] = Item{ID: "foo", Name: "Foo"} s.items["bar"] = Item{ID: "bar", Name: "Bar"} return nil }, func(ctx context.Context) error { s.mu.Lock() defer s.mu.Unlock() clear(s.items) return nil }, ) } ``` ```go [cmd/main.go] store := core.NewItemStore() app := muzak.New(muzak.AppOptions{Title: "Awesome API"}, muzak.WithSingleton(store, store.Lifecycle()), ) ``` :: The value itself is published unchanged, so the handler still retrieves it by type with `muzak.From[*core.ItemStore](ctx)`. The closures must capture something whose contents can be mutated in place, such as a map, a struct pointer or a slice header held behind one. Reassigning a captured variable inside `Start` will not change what handlers see. `muzak.NewLifecycle(name, start, stop)` builds a standalone `Lifecycle` from the same two closures, for a component that is not published as a value at all. Either function may be nil, which makes that half a no-op. ## Components with no published value A background worker or a metrics exporter has nothing a handler would ask for. Register it directly. ```go app := muzak.New(muzak.AppOptions{Title: "Awesome API"}, muzak.WithLifecycle( muzak.NewLifecycle("outbox-worker", worker.Start, worker.Stop), ), ) ``` Components registered this way are started and stopped exactly like those discovered through `WithSingleton`. ## Start-up Components start **in parallel**, so start-up costs the slowest one rather than the sum. ``` 14:32:07.482 INFO [Server] Starting Muzak application... 14:32:07.483 INFO [Server] Starting 3 lifecycle components in parallel: redis, database, ml-model 14:32:07.501 INFO [Server] Started "redis" (18ms) 14:32:07.512 INFO [Server] Started "ml-model" (29ms) 14:32:07.544 INFO [Server] Started "database" (61ms) 14:32:07.545 INFO [Server] All lifecycle components ready (61ms total) ``` If one fails, the others are cancelled immediately through the context they were given, everything that did start is stopped, and the failures are reported together. A failed start-up never leaks a connection pool. That is why `Start` should honour its context. A dial that ignores cancellation holds up a start-up that has already failed. The context `Start` receives **stays live after `Start` returns**, so a component may keep it for a background worker. It used to be cancelled as soon as `Start` returned, which cancelled the worker during boot. It is still cancelled when a sibling fails to start and when the context passed to `StartLifecycle` is, and it is now also cancelled after `Stop` has run. Under a run method it is cancelled by the run's context, or by `Shutdown`, only while the components are still starting. Once they are up, the run's context ending (`SIGTERM` under `RunSignals`) starts a drain, and the components' context stays live until `Stop` has run, so a worker kept on it keeps working for the requests still being answered. `StartLifecycle` called while another is still starting waits for it and reports its outcome instead of returning success early, and a `StopLifecycle` that overtakes a start makes it release what it had started rather than leave it running. ## Shutdown Shutdown runs in the order that matters: 1. Readiness reports unavailable, so a load balancer probing the application's [health checks](/docs/deployment/health-checks) stops sending it traffic. With `ServerOptions.DrainDelay` set, the server keeps serving for that long before it goes on. 2. Open WebSocket connections are closed with `1001 Going Away`, and open event streams are ended. Both are tracked by the application precisely so this can happen: a hijacked connection is no longer one `net/http` knows about, and a streaming handler would otherwise make the drain wait out its whole deadline once per stream. 3. The HTTP server stops accepting new connections and drains its in-flight requests, together with the step above. 4. The tasks handlers registered with `Context.AfterResponse` are drained. See [Background Tasks](/docs/techniques/background-tasks). 5. Only then are the lifecycle components stopped, so a background task can still use them. Pulling a database connection out from under a request that is still running would turn an orderly shutdown into a burst of errors, which is why components stay usable to the very end. `ServerOptions.ShutdownTimeout` is **one deadline for the whole shutdown**, counted from the call, defaulting to 15 seconds. `ServerOptions.DrainDelay` comes out of it first, rather than being added to it, which is why a delay that leaves no time to drain is a build error. The WebSocket, event stream and HTTP drains then happen together within it, where each used to be given a full timeout of its own, and the background tasks are drained within what remains. When it passes, every connection still open is closed, hijacked ones included, and handlers get a further 100 milliseconds to notice and return; background tasks still queued are dropped and running ones have their context cancelled, both logged. `Stop` then gets what remains of the deadline, or one second if the drain used all of it, so a shutdown returns within `ShutdownTimeout` plus about one second unless a component's `Stop` ignores its context. A component is stopped only after every handler has returned, except a handler that ignores both its request's context and its connection being closed: `Shutdown` logs how many of those there were. `Run` returns once `Shutdown` has finished, after which the application may be run again. A negative `ShutdownTimeout` waits for in-flight requests and background tasks without limit. Set it below whatever grace period the platform allows, or the platform kills a drain that is still running: Cloud Run allows about ten seconds after `SIGTERM`, and Kubernetes and ECS both default to thirty. See [Running on Cloud Run](/docs/deployment/cloud-run). A `Stop` that honours its context keeps the shutdown within its bound. Make `Stop` return when its context ends, or raise `ShutdownTimeout`. Components are stopped **in parallel, in no order**, so a `Stop` must not need another component still being open. A consumer that flushes into a database pool should own the pool, or be one component with it, rather than expect it to outlive its own `Stop`. ### When the server fails to serve A certificate that would not load, or an accept error that cannot be retried, used to return the error with every component that had started before the socket opened still running. `Run` now stops them as a shutdown stops them, and closes the listener. So does a failed start-up: it stops what did start with a context that expires after the whole of `ShutdownTimeout`, never less than one second, instead of waiting forever on a `Stop` stuck on an unreachable broker. A shutdown requested while the server is still starting is honoured: it cancels the context the components are being started with, stops whatever did start and returns nil without serving a request. A second `SIGINT` or `SIGTERM` while `RunSignals` is draining ends the process, so a second Ctrl-C during a long drain does something. ### A component that panics A panic in `Start` or `Stop` becomes that component's error rather than the end of the process, and the components either side of it are still started or released. This matters most in `Stop`, which runs after the drain, on the framework's goroutine, when every request has already been answered. A crash there takes the process down at the one moment where doing so achieves nothing and the stack points at the framework rather than at the component that caused it. A handler that panics already becomes a 500 rather than a dead process; this is the same bargain for the same reason. It is a backstop, not permission. A component whose `Stop` can panic is usually one that dereferences something `Start` never set, and the fix is to check it: ```go [core/deps.go] func (d *Deps) Stop(ctx context.Context) error { // Every field is checked, because a partially constructed Deps is exactly // what exists when the constructor failed halfway. if d.Pool != nil { d.Pool.Close() } if d.Cache != nil { return d.Cache.Close(ctx) } return nil } ``` ## Driving it by hand `Run`, `RunContext` and `RunSignals` call `StartLifecycle` between building the application and opening the socket, and `Shutdown` calls `StopLifecycle` after the server has drained. A program that serves the application some other way, such as a test wiring it into `httptest`, calls them itself. ```go if err := app.Build(); err != nil { return err } if err := app.StartLifecycle(ctx); err != nil { return err } defer app.StopLifecycle(context.Background()) ``` Both are idempotent: starting twice is a no-op, and stopping components that were never started returns nil. The `muzak.dev/framework/testclient` package does all of this for you, including releasing everything through the test's cleanup. It runs the application through the same path `Run` takes, so `Shutdown` is what releases everything at the end of a test. See [Testing](/docs/fundamentals/testing). ## Components the framework registers `MemoryRateLimitStorage` implements `Lifecycle`, so an application that uses the built-in rate limiter starts and stops it as part of its own start-up and shutdown. Stopping releases every counter it holds, so no key outlives the server that was counting it. The same applies to a `RateLimitStorage` of your own: implement `Lifecycle` and it is started before serving and stopped after draining, with no separate registration. So it does for the `Tracer` and the `RequestObserver` named in `AppOptions.Tracing` and `AppOptions.Observer`: one that implements `Lifecycle` is started and stopped with the application, which is how the `otlp` exporter sends its last spans at shutdown. See [Observability](/docs/fundamentals/observability). An outbound `*muzak.Client` holds idle connections open. `Client.Close` releases them and leaves the client usable, so a late request still finishing during the drain is not refused; register it as a component of its own: ```go muzak.WithLifecycle(muzak.NewLifecycle("payments-client", nil, func(context.Context) error { return client.Close() })) ``` See [HTTP Client](/docs/techniques/http-client). ## Where to go next [Configuration](/docs/fundamentals/configuration) covers reading the settings those components are built from, and [Server Configuration](/docs/deployment/server-configuration) covers the shutdown timeout and the signals that trigger it. [Health Checks](/docs/deployment/health-checks) covers the readiness a shutdown turns off first. -------------------------------------------------------------------------------- title: "Command Line" description: "Create a project, run it while you write it, and read the OpenAPI document it publishes, with the muzak command." version: "0.3.0" last_updated: "2026-10-10T07:35:27.000Z" source: "https://muzak.dev/docs/0.3.0/getting-started/command-line" -------------------------------------------------------------------------------- # Command Line `muzak` is the framework's command. It creates a project laid out the way the framework's own example is, runs it while you write it and restarts it when a file changes, and reads the OpenAPI document an application publishes: as a table of routes, as a compatibility report for CI, or as TypeScript for a frontend. ```bash go install muzak.dev/framework/cmd/muzak@latest ``` The command lives in the framework's own module, so the `muzak` installed at a version is the one that matches the framework of that version, and the project it creates requires that version. Like the framework, it uses the standard library and nothing else. | Command | What it does | |---|---| | `muzak new` (`muzak.status.422`), so a locale file can word it like any
other string. Its `type`, `status` and `code` are never translated.
### Four scopes, narrowest first
A failure is looked up under four keys, in order, so one rule on one field of one model can
be phrased without restating any other.
```
errors.models..attributes..
errors.models..
errors.attributes..
errors.messages.
```
```yaml
es:
errors:
messages:
blank: "es obligatorio"
models:
create_item:
attributes:
name:
blank: "cada artÃculo necesita un nombre"
```
The model is the input type's name in snake case. A framework that distinguishes a
persisted record from a plain model needs a fifth level above these; Muzak has one kind of
model, so it has four.
### Naming a translation yourself
`Message` fixes the wording in one language on purpose. `MessageKey` names a translation
instead, and the rule's own values still fill it in.
```go [schemas/items.go]
func (in *CreateItem) Validate(v *muzak.Validation) {
v.String(&in.Password).MinLen(12).MessageKey("errors.password.too_short")
v.When(in.Price < in.Cost).
RejectKey(&in.Price, "errors.item.price_below_cost")
}
```
An error you raise yourself works the same way, keeping the message already written as the
fallback.
```go
return muzak.Forbidden("only editors may do that").
WithMessageKey("errors.access.denied")
```
## Pluralization
A count both prints and chooses which wording prints it.
```yaml
en:
greeting:
items:
zero: "You have no items"
one: "You have one item"
other: "You have %{count} items"
```
```go
ctx.T("greeting.items", "count", 0) // You have no items
ctx.T("greeting.items", "count", 1) // You have one item
ctx.T("greeting.items", "count", 5) // You have 5 items
```
English has two forms and an optional zero. Russian has four, Arabic six, and Japanese one.
Muzak carries the CLDR arithmetic for around ninety languages, so a locale file supplies only
the words.
```yaml
ru:
files:
one: "%{count} файл"
few: "%{count} файла"
many: "%{count} файлов"
```
A count may be a float, and it selects the form CLDR gives its language for that number as it
is printed. `ctx.T("distance.km", "count", 1.5)` gives "1.5 kilometres" in English, where
French chooses its `one` form and Czech has a form for fractions of its own. A count that is
NaN, an infinity, or 2^63 or more in magnitude is refused as a malformed argument. A regional
locale counts as its language does, so `pt-BR` counts the way `pt` does, except `pt-PT`, which
CLDR gives a rule of its own.
A rule that is genuinely new is conventionally written as a function inside the locale
data, which a YAML file in Go cannot hold. A locale file here names a rule instead, and a
rule nobody has written yet is supplied as a function.
::code-group
```yaml [naming one]
ru:
i18n:
plural:
rule: slavic
```
```go [writing one]
i18n.StoreOptions{
PluralRules: map[string]i18n.PluralRule{
"xx": func(n i18n.PluralOperands) i18n.PluralCategory { ... },
},
}
```
::
A rule reads CLDR's operands: `n.I` is the whole part, and `n.V` and `n.F` are how many digits
follow the point and what they are, so a rule over whole counts checks `n.V == 0`.
## Interpolation
`%{name}` takes a value as it comes; `%d` takes a format directive.
```yaml
en:
product_price: "$%{price}"
progress: "%