# Muzak documentation (0.3.1) 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.1, the current release. Older versions are indexed under https://muzak.dev/docs//llms.txt. The page index is at https://muzak.dev/llms.txt. Every page below is also served as HTML under https://muzak.dev/docs/0.3.1. -------------------------------------------------------------------------------- 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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/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.1" last_updated: "2026-10-10T11:16:23.000Z" source: "https://muzak.dev/docs/0.3.1/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 ` | Creates a project in `dir` | | `muzak dev` | Builds and runs the application, and rebuilds and restarts it on every change | | `muzak routes` | Prints the operations a document describes | | `muzak diff old.json new.json` | Reports what changed between two documents, and fails on breaking changes | | `muzak ts` | Writes TypeScript declarations, and optionally a client, for a document | | `muzak version` | Prints the framework version it belongs to, which a new project requires, and the Go toolchain it was built with | | `muzak help [command]` | Prints help; `muzak -h` prints the same | Flags may come before or after the other arguments, so `muzak new shop -module example.com/shop` reads as it is written. A `--` ends the flags. ## Creating a project ```bash muzak new shop -module example.com/shop cd shop go mod tidy go test ./... muzak dev ``` ``` shop/ .gitignore README.md cmd/server/main.go composition, and nothing else go.mod requires muzak.dev/framework at this version handlers/hello.go the function that answers GET /hello handlers/hello_test.go a test that serves the application with testclient routers/hello.go which handler answers which path routers/routers.go schemas/hello.go the request and response models, with their rules ``` It is the layout [First Steps](/docs/getting-started/first-steps#project-layout) describes, with one route in it, and the test is written the way [Testing](/docs/fundamentals/testing) recommends. Two things differ from the page's picture: the main package is in `cmd/server`, which is where `muzak dev` looks by default, and there is no `core/` yet; add it when the application gains configuration, guards or managed resources. A project with its main package in `cmd/`, as the framework's example has, runs with `muzak dev -pkg ./cmd`. `go mod tidy` downloads the framework and records its checksum in `go.sum`, which `muzak new` cannot do without the network. | Flag | Default | Effect | |---|---|---| | `-module path` | the name of `dir` | The module path written to `go.mod` and every import | A module path is checked the way the go command checks one: elements of ASCII letters, digits and `- . _ ~`, none empty, none starting or ending with a dot, none a name Windows reserves for a device. A directory called `My App` is not a module path, so it needs `-module`. ### What it will not write over `muzak new` writes into a directory it creates, or into one that exists and is empty. Anything else is refused, and nothing is written: - a directory that holds anything, even only a `.git`; - a file at the path; - a symbolic link at the path, which would put the project somewhere other than where the command line says; - a path whose parent does not exist. Every file is created through an `os.Root` opened on the directory, and created exclusively, so no name and no symbolic link placed inside the directory while it is being written can lead a write outside it or over a file that appeared meanwhile. If a write fails part way, everything the command created is removed again, newest first, and the directory too when the command created it: a failed run leaves the file system as it found it. Files are created `0644` and directories `0755`, less whatever your umask takes away. ## Running while you write ```bash muzak dev muzak dev -pkg ./cmd/api -- -config dev.yaml ``` `muzak dev` builds the package with `go build` into a temporary directory, runs it, and watches the tree. When a file changes it waits for the editing to settle, builds again, and, if the build succeeds, stops the running application and starts the new build. A build that fails prints what the compiler said and leaves the application that was running alone, so the server stays up while you fix the mistake. ``` muzak dev: building ./cmd/server muzak dev: started ./cmd/server (pid 41207) muzak dev: building ./cmd/server # example.com/shop/handlers handlers/hello.go:12:9: undefined: greeting muzak dev: the build failed; the application keeps running the previous build muzak dev: building ./cmd/server muzak dev: started ./cmd/server (pid 41388) ``` | Flag | Default | Effect | |---|---|---| | `-pkg package` | `./cmd/server` | The package to build and run | | `-watch directory` | `.` | The tree to watch | | `-poll duration` | `300ms` | How often to look for changes, at least `10ms` | | `-grace duration` | `5s` | How long a stopping application has before it is killed | | `-ext list` | `go,mod,sum,work,yaml,yml,env` | The extensions of the files whose change rebuilds | Arguments after `--` are passed to the application. It runs in the current directory, with the same environment as `muzak dev`, and with no standard input. ### How a change is found Changes are found by polling: every `-poll`, the tree is walked and each watched file's size and modification time compared with the last walk. That needs nothing from the operating system but `stat`, so it works the same on every platform and on every file system, network mounts and container bind mounts included. Only files with one of the `-ext` extensions are watched. The default covers the Go sources and the files the go command reads to build them, YAML, which is what locale files are and which an application usually embeds, and `.env`, which configuration is read from at start-up. An application that writes a SQLite database, a log or an upload into its own directory therefore does not restart itself every time it writes. Add the extensions of anything else you embed: ```bash muzak dev -ext go,mod,sum,yaml,html,tmpl ``` Directories whose names start with a dot (`.git`, `.idea`), `node_modules`, `vendor` and `testdata` are not walked, nor is the directory `muzak dev` builds into. A `-watch` root that is a symbolic link, or a directory reached through one, is followed, so a project you reach through a link is watched as the directory it names. Links inside the tree are not: a symbolic link to a file is watched by what it points to, and one to a directory is not followed, so a link cannot make the walk loop. A poll reads every directory under `-watch` that is not skipped, and costs one `stat` per watched file. The number of watched files is bounded: a tree with more than 10,000 is refused with a message rather than polled ever more slowly. A project that size is one where `-watch` is pointing at something it should not, such as a build output or another checkout. Only watched files count toward the bound, so a large data directory whose files have other extensions is not refused, but it is still read on every poll: keep it outside the watched tree. A rebuild waits for one poll in which nothing changed, so a save that touches several files builds once. A file that changes on every poll cannot put the build off for ever: after ten polls in a row it builds anyway. ### Stopping Ctrl-C or `SIGTERM` stops `muzak dev`, and so do the hang-up (`SIGHUP`) a terminal sends when it closes and the quit (`SIGQUIT`) that `Ctrl-\` sends; left to their defaults, those two would end `muzak dev` alone and leave the application running, holding its port. The application is sent the same signal, given `-grace` to shut down, and killed if it has not. A restart after a change sends `SIGINT` in the same way, which a Muzak application started with `RunSignals` answers by draining its requests and stopping its lifecycle components, as it would in production; see [Server Configuration](/docs/deployment/server-configuration#shutting-down). On Unix the application runs in a process group of its own, and the signal goes to the whole group, so a process the application started (a worker, a `sh -c`) is stopped with it. A signal from the terminal, Ctrl-C, `Ctrl-\` or the hang-up, reaches `muzak dev` alone and is forwarded from there, so it is not delivered twice. An application that exits by itself is reported, whatever it started is stopped, and `muzak dev` waits for a change rather than starting it again. Every process is waited for, so none is left behind as a zombie, and the build directory is removed when `muzak dev` exits. ### On Windows Windows has no interrupt one process can send another, so the application is stopped at once, without the chance to shut down gracefully, and `-grace` has nothing to wait for. It runs in a job object of its own, which Windows puts every process it starts into as well, and stopping it terminates the job: a process the application started is stopped with it, as it is on Unix. The application starts suspended and joins the job before it runs, so nothing it starts can be outside it. On Unix, a process that leaves the application's group, as a daemon calling `setsid` does, is not stopped. ## Listing the routes ```bash muzak routes -url http://localhost:8080/openapi.json muzak routes -file openapi.json go run ./cmd/openapi | muzak routes -file - ``` `cmd/openapi` is the small command [OpenAPI](/docs/fundamentals/openapi#committing-the-document) shows, which writes the application's document to standard output without starting a server. ``` METHOD PATH OPERATION SUMMARY SECURITY GET /health get_health - - GET /items get_items List items none POST /items post_items Create an item oauth[items:write,items:read] GET /items/{id} get_items_by_id Read an item bearer | bearer+key DELETE /items/{id} delete_items_by_id (deprecated) Delete an item oauth[items:write] ``` Rows are sorted by path and then by method, in the order `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, so the same document always prints the same table. A summary longer than 72 characters is cut, and a deprecated operation's is marked. The security column lists the alternatives a client satisfies one of, separated by `|`. Each names the schemes it needs together, joined by `+`, with the scopes in brackets: `bearer | bearer+key` accepts a bearer token alone or one with an API key. `none` is an operation that declares it needs no credentials, which is what [`muzak.Public()`](/docs/fundamentals/openapi#declaring-how-a-route-authenticates) writes, and `-` one that declares nothing at all. | Flag | Effect | |---|---| | `-url URL` | Fetches the document from a running application | | `-file file` | Reads the document from a file, or from standard input when it is `-` | Exactly one of the two is given. ### Fetching from a running application `-url` fetches through a [`muzak.Client`](/docs/techniques/http-client), so the fetch has every bound the client has: it gives up after 30 seconds, reads at most 16 MiB, follows at most five redirects and never from https down to http. The client is allowed to connect to loopback and private addresses, which a client of a URL from somewhere else never should be, because a developer points it at their own application, on `localhost` or inside their network. Cloud metadata addresses stay refused all the same: no document is served there, and a redirect to one is how a fetch would read a machine's credentials. The document is read with `muzak.ReadDocument`, strictly and within its bounds. It reads what Muzak writes, so a document that is not OpenAPI 3.1, that uses a keyword Muzak never writes, such as `oneOf`, or an extension, or that is larger or deeper than a document is read at, is refused with a message saying what and where. A document another framework generated is therefore usually refused rather than half understood. ## Comparing two documents ```bash muzak diff baseline.json current.json muzak diff -fail-on possibly baseline.json current.json ``` ``` Breaking (1): /paths/~1items/post: The operation POST /items is no longer served. [operation-removed] Possibly breaking (1): /paths/~1items~1{id}: The path "/items/{id}", which was deprecated, is no longer served. [path-removed] Compatible (1): /paths/~1users: The path "/users" is new. [path-added] muzak: the comparison found 1 breaking change, and -fail-on breaking fails on that ``` This is `muzak.CompareDocuments` and `muzak.WriteChanges`, so it judges exactly what `testclient.AssertCompatible` judges in a test; see [API Compatibility](/docs/fundamentals/api-compatibility) for how each change is classified. The report goes to standard output and the verdict to standard error. | Exit status | Meaning | |---|---| | `0` | No change as serious as `-fail-on` names | | `1` | At least one change that serious, or the report cannot be written, even under `-fail-on never` | | `2` | The command line is wrong, or a document cannot be read | | `-fail-on` | Fails on | |---|---| | `breaking` (default) | Breaking changes | | `possibly` | Breaking and possibly breaking changes | | `never` | Nothing: the report only informs | A document that cannot be read exits `2` rather than `1`, so a CI step can tell a missing baseline from a breaking change. Either document may be read from standard input by naming it `-`: ```bash go run ./cmd/openapi | muzak diff openapi.json - ``` In a pipeline, compare the document a pull request generates with the one its base branch committed: ```yaml [.github/workflows/api.yml] - run: go install muzak.dev/framework/cmd/muzak@latest - run: git show origin/main:openapi.json > baseline.json - run: go run ./cmd/openapi > openapi.json - run: muzak diff baseline.json openapi.json ``` ## Generating TypeScript ```bash muzak ts -url http://localhost:8080/openapi.json -o web/src/api.ts muzak ts -file openapi.json -client -o web/src/api.ts ``` `muzak ts` writes what the `tsgen` package generates: an interface for every component schema and the parameter, body, response and error types of every operation, and with `-client`, a small client built on `fetch`. See [TypeScript](/docs/fundamentals/typescript) for what is written and why. | Flag | Effect | |---|---| | `-url URL` / `-file file` | Where the document is read from, as for `muzak routes` | | `-client` | Also writes the client | | `-o file` | Writes to the file rather than to standard output | The file `-o` names is written to a temporary file beside it, `...tmp`, and renamed into place, so a frontend build watching it never reads half a file and an interrupted run leaves the previous one intact, though it may leave the temporary file behind. A new file is created `0644`, less whatever your umask takes away; a file that is there already keeps its mode. A symbolic link at the path is replaced rather than written through. `muzak ts` refuses a schema nested more than 64 levels deep, as `tsgen` does, even though a document nested up to 128 levels is read: a document `muzak routes` and `muzak diff` accept can still be one it cannot generate from. ## What a hostile document cannot do `muzak routes`, `diff` and `ts` read documents someone else may have written: a partner's Muzak service, or a URL in a ticket. Everything the command prints that came from a document, a server or a file is escaped for the terminal first: control characters, including the escape that starts an ANSI sequence, the tab and line break that would split the table, the Unicode controls that reorder or hide text, and bytes that are not UTF-8 are printed as the Go escape that names them. A path named `/a\x1b[2J` is shown as exactly that, and cannot clear your screen, retitle the window or forge a row of the report. | Bound | Value | |---|---| | Document size | 16 MiB | | Document nesting | 128 levels | | Schema nesting `muzak ts` generates from | 64 levels | | Fetch time | 30 seconds, every redirect and the body included | | Redirects | 5, never from https to http | | Addresses fetched from | Public, loopback and private; never cloud metadata | | Files `muzak dev` watches | 10,000 | | Shortest poll | 10ms | ## Exit status Every command exits `0` when it did what it was asked, `1` when it could not, and `2` when the command line was wrong: an unknown command, an unknown flag, a missing or extra argument. An unknown command is answered with the nearest one: ``` $ muzak rotues muzak: there is no command "rotues"; did you mean "routes"? Run "muzak help" for the list of commands. ``` `muzak diff` is the exception that a CI step relies on: `1` means the documents differ by more than `-fail-on` allows, or that the report could not be written, and `2` means they could not be compared. ## Where to go next [First Steps](/docs/getting-started/first-steps) walks through the project `muzak new` creates. [API Compatibility](/docs/fundamentals/api-compatibility) covers the comparison `muzak diff` runs, and [TypeScript](/docs/fundamentals/typescript) the declarations `muzak ts` writes. -------------------------------------------------------------------------------- title: "Configuration" description: "Read settings from the environment and a dotenv file into a typed struct, with every missing or malformed variable reported together." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/configuration" -------------------------------------------------------------------------------- # Configuration Configuration is a struct. Its tags say where each field comes from and what happens when the value is absent, and the whole thing is read once at start-up. ::code-group ```go [core/config.go] // Settings is the service configuration, read once at start-up from the // environment and from a .env file when one is present. type Settings struct { // AppName titles the API in the generated documentation. AppName string `env:"APP_NAME" default:"Awesome API"` // AdminEmail is who to contact about the API. AdminEmail string `env:"ADMIN_EMAIL" required:"true"` // ItemsPerUser caps how many items one user may hold. ItemsPerUser int `env:"ITEMS_PER_USER" default:"50"` // Addr is the address the server listens on. Addr string `env:"ADDR" default:":8080"` // AdminToken guards the admin subtree. It is marked secret so that a // malformed value never appears in a start-up error. AdminToken string `env:"ADMIN_TOKEN" secret:"true" required:"true"` // TrustedProxies lists the proxies whose X-Forwarded-For header is // believed, as a comma-separated list of addresses or CIDR prefixes. TrustedProxies []string `env:"TRUSTED_PROXIES"` } // LoadSettings reads the configuration, stopping the process if a required // value is missing. func LoadSettings() Settings { return muzak.MustLoadConfig[Settings](muzak.EnvFile(".env")) } ``` ```go [cmd/main.go] settings := core.LoadSettings() app := muzak.New(muzak.AppOptions{ Title: settings.AppName, Version: "1.0.0", Contact: &muzak.Contact{Email: settings.AdminEmail}, Addr: settings.Addr, }, muzak.WithSingleton(settings), ) ``` :: Publishing the value with `WithSingleton` is what lets a handler read it with `muzak.From[core.Settings](ctx)` rather than through a package-level variable. ## Loading ```go settings, err := muzak.LoadConfig[Settings](muzak.EnvFile(".env")) if err != nil { return err } ``` `MustLoadConfig` is the same thing for a program that cannot run without its configuration. It panics, which is the right behaviour in a `main` function: a service missing a required setting should stop immediately and visibly rather than start in an undefined state. Prefer `LoadConfig` anywhere the failure can be handled. Every problem found is reported together: ``` muzak: configuration could not be loaded: muzak: ADMIN_EMAIL is required but was not set, or is empty, in the environment or .env muzak: ITEMS_PER_USER must be a valid integer (got "fifty") muzak: ADMIN_TOKEN could not be parsed (value hidden because the field is marked secret) ``` A first run in a new environment lists everything missing at once instead of one thing per attempt. ## The tags | Tag | Effect | |---|---| | `env:"NAME"` | The variable to read. Without it, the name is derived from the field | | `env:"-"` | Skip the field entirely | | `default:"value"` | The value used when no source holds the variable | | `required:"true"` | An absent **or empty** variable is an error | | `secret:"true"` | Keep the value out of the error produced when it fails to parse | A field with neither `default` nor `required:"true"` is simply left at its zero value when nothing supplies it. An empty value does not satisfy `required`. `API_KEY=`, which Compose and Kubernetes produce when they expand an unset variable, used to load an empty secret without a word. It is now treated as missing: a `default` applies if the field has one, and the load fails otherwise. A field that is not `required` is unchanged, so an explicitly blank setting still overrides a default there. Drop `required` from a field whose empty value is legitimate, or give it a `default`. ### Where a setting can be declared A setting has to be somewhere `LoadConfig` can set. A tagged unexported field, or an unexported embedded pointer whose type holds tagged fields, used to load nothing without a word, so a `required` setting was never enforced. `LoadConfig` now reports the field. Export it, embed the struct by value, or tag it `env:"-"` to opt out. ### Derived names Without an `env` tag, the name is the field name upper-cased with underscores between words: | Field | Variable | |---|---| | `AppName` | `APP_NAME` | | `ItemsPerUser` | `ITEMS_PER_USER` | | `DatabaseURL` | `DATABASE_URL` | | `APIKey` | `API_KEY` | ### Secrets `secret:"true"` matters for the error path. A malformed value normally appears in the message, which is what makes a typo obvious, but a malformed credential in a start-up log is a credential in a log. ```go DatabaseURL string `env:"DATABASE_URL" secret:"true" required:"true"` ``` That applies to a custom type's own error too. A type implementing `encoding.TextUnmarshaler` writes its own message, which may well echo the text it was given, so a secret field's failure is replaced wholesale rather than filtered. A value read by a `secret:"true"` field is hidden from every other field that reads the same variable, and `secret:"true"` on an embedded struct marks every field inside it, at any depth. `secret:"true"` affects error messages only. It does not redact the field if you log the settings struct, so log the fields you need rather than the whole value. See [Logging](/docs/fundamentals/logging). ## Types Fields are converted with the same setters the request binder uses, so configuration and requests agree on what an `int` is: - `string`, `bool`, every integer width, `float32` and `float64`, a number written as JSON writes one: digits with an optional `-`, and for a float an optional fraction and exponent. Hexadecimal, `1_000`, `.5` and a leading `+` are refused, and a value outside the type's range names the range - `time.Duration`, written as `1500ms`, `30s`, `1h30m` - any type implementing `encoding.TextUnmarshaler`, which covers `time.Time` and `uuid.UUID` - a slice of any of the above, written as a comma-separated list ```go type Settings struct { Addr string `env:"ADDR" default:":8080"` ReadTimeout time.Duration `env:"READ_TIMEOUT" default:"30s"` Workers int `env:"WORKERS" default:"4"` Debug bool `env:"DEBUG" default:"false"` AllowedOrigins []string `env:"ALLOWED_ORIGINS"` } ``` ```bash ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com ``` Entries are split on commas and trimmed of surrounding spaces. An empty variable yields an empty slice rather than a slice holding one empty string. ## Composing settings An embedded struct is flattened, so a group of settings several services share is declared once and reused. ```go type DatabaseSettings struct { URL string `env:"DATABASE_URL" required:"true" secret:"true"` MaxConns int `env:"DATABASE_MAX_CONNS" default:"10"` DialTimeout time.Duration `env:"DATABASE_DIAL_TIMEOUT" default:"5s"` } type Settings struct { DatabaseSettings AppName string `env:"APP_NAME" default:"Awesome API"` Addr string `env:"ADDR" default:":8080"` } ``` `settings.URL` and `settings.AppName` both read as if declared on `Settings`. ## Sources The process environment is consulted first, then each source in the order it was added, and the first source holding a name wins. That ordering is what lets a deployed value beat a checked-in file. ```go settings, err := muzak.LoadConfig[Settings]( muzak.EnvFile(".env"), muzak.EnvPrefix("AWESOME_"), ) ``` | Option | What it adds | |---|---| | `EnvFile(path)` | A dotenv file. A missing file is not an error; a malformed one is | | `ConfigValues(map)` | An explicit set of values, which is what a test uses | | `WithConfigSource(src)` | Anything implementing `ConfigSource`, such as a secret manager | | `EnvPrefix(prefix)` | Requires every name to carry the prefix, so `APP_NAME` is read as `AWESOME_APP_NAME` | | `WithoutEnvironment()` | Stops the process environment being consulted at all | ### The dotenv format ```bash [.env] # Copy to .env and adjust. Values exported in the real environment always win # over this file, so a container runtime can override anything here. APP_NAME=Awesome API ADMIN_EMAIL=admin@example.com ITEMS_PER_USER=50 ADDR=:8080 # Guards the /admin subtree. Compared in constant time. ADMIN_TOKEN=coneofsilence export DATABASE_URL="postgres://localhost:5432/awesome?sslmode=disable" # Proxies whose X-Forwarded-For header is believed, as a comma-separated list. # TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12 ``` One `KEY=VALUE` pair per line. Blank lines and lines beginning with `#` are ignored, an optional leading `export ` is stripped, and a value may be wrapped in single or double quotes to preserve surrounding spaces or a `#`. Escape sequences are interpreted only inside double quotes. A quoted value may be followed by a comment, after whitespace, so `KEY="value" # note` reads `value`. In an unquoted value a `#` after a space or a tab starts a comment, so `API_KEY= # fill me in` sets `API_KEY` to the empty string, which a required setting refuses. A `#` with no whitespace before it, as in `COLOR=#336699`, is part of the value. A file that cannot be parsed is reported by its line number and, when the line has something that looks like a variable name before the `=`, the key. The text of the line is never quoted, because a line that fails to parse is exactly where a secret tends to be: the second line of an unquoted multi-line PEM key, or `API_KEY: sk-...` typed with a colon. A value cannot span lines, so write a line break as `\n` inside double quotes. Keep the file out of version control and check in a `.env.example` beside it. ## Configuration in tests Pin the values a test needs and shut the environment out, so a test does not inherit whatever the developer happens to have exported. ```go [core/config_test.go] func TestLoadSettings(t *testing.T) { settings, err := muzak.LoadConfig[core.Settings]( muzak.WithoutEnvironment(), muzak.ConfigValues(map[string]string{ "ADMIN_EMAIL": "admin@example.com", "ADMIN_TOKEN": "coneofsilence", }), ) if err != nil { t.Fatalf("LoadConfig = %v", err) } if settings.ItemsPerUser != 50 { t.Errorf("ItemsPerUser = %d, want the default 50", settings.ItemsPerUser) } } ``` ## A source of your own ```go type vaultSource struct { client *vault.Client path string } func (v vaultSource) Name() string { return "vault:" + v.path } func (v vaultSource) Lookup(key string) (string, bool) { secret, err := v.client.Read(v.path + "/" + key) if err != nil || secret == nil { return "", false } return secret.Value, true } ``` ```go settings, err := muzak.LoadConfig[Settings]( muzak.WithConfigSource(vaultSource{client: client, path: "secret/awesome"}), muzak.EnvFile(".env"), ) ``` `Name` appears in the error listing the sources that were searched. A present but empty value must be reported as present, so that an explicitly blank setting can override a default. ## Where to go next [Lifecycle](/docs/getting-started/lifecycle) covers building resources from those settings, and [Logging](/docs/fundamentals/logging) covers the logger the application uses while it does. -------------------------------------------------------------------------------- title: "Validation" description: "Rules declared against the field itself, transforms that mutate before the checks run, cross-field conditions in ordinary Go, and constraints that reach the OpenAPI document." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/validation" -------------------------------------------------------------------------------- # Validation An input model declares its rules by implementing one method. ```go type Validatable interface { Validate(v *muzak.Validation) } ``` ```go [schemas/users.go] func (in *CreateUserIn) Validate(v *muzak.Validation) { v.String(&in.Email).Trim().Lower().Required().Email() v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword) v.String(&in.Confirm).Equal(in.Password).Message("must match the password") v.Number(&in.Age).Between(18, 120) v.String(&in.Role).OneOf("admin", "editor", "viewer") v.Slice(&in.Tags).MaxItems(10).Unique().Each(validate.String().MaxLen(20)) } ``` Implementing it is the only thing needed. There is no option to remember and no pipe to install, so a model cannot be left unvalidated by forgetting one. The method belongs on the pointer type, which is what lets rules name fields by address. `&in.Email` **is** the field, so renaming it is a change the compiler checks, and `v.Number(&in.Email)` does not compile. Type `v.` and your editor lists the kinds; type `v.String(&in.Email).` and it lists every rule that applies to a string. ## How a rule set behaves Four things govern every rule set: - **Transforms mutate, and run first.** `Trim().Lower()` changes what the handler receives. Because they run before the checks, `Trim().Required()` rejects a field of pure whitespace. - **Optional means optional.** A field without `Required()` skips its remaining checks when it is empty, so `MaxLen(20)` has nothing to say about a value nobody sent. A nil pointer field skips its other rules, and **`Required()` fails it**: `v.String(&in.Name).Required()` on a `*string` reports `is required` for `{}` and for `{"name":null}`, with the rule's own message if it has one. A pointer field that is meant to be optional must not declare `Required()`. **A number is the exception, and it is narrower.** Zero only counts as empty when the rules would have accepted zero anyway. `Between(1, 720)` applies to `0` and rejects it, because a rule set that says zero is out of range should not be the one thing that lets it through. See [Number rules](#number-rules). - **One failure per field.** The first check that fails reports; a single empty field does not also complain that it is too short and not an email address. - **Guards run first.** Validation happens after binding and after every guard, so an unauthenticated caller gets `401`, not a map of your schema. - **A rule binds to the field, not to what the field holds.** `v.String(&in.Name)` is right for `Name string` and for `Name *string` alike: the address is how a failure finds the name it reports under. `v.String(in.Name)` compiles, because the entry points accept the pointer type, and is refused when the route is compiled: it would report failures with no field name and drop its constraints from the generated document. ## The entry points | Call | Field types | Rule set | |---|---|---| | `v.String(&in.F)` | `string`, `*string` | `validate.StringRules` | | `v.Number(&in.F)` | any integer, float, or pointer to one | `validate.NumberRules` | | `v.Slice(&in.F)` | `[]E` | `validate.SliceRules[E]` | | `v.Time(&in.F)` | `time.Time`, `*time.Time` | `validate.TimeRules` | | `v.Value(&in.F)` | any type | `validate.ValueRules[T]` | | `v.Nested(&in.F)` | another `Validatable` | | | `v.When(cond)` | | a cross-field condition | | `v.Reject(&in.F, issue)` | | a failure recorded directly | ## String rules ```go 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"). NotOneOf("admin", "root", "support"). Message("is reserved") ``` | Rule | Effect | Message on failure | |---|---|---| | `Trim()` | Transform: removes surrounding whitespace | | | `Lower()` / `Upper()` | Transform: folds case | | | `Required()` | Rejects an empty value | `is required` | | `MinLen(n)` / `MaxLen(n)` / `Len(n)` | Bounds the length in runes | `must be at least 12 characters` | | `Email()` | Parses with `net/mail` and requires the domain to be a hostname or a bracketed IP address | `must be a valid email address` | | `URL()` | Requires an absolute http or https URL with a hostname (`http://:8080/` is refused) | `must be a valid absolute http or https URL` | | `UUID()` | The canonical 8-4-4-4-12 form, in either case, and nothing else | `must be a valid UUID` | | `Matches(pattern)` | A regular expression | `is not in the expected format` | | `OneOf(...)` | A fixed set, which also becomes the enum in the document | `must be one of "admin", "editor", "viewer"` | | `NotOneOf(...)` | A set the value must never take | `is not available` | | `Prefix(s)` / `Suffix(s)` / `Contains(s)` | Substring checks | `must begin with "user_"` | | `Equal(other)` / `EqualFold(other)` | Matches another value, the second ignoring case | `does not match` | | `MatchesNot(pattern)` | Rejects a regular expression, for a shape easier to name than the shapes it excludes | `is in a format that is not accepted` | | `NotBlank()` | Rejects a value that is nothing but whitespace or invisible characters (zero-width space, Hangul filler, braille blank, format characters), and an absent one | `must not be blank` | | `NoControl()` | Rejects control characters, including tabs and line breaks | `must not contain control characters` | | `MinBytes(n)` / `MaxBytes(n)` | Bounds the length in bytes rather than in runes | `must be at least 8 bytes` | | `Must(fn)` | Your own `func(string) error` | whatever the function returns | | `As(name)` | Renames the field in this rule set's messages | | | `Message(text)` | Overrides the wording of the check written immediately before it | | | `MessageKey(key, args...)` | Overrides it with a translation key instead, so the override is translated like every built-in rule | | Lengths are counted as runes rather than bytes, so a name in any script is measured the way a person would count it. `Matches` compiles its expression the first time the rule is declared, so a malformed pattern is a panic at start-up rather than a failure on the first request that happens to reach it. Rules are declared inside `Validate`, which runs on every request, so compiled expressions are kept by pattern text, up to 1024 distinct patterns, and a rule declared per request does not compile again. The expression is Go's RE2 syntax, which is linear in the length of the input, and it is written into the document as it stands. `UUID()` takes only the canonical form. A URN, braces and 32 bare digits name the same identifier, which is the trouble: a value stored as the client wrote it holds one UUID under several strings, and the document's `format: uuid` describes only the canonical one. Normalise the spelling before validating, or use `Matches` for a wider set. `URL()` also rejects a port above 65535 and a value containing a control, format or separator character, such as a bidirectional override or a zero-width space, which would make the address display as another one. `HTTPS()` and `URLWithSchemes()` hold to the same. `Base64()` rejects a value with a carriage return or a line feed, which Go's decoder skips. `Email()` refuses the same invisible characters and the C1 controls, and still accepts internationalised addresses. The host of a URL must name a machine. `http://:8080/admin` has a port and no hostname, and Go's HTTP client dials an empty hostname as the local machine, so a value approved as somewhere else on the network would reach a port on the server itself. `URL()`, `HTTPS()` and `URLWithSchemes()` refuse it like a URL with no host at all; a scheme with no authority, such as `mailto:`, still passes on what follows its colon. The domain of an email address must be a hostname too, which `net/mail` does not ask: labels of letters in any script, digits and hyphens, with no hyphen at either end, so `a@-` and `a@foo_bar.com` are refused. A bracketed IP address, as in `a@[192.0.2.1]`, is still accepted. `URL()` rejects every scheme but http and https on purpose. Without that restriction, `javascript:`, `data:` and `file:` all parse as perfectly valid absolute URLs, and a value this check approves is exactly the kind of thing that ends up in a redirect, a fetch or an href with the validator's blessing. ### Addresses and URLs `URL()` accepts any absolute http or https URL. These narrow it, which is usually what a field means. | Rule | Effect | Message on failure | |---|---|---| | `HTTPS()` | An absolute https URL with a hostname | `must be a valid https URL` | | `URLWithSchemes(...)` | An absolute URL using one of the given schemes | `must be a URL using "s3" or "gs"` | | `Host()` | A DNS hostname, with the label rules a resolver enforces | `must be a valid hostname` | | `IP()` / `IPv4()` / `IPv6()` | An address of either family, or of one | `must be a valid IPv4 address` | | `CIDR()` | A network, as `10.0.0.0/8` | `must be a valid network in CIDR notation` | | `MAC()` | A hardware address in any of its spellings | `must be a valid MAC address` | `URL()` refuses every scheme but http and https on purpose. Without a restriction, `javascript:`, `data:` and `file:` all parse as perfectly valid absolute URLs, and a value a validator has blessed is exactly the kind of thing that ends up in a redirect or an `href`. `IPv6()` refuses an IPv4 address written in the mapped form, such as `::ffff:127.0.0.1`. It is an IPv4 address wearing a costume, and a field asking for IPv6 wants one an IPv6-only network can route. `IP()` and `IPv6()` refuse an address with a zone, such as `fe80::1%eth0`. The zone is free text to the parser, so an address written as `fe80::1%` followed by a line break and a header, or by markup, used to pass as valid, and a zone names an interface on the machine that wrote it, which means nothing to the one reading it. A field that needs a link-local zone should take a string and check it with a rule of its own. ### Character classes and formats | Rule | Effect | Message on failure | |---|---|---| | `Alpha()` | Letters only | `must contain only letters` | | `Alphanumeric()` | Letters and digits | `must contain only letters and digits` | | `Numeric()` | Digits only, as characters rather than as a number | `must contain only digits` | | `ASCII()` | Printable ASCII, which is space through tilde | `must contain only printable ASCII characters` | | `Slug()` | Lower case letters and digits in hyphen-separated groups | `must contain only lower case letters, digits and hyphens` | | `Hex()` | Hexadecimal digits, in either case | `must be hexadecimal` | | `HexColour()` | A colour, as `#1a2b3c`, with three, four, six or eight digits | `must be a hexadecimal colour, such as #1a2b3c` | | `Base64()` | Decodes as standard base64 | `must be valid base64` | | `JSON()` | A well-formed JSON document, for a field carrying JSON as text | `must be valid JSON` | | `Semver()` | A semantic version, as `2.0.0-rc.1+build.5` | `must be a semantic version, such as 1.4.0` | | `E164()` | A telephone number in international format | `must be a telephone number in international format` | | `LanguageTag()` | A BCP 47 tag, as `pt-BR` | `must be a valid language tag, such as pt-BR` | | `Timezone()` | A zone spelled exactly as the IANA database spells it, as `Europe/Istanbul` | `must be a known time zone, such as Europe/Istanbul` | | `CountryCode()` / `CurrencyCode()` | An assigned ISO 3166-1 alpha-2 or ISO 4217 code | `must be a known country code` | Letters are Unicode letters rather than the twenty-six of English, so `Alpha()` accepts a name in any script. Narrow it by composing: `Alpha().ASCII()`. `CountryCode()` and `CurrencyCode()` look the value up in the assigned registers rather than counting letters, so `XQ` is refused although it is two upper case letters, and a currency withdrawn from the standard is refused although it was valid a few years ago. Both tables carry the date they were taken, and both need regenerating when the standards change. The generated document describes the shape rather than the table, because two hundred values in every schema that names a country would be noise rather than documentation. Reach for `OneOf(...)` with your own list when a service trades in a known handful. It is narrower, it becomes an enum in the document, and it cannot go stale in a way that matters. `Timezone()` accepts only names spelled as the IANA database spells them, and resolves them against the zone data the host or the binary carries, so what passes here is what `time.LoadLocation` will later accept. Spellings `LoadLocation` would also take are refused: case variants (`europe/paris`), doubled or trailing slashes, `Local`, and the `posix/` and `right/` names. A zone created after the list built into the package (tzdata 2026c) is refused until the list is updated. Send canonical names. The names are checked against that list, so a client sending nonsense pays for a map lookup and nothing grows from what it sends. ## Number rules ```go v.Number(&in.Age).Required().Between(18, 120) v.Number(&in.Limit).Clamp(1, 100) v.Number(&in.Quantity).Positive().MultipleOf(12) ``` | Rule | Effect | Message on failure | |---|---|---| | `Required()` | Rejects a zero value | `is required` | | `Min(n)` / `Max(n)` / `Between(lo, hi)` | Inclusive bounds | `must be between 18 and 120` | | `GreaterThan(n)` / `LessThan(n)` | Exclusive bounds, which refuse the bound itself | `must be greater than 10` | | `Positive()` / `Negative()` | Compared against zero, which is refused | `must be greater than zero` | | `NonNegative()` / `NonPositive()` | Compared against zero, which is allowed | `must not be negative` | | `MultipleOf(n)` | For a quantity that only makes sense in steps | `must be a multiple of 12` | | `Whole()` | Nothing after the decimal point, for a float carrying a count | `must be a whole number` | | `Port()` | A whole number from 1 to 65535 | `must be a port number between 1 and 65535` | | `OneOf(...)` | A fixed set, which also becomes the enum in the document | `must be one of 1 or 2` | | `Clamp(lo, hi)` | Transform: pulls the value inside the range instead of rejecting it | | | `Must(fn)` | Your own `func(float64) error` | whatever the function returns | Bounds are written as ordinary untyped constants whatever the field's own numeric type, so `Between(18, 120)` reads the same on an `int`, an `int64` and a `float64`. An integer field is compared with its bounds exactly, whatever its size: an `int64` of 2^53 + 1 is over `Max(1 << 53)`, although the two are the same number once both are a `float64`. A float field is compared as floating point. Two things still see an integer through `float64`: a fractional `MultipleOf` step, and the function given to `Must`. The inclusive and exclusive pairs are worth keeping apart. A price that must be above zero and a quantity that may be zero are different rules, and the generated document says so: `GreaterThan` and `Positive` produce `exclusiveMinimum`, where `Min` and `NonNegative` produce `minimum`. A client reading `minimum: 0` is being told that zero is allowed. ### Zero, absence, and which one you meant Zero is indistinguishable from absent for a plain numeric field, and the two need different answers. So the rule is narrow: **zero skips the remaining checks only when those checks would have accepted zero.** ```go v.Number(&in.Window).Between(1, 720) // 0 is rejected: the bounds exclude it v.Number(&in.Discount).Between(0, 100) // 0 is accepted: the bounds admit it v.Number(&in.Retries).Max(10) // 0 is accepted v.Number(&in.Score).Must(inHouseRule) // 0 skips: your rule is never run speculatively ``` The first line is the one that used to surprise people. Before 0.2.7 it accepted `0`, the request reached the database, and a `CHECK` constraint turned what should have been a `422` into a `500`. It also made this page's own promise false in the generated document, which advertised a `minimum` the server did not enforce. **A number that may legitimately be absent, and whose bounds exclude zero, is a pointer.** That is the same rule that already applied to one that may legitimately *be* zero: ```go Age *int `json:"age,omitzero"` v.Number(&in.Age).Between(18, 120) // nil is skipped; 0 is rejected; 25 is checked ``` `Required()` still means what it did: it demands presence, and on a plain numeric field it rejects zero. Reach for it when the bounds themselves admit zero and you still want the field supplied. `Clamp` is for a value outside the range being a client being imprecise rather than a client being wrong, such as a page size that should quietly cap rather than fail. Because it moves a value into range and rejects nothing, the generated document does not describe it as a range: `minimum` and `maximum` would tell a client that 500 is refused. `MultipleOf` compares the quotient with its nearest whole number within a few units of float64 precision, so `19.99` is a multiple of `0.01`, and `5.0000000001` is not a multiple of `5`. ## Slice rules ```go v.Slice(&in.Tags).MaxItems(10).Unique().Each(validate.String().MaxLen(20)) v.Slice(&in.Scores).MinItems(1).Each(validate.Value[int]().Must(positive)) ``` | Rule | Effect | Message on failure | |---|---|---| | `Required()` | Rejects an empty or absent collection | `is required` | | `MinItems(n)` / `MaxItems(n)` / `Items(n)` | Bounds the length, or fixes it | `must have at most 10 items` | | `NotEmpty()` | Rejects an empty or absent collection, in words a client can act on | `must not be empty` | | `Contains(v)` / `Excludes(v)` | An element that must be present, or must not be | `must contain read` | | `Unique()` | Every element differs, compared as `reflect.DeepEqual` does, except that a `time.Time` is the instant it names | `must not repeat fine` | | `Each(rules)` | Applies a rule set to every element | reported against the position | | `Must(fn)` | Your own `func([]E) error` | whatever the function returns | `Unique()` is linear in the size of the collection for every element type. Strings, numbers, booleans, arrays and structs of them, times and `netip` addresses are hashed as they are; a pointer, an interface, a slice, a map or a struct holding one is keyed by a canonical encoding of what it holds. Two pointers to equal values are a repeat, a map is the same map in any order, a nil slice is not an empty one and a `NaN` equals nothing. A `time.Time` is the instant it names, wherever in an element it sits, so `12:00Z` and `13:00+01:00` are a repeat. The body limit bounds the memory it uses. A collection over a `MaxItems` or `Items` bound is reported as too long and never searched for repeats, whichever rule is declared first, and neither is needed to protect the server. `Each` only accepts a rule set for the element's own type, so `Each(validate.String())` on a slice of integers does not compile. A failure inside an element is reported against that position, as `tags[2]`, so a client can tell which one to fix. ## Time rules ```go v.Time(&in.StartsAt).Required().After(time.Now()) v.Time(&in.BornOn).Before(eighteenYearsAgo) v.Time(&in.Window).Between(seasonStart, seasonEnd) ``` | Rule | Effect | Message on failure | |---|---|---| | `Required()` | Rejects the zero time | `is required` | | `Before(t)` / `After(t)` / `Between(a, b)` | Bounds against a moment you name | `must be before 2026-01-01T00:00:00Z` | | `Past()` / `Future()` | Bounds against now, judged when the request is validated | `must be in the past` | | `Within(d)` | No further from now than a distance, in either direction | `must be within 5m0s of now` | | `Must(fn)` | Your own `func(time.Time) error` | whatever the function returns | The generated schema carries `"format": "date-time"`. `Within` is the rule a signed request or a replayed event wants: a timestamp far in the past is stale and one far in the future is a clock that cannot be trusted, and both are the same mistake. ```go v.Time(&in.Timestamp).Within(5 * time.Minute) ``` ## Value rules `v.Value` is the escape hatch for anything the typed rule sets do not cover: a custom type, an enum with its own `String` method, a struct compared as a whole. Because it is generic over the field's type, `Must` and `OneOf` take that type directly and the compiler checks the values written at the call site. ```go type Currency string v.Value(&in.Currency).Required().OneOf(Currency("EUR"), Currency("USD"), Currency("TRY")) v.Value(&in.Coordinates).Must(insideServiceArea) ``` ## Rules of your own A rule is an ordinary function, so it needs no registration and is testable on its own. ```go [schemas/rules.go] // commonPasswords stands in for the list a real service would load. Keeping it // here rather than inside the rule means the rule stays a plain function that a // test can call directly. var commonPasswords = map[string]bool{ "password1234": true, "qwertyuiop12": true, "letmeinplease": true, } // NotACommonPassword rejects a password from the known-bad list. // // Its message is phrased to read after the field name, the way every built-in // rule words its own failures. func NotACommonPassword(password string) error { if commonPasswords[strings.ToLower(password)] { return errors.New("is too common, choose something less guessable") } return nil } ``` ```go v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword) ``` ```go [schemas/rules_test.go] func TestNotACommonPassword(t *testing.T) { if err := NotACommonPassword("password1234"); err == nil { t.Error("want a common password to be rejected") } } ``` ## Reusing a rule set `validate.String()`, `validate.Number()`, `validate.Slice[E]()`, `validate.Time()` and `validate.Value[T]()` build unbound rule sets, which is how a constraint stays consistent across the models that share it. ```go [schemas/rules.go] // validateTag returns the rules every tag must satisfy. func validateTag() *validate.StringRules { return validate.String().Trim().Lower().MaxLen(20). Matches(`^[a-z0-9-]+$`). Message("may only contain lower case letters, digits and hyphens") } ``` ```go v.Slice(&in.Tags).MaxItems(10).Unique().Each(validateTag()) ``` An unbound rule set is also usable directly, which is what makes it testable without a request: ```go if err := validateTag().Check("Not A Tag"); err == nil { t.Error("want a spaced tag to be rejected") } ``` ## Cross-field rules `in` is right there, so a cross-field rule is ordinary Go. ```go // A confirmation field. v.String(&in.Confirm).Equal(in.Password).Message("must match the password") // A condition that reads better as a rule. v.When(in.Role == "admin" && in.Age < 21). Reject(&in.Role, "an admin must be at least 21") // The same, naming a translation rather than fixing the wording in English. v.When(in.Price < in.Cost). RejectKey(&in.Price, "errors.item.price_below_cost") // A condition that reads better as an if. if in.Start.After(in.End) { v.Reject(&in.End, "must not be before the start") } ``` `When` returns a condition, and several failures can hang off one test: ```go v.When(in.Recurring). Reject(&in.Interval, "is required for a recurring booking"). Reject(&in.EndsAt, "is required for a recurring booking") ``` ## Messages in another language Every rule's wording is translated when the application has a locale configured, and nothing in the model says so. A failure reports the rule it came from as well as its English, and the wording is looked up under that rule at render time. ```yaml [core/locales/es.yml] es: errors: messages: blank: "es obligatorio" too_short: one: "debe tener al menos 1 carácter" other: "debe tener al menos %{count} caracteres" ``` `Message` fixes the wording in one language on purpose, and clears the rule with it: a sentence you wrote is the sentence you meant. `MessageKey` names a translation instead, and the rule's own values still fill it in, so `%{count}` works exactly as it does in the built-in message. ```go v.String(&in.Password).MinLen(12).MessageKey("errors.password.too_short") ``` The full picture, including the four scopes a message is looked up under, is in [Internationalization](/docs/fundamentals/internationalization). ## Nested models ```go type CreateOrderIn struct { Customer CustomerIn `json:"customer"` Address AddressIn `json:"address"` } func (in *CreateOrderIn) Validate(v *muzak.Validation) { v.Nested(&in.Customer) v.Nested(&in.Address) } func (in *AddressIn) Validate(v *muzak.Validation) { v.String(&in.City).Trim().Required().MaxLen(64) v.String(&in.PostCode).Trim().Upper().Required().Matches(`^[0-9]{5}$`) } ``` A failure on the nested model's `City` field is reported as `address.city`. A nil pointer is skipped, so an optional nested model needs no guard of its own. A rule may also name a member of a struct the model holds by value, without nesting it: ```go func (in *CreateOrderIn) Validate(v *muzak.Validation) { v.Number(&in.Price.Amount).Positive() } ``` Its failures are reported under the member's path, `price.amount`, and the document narrows that member for this use of the struct alone, so another type holding the same struct is not held to the rule. A struct held behind a pointer lives outside the model, so its members are validated by nesting it with `Nested` instead. A model nested once per element of a collection is named by its position, and is nested by the address of the element itself: ```go func (in *CreateOrderIn) Validate(v *muzak.Validation) { for i := range in.Items { v.Nested(&in.Items[i]) } } ``` A failure on the third item's `Name` is reported as `items[2].name`. Ranging over the values (`for _, item := range in.Items { v.Nested(&item) }`) validates a copy, whose transforms never reach the collection and whose position cannot be traced. `Nested` validates the model when it is called, transforms included, rather than after the outer `Validate` returns, so code after it sees the nested model's transforms applied. Its failures are still listed after the outer model's own. `Nested` that would validate nothing is a build error. `v.Nested(in.Addr)` with a value instead of a pointer was skipped without a word, and a model whose `Validate` has a value receiver bound its rules to a copy, so `{"addr":{"city":""}}` passed a `Required()` city and its transforms were lost. Both are refused when the route is compiled, for the input and for every model it nests. Pass the model's address, `v.Nested(&in.Addr)`, and declare `Validate` on the pointer receiver. ### Bounded reports Validation work and output are bounded, because the length of a report is otherwise chosen by the client. A report holds at most `muzak.MaxValidationDetails` failures (100). A full report ends with one further detail of kind `too_many_problems`, and nothing is evaluated after it, so the limit bounds the work as well as the response: correct these and send the request again to see the rest. A model nested past `muzak.MaxNestedDepth` (32) levels is not validated. It is refused, as a failure of kind `too_deep` on the field that holds it, so the request is not passed on with part of it unchecked. A recursive model that must go deeper bounds its own depth with a rule before it nests. Clients that match field names should expect the indexed form, `items[3].name`. ## Validating what did not come from a body Rules bind to the field, not to the location, so a header is checked exactly as a query parameter is and its failure is reported against the name the client actually sent. ```go [schemas/feed.go] func (in *FeedIn) Validate(v *muzak.Validation) { v.String(&in.SaveData).Trim().Lower().OneOf("on", "off") v.String(&in.Traceparent).Matches(`^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$`). Message("must be a W3C trace context, such as 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") v.Slice(&in.Tags).MaxItems(5).Each(validate.String().MaxLen(24)) v.String(&in.SessionID).Trim().MinLen(4).MaxLen(64) v.Number(&in.Limit).Between(1, 100) } ``` Transforms run before the checks, which is what lets `Save-Data` be compared against a single spelling in the handler afterwards. ## What the client sees Rule failures merge into the same envelope as binding failures, with the location the binder already established, and every field is reported at once. The exception is a body that failed to decode: the decoder stops at the first member it cannot read, so the model holds part of a body nobody sent, and its rules would report members the client did send as missing. That failure is reported alone, with any path, query, header or cookie parameter that failed beside it, and the model's rules run once the body decodes. ```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": "tags", "location": "body", "issue": "must not repeat fine" }, { "field": "limit", "location": "query", "issue": "must be between 1 and 100" } ] }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` The field name is derived from the same struct tag the binder used, so a query parameter and a body member are reported the same way. `As(name)` overrides it for a field whose tag reads worse than its purpose: ```go v.String(&in.DOB).As("date_of_birth").Required() ``` ## Rules feed the documentation The constraints a rule set declares become JSON Schema keywords, so the OpenAPI document carries the limits the code actually enforces. | Rule | Keyword | |---|---| | `Required()` | membership of `required`, for a JSON body member that has no `default` | | `MinLen` / `MaxLen` | `minLength` / `maxLength` | | `Min` / `Max` / `Between` | `minimum` / `maximum` | | `MultipleOf` | `multipleOf` | | `MinItems` / `MaxItems` | `minItems` / `maxItems` | | `Unique()` | `uniqueItems` | | `OneOf(...)` | `enum`, which includes `null` for a pointer that nothing requires, since a nil pointer skips its rules | | `Matches(p)` | `pattern`, the Go (RE2) expression as it stands | | `Email()` / `URL()` / `UUID()` | `format` of `email`, `uri`, `uuid` | | `Each(...)` | the array's `items` | ```json "role": { "type": "string", "enum": ["admin", "editor", "viewer"] }, "tags": { "type": "array", "maxItems": 10, "uniqueItems": true, "items": { "type": "string", "maxLength": 20, "pattern": "^[a-z0-9-]+$" } } ``` The document cannot drift from the validation, because both are read from the same declaration. A `Must` rule is opaque by nature and contributes nothing. A field named by several rule sets, or by several rules of one kind in one chain, is held to all of them, and so is its schema: the tightest of each bound, only the values every `OneOf` list allows, and, where there is more than one pattern, format or multiple, each of them under `allOf`. Describing only the last let through what the others refuse. ```go v.String(&in.Code).Matches(`^[A-Z]+$`).Matches(`^.{3}$`) ``` ```json "code": { "type": "string", "allOf": [{ "pattern": "^[A-Z]+$" }, { "pattern": "^.{3}$" }] } ``` A request body's `required` list follows the same declaration, at every depth: a member is listed only when a `Required()` rule refuses a body without it, because the decoder accepts a body that leaves any member out. Every object a request is read into also says `additionalProperties: false`, because the decoder refuses a member it does not know at any depth, unless the route uses `AllowUnknownFields`. See [OpenAPI](/docs/fundamentals/openapi#request-bodies) for what that does to a type used as both a request and a response. `Clamp` is not described as a range, because it moves a value into range rather than rejecting one outside it. The document says less than the server enforces for the rules with no JSON Schema keyword (`Prefix`, `Suffix`, `Contains`, `NotBlank`, `MinBytes`, the date rules and others), and never more. A JSON body member that is not a pointer still accepts `null`, which the decoder reads as the zero value, and the schema does not list `null` for it. ## Turning validation off for a route ```go r.Post("/admin/repair", handlers.RepairRecord, muzak.SkipValidation()) ``` Reach for this only where a route must accept input the model itself would reject, such as an administrative endpoint that repairs bad data. ## The full example ::code-group ```go [schemas/users.go] // CreateUserIn is the JSON body for creating a user. // // It is the fullest example in this service: transforms, a custom rule, a // cross-field comparison and a collection with per-element rules. type CreateUserIn struct { Username string `json:"username" doc:"Lower case letters, digits and underscores"` Email string `json:"email" doc:"Where we reach the user"` Password string `json:"password" doc:"At least 12 characters"` Confirm string `json:"confirm_password"` Age int `json:"age"` Role string `json:"role" doc:"One of admin, editor or viewer"` Tags []string `json:"tags,omitzero"` Website *string `json:"website,omitzero"` } // Validate declares what a valid signup looks like. func (in *CreateUserIn) 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"). NotOneOf("admin", "root", "support"). Message("is reserved") v.String(&in.Email).Trim().Lower().Required().Email() v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword) v.String(&in.Confirm).Equal(in.Password).Message("must match the password") v.Number(&in.Age).Required().Between(18, 120) v.String(&in.Role).Required().OneOf("admin", "editor", "viewer") v.Slice(&in.Tags).MaxItems(10).Unique().Each(validateTag()) v.String(&in.Website).URL() // A cross-field rule is an ordinary Go expression over the model's fields. v.When(in.Role == "admin" && in.Age < 21). Reject(&in.Role, "an admin must be at least 21") } ``` ```go [handlers/users.go] // CreateUser registers a user. // // By the time this runs the model has been validated, so the handler can take // the input at face value: the email is trimmed and lower-cased, the password // is long enough, and the role is one of the three it is allowed to be. func CreateUser(ctx *muzak.Context, in schemas.CreateUserIn) (schemas.UserOut, error) { return schemas.UserOut{Username: in.Username, Email: in.Email}, nil } ``` :: ## Where to go next [Error Handling](/docs/getting-started/error-handling) covers the envelope these failures arrive in, and [OpenAPI](/docs/fundamentals/openapi) covers the document the constraints reach. -------------------------------------------------------------------------------- title: "API Versioning" description: "Serve several versions of an API from one application, with the version read from the path, a header, the Accept header or a function of your own." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/versioning" -------------------------------------------------------------------------------- # API Versioning Versioning is off until `AppOptions.Versioning` names a type. Once it is on, a route says which version or versions it answers, and a route that says nothing answers nothing. ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", Versioning: muzak.VersioningOptions{Type: muzak.VersioningURI}, }) app.Get("/cats", handlers.ListCatsV1, muzak.WithVersion("1")) app.Get("/cats", handlers.ListCatsV2, muzak.WithVersion("2")) app.Get("/health", handlers.Health, muzak.WithVersion(muzak.VersionNeutral)) ``` ```bash curl http://localhost:8080/v1/cats # ListCatsV1 curl http://localhost:8080/v2/cats # ListCatsV2 curl http://localhost:8080/health # Health, at its plain path curl http://localhost:8080/v3/cats # 404 ``` `AppOptions.Version` and `AppOptions.Versioning` are different things. The first is the API's own version string in the OpenAPI document; the second is how a request says which version of the API it wants. ## Choosing a type ```go muzak.VersioningOptions{Type: muzak.VersioningURI} ``` | Type | The version comes from | Needs | |---|---|---| | `VersioningNone` | nowhere. Versioning is off, and `WithVersion` may not be used anywhere | | | `VersioningURI` | the request path, such as `/v1/cats` | | | `VersioningHeader` | a request header | `Header` | | `VersioningMediaType` | a parameter of the `Accept` header, such as `application/json;v=2` | `Key` | | `VersioningCustom` | a function of your own | `Extractor` | `VersioningURI` is the one a new application should reach for first. It is the only type where the version is part of routing rather than something read off a request, which is what makes it visible in a URL, cacheable by anything in front of the service, and representable in the generated document. ## Declaring what a route answers `WithVersion` is a shared option, so it works on a route, on a router, and at the point one router is included into another. ```go [routers/cats.go] func Cats() *muzak.Router { // Every route below answers version 1 unless it says otherwise. r := muzak.NewRouter(muzak.WithTags("cats"), muzak.WithVersion("1")) r.Get("/cats", handlers.ListCats) r.Get("/cats/{id}", handlers.ReadCat) // This one is only in version 2. r.Post("/cats/{id}/adopt", handlers.AdoptCat, muzak.WithVersion("2")) return r } ``` A route's own declaration **replaces** what it inherited rather than adding to it, exactly as `Status` replaces a router's declared default. `AdoptCat` above answers version 2 and not version 1. ### Several versions at once ```go // One handler, two versions. Nothing changed between them. r.Get("/cats", handlers.ListCats, muzak.WithVersion("1", "2")) ``` Calling `WithVersion` with no arguments at all is a build error, since there would be nothing left for the declaration to mean. ### Version-neutral routes ```go r.Get("/health", handlers.Health, muzak.WithVersion(muzak.VersionNeutral)) ``` `VersionNeutral` answers every request regardless of the version it names, including a request that names none. Under `VersioningURI` a neutral route keeps its plain path, with no version segment inserted, so the route above is reached at `/health` and **not** at `/v1/health`. It cannot be combined with another version in the same call, because it already answers every request the narrower version would. ## The strict default A route that resolves no version at all answers no request while versioning is enabled. It is not a build error, and it is not served unversioned either: an application has to opt a route into being version-independent deliberately, with `VersionNeutral`, rather than by forgetting. The build says so rather than leaving you to find out: ``` 14:32:07.488 WARN [Router] GET /cats declares no version and no default version applies; it will answer no request while versioning is enabled ``` Where most routes share one version, say so once instead of on every route: ```go muzak.VersioningOptions{ Type: muzak.VersioningURI, DefaultVersion: []muzak.Version{"1"}, } ``` `DefaultVersion` applies to any route or router that declares none of its own. Setting it while `Type` is still `VersioningNone` is a build error, because it could not mean anything. ## URI versioning The version is inserted into the path of every route that declares one. ```go muzak.VersioningOptions{Type: muzak.VersioningURI} ``` | Declaration | Path | |---|---| | `r.Get("/cats", h, muzak.WithVersion("1"))` | `/v1/cats` | | `r.Get("/cats", h, muzak.WithVersion("1", "2"))` | `/v1/cats` and `/v2/cats` | | `r.Get("/health", h, muzak.WithVersion(muzak.VersionNeutral))` | `/health` | A route naming more than one version is registered once per version, each at its own path, because here the version is part of routing rather than something matched once a request arrives. ### The prefix `v` by default, so version `1` is reached at `/v1`. `Prefix` is a `*string`, which is what lets an empty prefix be told from an unset one. ```go prefix := "ver-" muzak.VersioningOptions{Type: muzak.VersioningURI, Prefix: &prefix} // version "1" is reached at /ver-1/cats ``` ```go none := "" muzak.VersioningOptions{Type: muzak.VersioningURI, Prefix: &none} // version "1" is reached at /1/cats ``` ### Versions come before router prefixes The version segment is prepended to the route's **fully resolved** path, after every `WithPrefix` has already been applied. ```go r := muzak.NewRouter(muzak.WithVersion("1")) r.Get("/cats", handlers.ListCats) app.Include(r, muzak.WithPrefix("/api")) ``` ``` /v1/api/cats ``` Not `/api/v1/cats`. If you want the version inside a prefix, put the prefix in the route paths themselves and leave the include unprefixed, or use one of the header-based types where the path is left alone entirely. ## Header versioning ```go muzak.VersioningOptions{ Type: muzak.VersioningHeader, Header: "X-API-Version", } ``` ```go app.Get("/cats", handlers.ListCatsV1, muzak.WithVersion("1")) app.Get("/cats", handlers.ListCatsV2, muzak.WithVersion("2")) ``` ```bash curl http://localhost:8080/cats -H 'X-API-Version: 1' # ListCatsV1 curl http://localhost:8080/cats -H 'X-API-Version: 2' # ListCatsV2 curl http://localhost:8080/cats -H 'X-API-Version: 3' # 404 curl http://localhost:8080/cats # 404, no version named ``` The path is left alone, so more than one route may share it, as long as their versions never overlap. Two routes at one method and path that both answer version `1` are reported as registered twice when the application is built. A header sent on several lines is one list and names **no** version, so it matches no route rather than the first line's. The response names the header in `Vary`, so a shared cache does not hand the answer for one version to a client asking for another. A request naming no version at all is answered only by a route registered `VersionNeutral`. ## Media type versioning ```go muzak.VersioningOptions{ Type: muzak.VersioningMediaType, Key: "v=", } ``` ```bash curl http://localhost:8080/cats -H 'Accept: application/json;v=2' ``` `Key` names the parameter to look for among those of the `Accept` header. It may be written with or without its `=`, and is matched without regard to case. Every `Accept` line is read, and each range is read as a media range: a quoted value is unquoted, and text that is not a media range at all, such as a bare `v=1`, names no version. Of the ranges that carry the parameter, the one with the highest `q` picks the version, the first written among equals, and a range refused with `q=0` is ignored. A proxy or firewall that parses `Accept` the standard way now agrees with the application about which version a request asked for, and so which guards it reached. Send one `Accept` range per version, in the form `application/json;v=2`. An `Accept` header with no such parameter names no version, so only a `VersionNeutral` route answers it. The response names `Accept` in `Vary`. ## Custom versioning ```go muzak.VersioningOptions{ Type: muzak.VersioningCustom, Extractor: core.VersionsFromHeader, } ``` ```go [core/versioning.go] // VersionsFromHeader reads a comma-separated list of acceptable versions, // most preferred first, so a client can say "2 if you have it, otherwise 1". func VersionsFromHeader(r *http.Request) []string { raw := r.Header.Get("X-Versions") if raw == "" { return nil } return strings.Split(raw, ",") } ``` ```bash curl http://localhost:8080/cats -H 'X-Versions: 3,2,1' # answered by version 2 ``` The extractor returns versions in order from most to least preferred, and the first one some route actually answers wins. Empty strings are dropped, and returning nil or an empty slice reports a request that named no version at all. Header and media type versioning add their header to `Vary` themselves, but an extractor may read anything, so under `VersioningCustom` the application declares it: install a middleware with `App.Use` that adds the header the extractor reads to `Vary` on every response, for example `w.Header().Add("Vary", "X-Versions")`. `VersioningCustom` is the only type that can offer several versions in one request. It is also where a version that is not a plain string belongs: a date, a build number, or a value read from a query parameter or a cookie. ## How a request is matched 1. The path and method are resolved as they always are. For `VersioningURI` that has already settled the version, since it is part of the path. 2. For every other type, the version or versions the request declares are extracted. 3. Each requested version is tried in order, from most to least preferred, against every route registered for that method and path. 4. If nothing matched, a route registered `VersionNeutral` answers. 5. If nothing answers, the request gets the same `404` a path that matches nothing gets. ```json { "error": { "code": "not_found", "message": "no route matches GET /cats", "status": 404 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` A version nobody serves is a `404` rather than a `406`, because as far as routing is concerned the request named something that does not exist. An application that never enables versioning pays nothing for any of this: there is exactly one route per method and path, and it is dispatched without a version ever being resolved. ## Errors reported when the application is built | Problem | Reported as | |---|---| | `WithVersion` used while `Type` is `VersioningNone` | a version is declared but versioning is not enabled | | `WithVersion()` with no versions | `WithVersion` was called with no versions | | `VersionNeutral` combined with another version | `VersionNeutral` cannot be combined with another version | | Two routes at one method and path with overlapping versions | is registered twice | | `DefaultVersion` set while `Type` is `VersioningNone` | `DefaultVersion` is set but `Type` is not | | `VersioningHeader` with no `Header` | `Header` must name a header for header versioning | | `VersioningMediaType` with no `Key` | `Key` must be set for media type versioning | | `VersioningCustom` with no `Extractor` | `Extractor` must be set for custom versioning | All of them arrive together, with everything else `Build` reports. ## Versioning and the generated document **Operation identifiers stay unique**, which is what a client generator needs. | Scheme | Declaration | Identifier | |---|---|---| | URI | `r.Get("/cats", h, WithVersion("1"))` | `get_v1_cats`, derived from the versioned path | | URI | with `OperationID("listCats")` and versions `1`, `2` | `listCats_1` and `listCats_2` | | Header, media type, custom | `r.Get("/cats", h, WithVersion("1"))` | `get_cats_1` | | Any | `WithVersion(muzak.VersionNeutral)` | no suffix | **URI versioning describes every version.** Each one is a distinct path, so `/v1/cats` and `/v2/cats` are two entries in the document and both appear in the documentation UI. **The other schemes describe one.** Several routes then share one path and method, and an OpenAPI path item has a single slot per method, so the last registration wins and the earlier versions are absent from the document. Nothing about routing changes; only the description does. That is the practical argument for `VersioningURI` whenever the generated document matters. Where a header-based scheme is required anyway, the options are to keep the document to the current version and describe the older ones elsewhere, or to generate one document per version by building an application per version: ```go [cmd/openapi/main.go] for _, version := range []muzak.Version{"1", "2"} { app := buildApp(version) // includes only the routers for that version document, err := app.Document() // ... write it to openapi-v{version}.json } ``` ## Inspecting what was registered `Route.Versions` holds the versions a route resolved to, from `WithVersion` or from `DefaultVersion`. It is empty for an application that never enables versioning, and also, deliberately, for a route that answers nothing because neither it nor anything it was declared under named a version. The fields are filled in when the application is built, so read them after `Build`. ```go cats := routers.CatsV1() app.Include(cats) if err := app.Build(); err != nil { log.Fatal(err) } for _, route := range cats.Routes() { log.Printf("%s %s versions=%v", route.Method, route.Path, route.Versions) } ``` ``` GET /cats versions=[1] GET /cats/{id} versions=[1] ``` `Routes()` reports the routes registered directly on the router it is called on, excluding those of any router included into it, so hold on to the routers you want to inspect rather than asking the application for everything. Two details are worth knowing when reading those paths. They carry every inherited prefix once the application is built, and under `VersioningURI` they are the paths *before* the version segment is inserted: the versioned clones the router actually dispatches are the framework's, not the ones this list holds. ## A version per package Once two versions differ in more than a handler, give each its own router and let `main` mount both. The schemas stay separate, which is the point: a version exists so that the old shape can keep working while the new one changes. ::code-group ```go [routers/cats_v1.go] func CatsV1() *muzak.Router { r := muzak.NewRouter(muzak.WithTags("cats"), muzak.WithVersion("1"), muzak.Deprecated()) r.Get("/cats", handlers.ListCatsV1, muzak.Summary("List cats")) r.Get("/cats/{id}", handlers.ReadCatV1, muzak.Summary("Read a cat")) return r } ``` ```go [routers/cats_v2.go] func CatsV2() *muzak.Router { r := muzak.NewRouter(muzak.WithTags("cats"), muzak.WithVersion("2")) r.Get("/cats", handlers.ListCatsV2, muzak.Summary("List cats")) r.Get("/cats/{id}", handlers.ReadCatV2, muzak.Summary("Read a cat")) r.Post("/cats/{id}/adopt", handlers.AdoptCat, muzak.Summary("Adopt a cat")) return r } ``` ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: settings.AppName, Version: "2.0.0", Addr: settings.Addr, Versioning: muzak.VersioningOptions{Type: muzak.VersioningURI}, }) app.Include(routers.CatsV1()) app.Include(routers.CatsV2()) // Operational routes belong to no version. app.Include(routers.Meta(), muzak.WithVersion(muzak.VersionNeutral)) ``` :: `Deprecated()` on the version 1 router marks every operation beneath it as deprecated in the document and changes nothing at runtime, which is the honest way to announce a removal before making it. Retiring a version is then deleting one `Include` and one package. ## Testing versioned routes ```go [handlers/cats_test.go] func TestCatsByVersion(t *testing.T) { client := testclient.New(t, buildApp()) client.Get("/v1/cats"). AssertStatus(http.StatusOK). AssertJSON(`{"cats":[{"name":"Plumbus"}]}`) client.Get("/v2/cats"). AssertStatus(http.StatusOK). AssertJSON(`{"cats":[{"name":"Plumbus","adopted":false}]}`) // A version nobody serves is a 404, the same as any unmatched path. client.Get("/v3/cats").AssertStatus(http.StatusNotFound) // A version-neutral route keeps its plain path under URI versioning. client.Get("/healthz").AssertStatus(http.StatusOK) } ``` ```go func TestCatsByHeader(t *testing.T) { client := testclient.New(t, buildHeaderVersionedApp()) client.Get("/cats", testclient.Header("X-API-Version", "1")).AssertStatus(http.StatusOK) client.Get("/cats", testclient.Header("X-API-Version", "9")).AssertStatus(http.StatusNotFound) // No version named at all, and no neutral route to fall back to. client.Get("/cats").AssertStatus(http.StatusNotFound) } ``` A test that every route answers something is worth more than usual here, because a route that answers nothing is a log line rather than a build error: ```go func TestEveryRouteAnswersSomething(t *testing.T) { app := muzak.New(appOptions()) // Hold on to each router, because Routes() reports only what was // registered directly on the router it is called on. all := []*muzak.Router{routers.CatsV1(), routers.CatsV2(), routers.Meta()} app.Include(all[0]) app.Include(all[1]) app.Include(all[2], muzak.WithVersion(muzak.VersionNeutral)) if err := app.Build(); err != nil { t.Fatalf("Build = %v", err) } for _, r := range all { for _, route := range r.Routes() { if len(route.Versions) == 0 { t.Errorf("%s %s answers no version", route.Method, route.Path) } } } } ``` ## Where to go next [Routers](/docs/getting-started/routers) covers the prefixes and options a version declaration layers on top of, and [OpenAPI](/docs/fundamentals/openapi) covers the document each version produces. -------------------------------------------------------------------------------- title: "Logging" description: "Structured logging that is readable in development and parseable in production, with scopes, request identifiers and secrets redacted by key." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/logging" -------------------------------------------------------------------------------- # Logging Muzak logs through `log/slog`. The handler it installs picks its format by looking at where its output is going: the aligned console format on a terminal, one JSON object per record anywhere else. ``` 14:32:07.482 INFO [Server] Starting Muzak application... 14:32:07.492 INFO [Server] Listening on :8080 14:32:10.114 INFO [Request] GET /items/ status=200 duration=412µs bytes=181 request_id=0611f4b2 14:32:10.115 INFO [UsersService] user created user_id=42 request_id=0611f4b2 ``` Colour is used only when the output is a terminal and `NO_COLOR` is unset. ## Reaching the logger ```go // The application's logger. log := app.Logger() ``` ```go // Inside a handler, the same logger with method, path and request_id on every // record, which joins a line the handler writes to its access log line. func ListItems(ctx *muzak.Context, _ muzak.Empty) (schemas.ItemListOut, error) { ctx.Logger().Info("listing items") // ... } ``` ## Scopes Give each subsystem its own scope so its lines line up in the console and can be filtered in production. ```go log := muzak.Scoped(app.Logger(), "UsersService") log.Info("user created", "user_id", 42) ``` ``` 14:32:10.115 INFO [UsersService] user created user_id=42 ``` The scope is an ordinary attribute under `muzak.ScopeKey`, which the console handler lifts out of the attribute list and renders as the bracketed column after the level. `Scoped` returns the logger unchanged when it is nil, so it is safe to call on an application that has not been built yet. The framework uses four scopes of its own, so its lines can be told apart from yours at a glance: | Constant | Value | Covers | |---|---|---| | `ScopeServer` | `Server` | Start-up, listening, shutdown, panics | | `ScopeRouter` | `Router` | Route registration and the routing table | | `ScopeRequest` | `Request` | The per-request access log | | `ScopeDocs` | `Docs` | The OpenAPI document and the documentation UI | ## Configuring it ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", LoggerOptions: muzak.LoggerOptions{ Level: slog.LevelDebug, Format: muzak.LogFormatConsole, }, }) ``` | Field | Default | What it does | |---|---|---| | `Level` | `slog.LevelInfo` | The minimum level emitted. Pass a `*slog.LevelVar` to change it while the process runs | | `Format` | `LogFormatAuto` | `Auto`, `Console`, `JSON` or `None` | | `Output` | `os.Stderr` | Where records are written, which keeps logs out of a program's data output | | `Color` | terminal and `NO_COLOR` | Forces ANSI colour on or off for the console format | | `TimeFormat` | `15:04:05.000` | The timestamp layout in the console format | | `ScopeWidth` | `16` | The column reserved for the bracketed scope. A longer scope pushes the message right rather than being truncated | | `AddSource` | off | Records the source file and line. It costs a stack walk per record | | `RedactKeys` | `DefaultRedactedKeys` | Replaces the redaction list | | `ShortRequestID` | on | Truncates request identifiers to eight characters in the console format only | ### Formats | Value | Behaviour | |---|---| | `LogFormatAuto` | Console when the output is an interactive terminal, JSON otherwise | | `LogFormatConsole` | Always the aligned, optionally coloured console format | | `LogFormatJSON` | Always one JSON object per record | | `LogFormatNone` | Discards every record. The fastest option, and what tests use to keep output clean | `ShortRequestID` never applies to JSON output, where the full identifier is always written. #### Auto, in a container A container's stdout is not a terminal, so a service run under Docker or Compose gets JSON even on a laptop. That is the right default for a production container and a surprise in front of `docker compose logs`. Name the format to read it there, and ask for colour separately, since `Color` follows its own terminal check: ```yaml [docker-compose.yml] environment: # Both, or the output is JSON with no colour. Production sets json. LOG_FORMAT: console LOG_COLOR: "true" ``` ```go [core/logging.go] muzak.LoggerOptions{ Format: muzak.LogFormatConsole, Color: &colour, // *bool, so "unset" and "off" stay different things } ``` ### Changing the level at runtime ```go var level slog.LevelVar app := muzak.New(muzak.AppOptions{ Title: "Awesome API", LoggerOptions: muzak.LoggerOptions{Level: &level}, }) // Later, from a handler or a signal: level.Set(slog.LevelDebug) ``` ## Bringing your own logger ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Logger: slog.New(myHandler), }) ``` `AppOptions.Logger` takes precedence, and `LoggerOptions` is then unused. To build Muzak's own handler outside an application, call `muzak.NewLogger`: ```go log := muzak.NewLogger(muzak.LoggerOptions{Format: muzak.LogFormatJSON}) ``` The result is safe for concurrent use, redacts sensitive attributes, and performs no formatting work for records below its level. ## Redaction Credentials reach logs by accident far more often than by design, most often through an attribute carrying a whole header map. An attribute whose key **contains** one of these terms has its value replaced with `muzak.RedactedPlaceholder`, which is `[redacted]`: ``` authorization cookie password passwd secret token api-key private-key signature credential session jwt bearer passphrase pwd dsn connection-string access-key signing-key master-key encryption-key hmac totp otp-code basic-auth auth-header x-auth ``` ```go log.Info("calling upstream", "api_key", key) ``` ``` 14:32:10.115 INFO [Upstream] calling upstream api_key=[redacted] ``` Both the key and each term are lower-cased with `-`, `_`, `.` and spaces removed before they are compared, and a key is redacted when its normalised form contains a term. So `token` catches `access_token`, `X-CSRF-Token` and `github_token`, `password` catches `db_password`, `api-key` catches `X-Api-Key`, and `session` catches `session_id`. Matching by containment errs towards hiding on purpose: a key such as `token_count` is redacted too, because a count missing from a log costs far less than a credential written into one. A key that these now hide and that is not a secret can be kept by passing `RedactKeys` without the term, or renamed. `auth`, `pass`, `sig` and `otp` are left out of the list because each sits inside a common word that is not a secret (`author`, `passenger`, `design`, `hot_path`). The terms that stand for them are the specific spellings above. A secret under a shorter name is added through `RedactKeys`. A key names a group as well as a single value. A group whose key matches, whether it was built with `slog.Group`, produced by a `slog.LogValuer` or opened with `WithGroup`, has every value inside it redacted, in the JSON format and the console format alike. The keys inside a map are matched too. When an attribute's value is an `http.Header`, `url.Values`, `map[string][]string` or `map[string]string`, each matching entry has its value replaced in a copy, leaving the caller's map untouched, so `slog.Any("headers", r.Header)` is written with `Authorization` and `Cookie` hidden and every other header intact. A struct, a pointer or another map is not looked inside; log the fields you need rather than a whole request or configuration value. Replace the list with one of your own: ```go muzak.LoggerOptions{ RedactKeys: append(muzak.DefaultRedactedKeys, "internal_user_id"), } ``` Passing an empty, non-nil slice disables redaction entirely, which is only appropriate when nothing sensitive can reach the logger. Redaction covers attribute values, not message text. `log.Info("token is " + token)` is still a leak, and no key-based rule can catch it. ## The access log One line per request, written by the middleware Muzak installs by default. ``` 14:32:10.114 INFO [Request] GET /items/ status=200 duration=412µs bytes=181 request_id=0611f4b2 ``` ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", AccessLogOptions: muzak.AccessLogOptions{ Level: slog.LevelInfo, SkipPaths: []string{"/healthz", "/metrics"}, }, }) ``` Successful responses use `Level`. Server errors are always logged at error level and client errors at warn level, so a quiet production level still surfaces failures. When a route answered, the line also carries its template under `route`, such as `/items/{item_id}`, which is what `muzak.RouteFromContext` returns. A HEAD answered by a GET route is named after that GET route, in the line and in `RouteFromContext`, rather than carrying no route at all. 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. A request whose response had started when it failed, and so aborted the connection, carries `aborted=true`. When the application traces requests, the access log line and every record written through `ctx.Logger()` also carry `trace_id` and `span_id` (`muzak.TraceIDKey` and `muzak.SpanIDKey`), so a log store can jump from a line to its trace and back. With tracing off the records are unchanged. See [Observability](/docs/fundamentals/observability). `DisableAccessLog` removes the middleware entirely. The probes `AppOptions.Health` serves are answered ahead of this middleware and of anything installed with `App.Use`, and write a line of their own at `HealthOptions.AccessLogLevel`, debug by default, so a platform probing every few seconds does not drown real traffic. `DisableAccessLog` and `SkipPaths` silence them as well. See [Health Checks](/docs/deployment/health-checks). ## What a log line can hold Every record is exactly one line, and no request can change that. The message carries the decoded request path, so `%0a` in a URL used to forge log lines, and an escape character drove the operator's terminal. In the console format, control characters, DEL, C1 controls, U+2028, U+2029, bidirectional controls and invalid UTF-8 are escaped. The JSON format escapes the same set (C1 controls, DEL and bidirectional controls included) as `\uXXXX`, which a JSON reader decodes to the same text, in messages, keys and values logged with `slog.Any`. A path or a method is quoted at most 1 KiB long in the access, error and panic logs, and in the `404` and `405` messages. A recovered panic's value is rendered with `fmt` and cut to 4 KiB with a `...(truncated)` marker; the `panic` attribute is a string in the JSON format for every kind of value. A malformed form body is not written to the error log at all, because its parse error quoted the bad part header whole. ## Request identifiers Every request is assigned one, echoed in the `X-Request-Id` response header, copied into the `request_id` member of an error body, and recorded under `muzak.RequestIDKey` on every log line the request produces. ```go // Inside a handler. ctx.RequestID() ``` ```go // In code that has only a context.Context, such as a repository or a client // wrapper that wants to propagate the identifier downstream. id, ok := muzak.RequestIDFromContext(ctx) ``` A client built with `muzak.NewClient` already does that downstream: its default `Propagate`, `muzak.DefaultPropagate`, copies the identifier into the `X-Request-Id` header of every request made with the handler's context, without replacing one the caller set. See [HTTP Client](/docs/techniques/http-client). The console handler shortens values under that key to their first eight characters, which keeps a development session narrow while JSON output keeps the identifier in full. Turn it off with `ShortRequestID`. Identifiers are UUID version 7, which sort chronologically, so a log store's index on the field is a time index for free. ## Logging from a goroutine that outlives the request A `Context` is pooled and reused, so it must not be captured. Pass `ctx.Context()` instead and copy out whatever else you need first. The logger `ctx.Logger()` returns may be kept: it holds copies of the request's attributes and nothing of the `Context`, so the goroutine's lines still carry the request identifier of the request that started it. ```go func Enqueue(ctx *muzak.Context, in JobIn) (JobOut, error) { log := muzak.Scoped(ctx.Logger(), "Jobs") requestCtx := ctx.Context() id := in.ID go func() { if err := process(requestCtx, id); err != nil { log.Error("job failed", "job_id", id, "error", err.Error()) } }() return JobOut{ID: id}, nil } ``` For work the client should not wait for, `ctx.AfterResponse` runs a task on a bounded pool once the response is written, with a context that keeps the request's values, so `RequestIDFromContext` still answers, and that a shutdown drains. See [Background Tasks](/docs/techniques/background-tasks). ## Quiet logs in tests ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone}, }) ``` ## Where to go next [Error Handling](/docs/getting-started/error-handling) covers what reaches the log when a request fails, and [Testing](/docs/fundamentals/testing) covers exercising an application in-process. -------------------------------------------------------------------------------- title: "Observability" description: "A server span per request named after its route, W3C trace context in and out, logs joined to traces, a request metrics hook, and an OTLP exporter, all without a dependency." version: "0.3.1" last_updated: "2026-10-10T11:16:23.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/observability" -------------------------------------------------------------------------------- # Observability Three questions come up about a service in production: what happened to this request, how slow is this endpoint, and which log lines belong together. Muzak answers all three without a dependency. Tracing records a span per request and carries the W3C trace context across services, the access log and the request logger carry the trace, and a request observer feeds whatever metrics library you already use. Every part of it is off until it is configured, and off means free: no header is parsed, no identifier is drawn and nothing is allocated on any request. ## Turning tracing on Tracing is on when `AppOptions.Tracing` names a `Tracer`. The one most services want is the OTLP exporter in `muzak.dev/framework/otlp`, which sends spans to any OpenTelemetry collector (Jaeger, Tempo, Honeycomb, Grafana Cloud, Datadog's OTLP intake and so on). ::code-group ```go [cmd/main.go] exporter, err := otlp.New(otlp.Options{ Endpoint: settings.OTLPEndpoint, ServiceName: "shop", Headers: otlp.Headers{"Authorization": "Bearer " + settings.OTLPToken}, Gzip: true, }) if err != nil { log.Fatal(err) } app := muzak.New(muzak.AppOptions{ Title: settings.AppName, Tracing: muzak.TracingOptions{Tracer: exporter}, }) ``` ```go [core/config.go] type Settings struct { // OTLPEndpoint is the collector's OTLP/HTTP base URL. Spans are posted // to it followed by /v1/traces. OTLPEndpoint string `env:"OTLP_ENDPOINT" default:"http://localhost:4318"` // OTLPToken authenticates to a hosted collector. The exporter sends it // as a header and never logs it; secret keeps it out of a config error. OTLPToken string `env:"OTLP_TOKEN" secret:"true"` } ``` :: The exporter is also a lifecycle component, and naming it as the `Tracer` is enough for the application to start it before the first request and stop it after the last. Stopping it sends what is still queued. Registering it with `WithLifecycle` as well does no harm; it is started once. See [Lifecycle](/docs/getting-started/lifecycle) for the order components start and stop in. `Sampler`, `Parent` and `RecordUserAgent` only mean something beside a `Tracer`, so setting any of them without one is a build error rather than an option silently ignored. ## The server span Every request gets one span, of kind server, named after its method and route template: ``` GET /orgs/{org_id}/apps/{app_id}/keys ``` **Never after its path.** The path carries identifiers, which makes one span name per request, and often personal data. A request no route answers, a 404, is named after its method alone. A method no standard defines, which a client can make up per request, is written `HTTP` in the name unless a route registered for that very method answered it. A handler mount answers every method a client sends, so through one such a method is `HTTP /debug/pprof`, named after the mount's prefix. A `HEAD` that a `GET` route answers is named after that route. The span carries the OpenTelemetry HTTP attributes: | Attribute | Value | |---|---| | `http.request.method` | The method, or `_OTHER` for one no standard defines that no route registered for it answered | | `http.route` | The route template, or a handler mount's prefix, when one answered | | `http.response.status_code` | The status sent: `101` for a WebSocket, the status already on the wire for an aborted response | | `url.scheme` | `http` or `https`, as this server was spoken to | | `server.address`, `server.port` | From the `Host` header, when it is a valid host name or address | | `network.protocol.version` | `1.0`, `1.1`, `2` or `3` | | `user_agent.original` | Only with `RecordUserAgent`, cut to 1 KiB | | `error.type` | On a failed span: the Go type of the cause, `panic`, the status code, or `_OTHER` when none of them says | `url.path` and `url.query` are deliberately absent, for the reason the name avoids the path. A handler that wants them on a span adds them itself. ### When it is an error | Outcome | Span status | |---|---| | A 1xx, 2xx, 3xx or 4xx | Unset. A 404 is the client's mistake, not the server's failure | | A 5xx | Error, described by the status code | | A response aborted after it started | Error: `the response was aborted after it had started` | | An event stream or WebSocket handler that failed | Error: `the handler failed after the response had started` | A failure the log records is added to the span as an `exception` event with `exception.type` and `exception.message`, carrying the same text the log line does and nothing more. A panic is recorded as type `panic` with the framework's own message; its value and stack stay in the log. ### When it ends After the response has been written. For an event stream that is when the stream ends, and for a WebSocket when the connection closes, so the span measures how long the client was served (see [Server-Sent Events](/docs/realtime/server-sent-events) and [WebSockets](/docs/realtime/websockets) for what ends each). A handler that takes the connection over ends its span when it returns. ## Trace context A request carrying a `traceparent` continues that trace: the server span gets the same trace identifier, records the caller's span as its parent, and inherits its sampling decision. The `tracestate` travels with it. Both are read strictly, as the W3C recommendation writes them: - `traceparent` version `00` is exactly 55 characters of lowercase hex and dashes. Uppercase hex, an identifier of all zeroes, version `ff` and two `traceparent` headers are refused, and the request starts a new trace. - A later version is read forward-compatibly: its first 55 characters as version `00`, and anything after them must start a new field with a dash. It is written onward as version `00`. - `tracestate` is refused whole, and the parent kept, when it has more than 32 members, is longer than 512 bytes, repeats a key, or has one key or value outside the grammar. It is never read unless the `traceparent` was accepted. Identifiers this server draws come from `crypto/rand`, so nobody who sees one trace can guess the next. ### Whose trace to believe A client choosing its own trace identifier can attach its requests to someone else's trace, and a client setting the sampled flag decides for you that every one of its requests is recorded, whatever your `Sampler` says. Inside a system that traces end to end that is what you want; at the edge it is not, and the default believes every caller. A service the internet reaches directly sets `Parent`: ```go muzak.AppOptions{ ClientIP: muzak.ClientIPOptions{TrustedProxies: []string{"10.0.0.0/8"}}, Tracing: muzak.TracingOptions{ Tracer: exporter, Parent: muzak.TraceParentFromTrustedProxies, }, } ``` | `Parent` | A request's `traceparent` is continued | |---|---| | `TraceParentAccept` (default) | Always, when valid | | `TraceParentFromTrustedProxies` | Only when the connection came from an address in `ClientIP.TrustedProxies` | | `TraceParentIgnore` | Never | A `traceparent` that is not believed is not even parsed. `TraceParentFromTrustedProxies` is only as good as the proxy. Many load balancers pass a client's `traceparent` on unchanged, and with it the client's choice, so the proxy must replace or remove the header; where it cannot, use `TraceParentIgnore`. [Behind a Proxy](/docs/deployment/behind-a-proxy) covers naming the proxies in `ClientIP.TrustedProxies`. ### Sampling A trace this server starts is sampled unless a `Sampler` says otherwise. A trace it continues keeps its caller's decision, and the sampler is not asked. ```go muzak.TracingOptions{ Tracer: exporter, Sampler: muzak.SampleRatio(0.1), } ``` `SampleRatio` decides from the trace identifier, so every service sampling at the same ratio makes the same decision for the same trace. A request that is not sampled still has identifiers, which are propagated with the sampled flag cleared and written to the logs, but the `Tracer` is never called for it. A `Sampler` is a function of the request and the trace identifier, so it can also leave out requests nobody wants a span for, such as a platform's health probes: ```go ratio := muzak.SampleRatio(0.1) sampler := func(r *http.Request, trace muzak.TraceID) bool { if r.URL.Path == muzak.DefaultLivenessPath || r.URL.Path == muzak.DefaultReadinessPath { return false } return ratio(r, trace) } ``` ### Calling another service A client built with `muzak.NewClient` carries the trace along by itself: its default `Propagate`, `DefaultPropagate`, writes the current span as the parent beside the request identifier, and leaves a `traceparent` you set alone. See [HTTP Client](/docs/techniques/http-client). Any other client calls `InjectTraceContext`: ```go func ChargeCard(ctx *muzak.Context, in schemas.ChargeIn) (schemas.ChargeOut, error) { req, err := http.NewRequestWithContext(ctx.Context(), http.MethodPost, paymentsURL, body) if err != nil { return schemas.ChargeOut{}, err } muzak.InjectTraceContext(req.Context(), req.Header) // ... } ``` `InjectTraceContext` writes the current span as the parent, with its sampling decision and its `tracestate`, and removes a `tracestate` already in the headers when the trace has none. A context that carries no span, which is every context when tracing is off, leaves the headers as they are. `SpanContextFromContext` returns the same identity for anything that is not an HTTP request: a message published to a queue, a row in an audit table. A tool call made through the application's [MCP endpoint](/docs/techniques/mcp-tools) is served in-process as a child of the MCP request's span, so the route that answers it joins the same trace. ## Spans of your own ```go func GetOrder(ctx *muzak.Context, in schemas.OrderParams) (schemas.OrderOut, error) { // Add what the handler knows to the server span. muzak.SpanFromContext(ctx.Context()).SetAttributes(slog.String("tenant.id", in.Tenant)) // A child span, recorded through the same Tracer. queryCtx, span := muzak.StartSpan(ctx.Context(), "load order", muzak.SpanKindClient, slog.String("db.system.name", "postgresql")) defer span.End() order, err := orders.Load(queryCtx, in.ID) if err != nil { span.SetStatus(muzak.SpanStatusError, "the order could not be loaded") return schemas.OrderOut{}, err } return order, nil } ``` Attributes are `slog.Attr`, so what a handler logs and what it records on a span are built the same way. With tracing off, `StartSpan` returns the context unchanged and a span that records nothing, without allocating, so code written to trace costs nothing where it is not configured. The server span is the framework's to end; a span you start is yours. ## Logs that join the trace With tracing on, the access log and every record written through `ctx.Logger()` carry the trace and the server span: ``` {"level":"INFO","msg":"GET /orders/42","scope":"Request","status":200,"duration":412000,"bytes":181,"request_id":"0611f4b2...","route":"/orders/{id}","trace_id":"0af7651916cd43dd8448eb211c80319c","span_id":"b7ad6b7169203331"} ``` The keys are `muzak.TraceIDKey` and `muzak.SpanIDKey`. An unsampled request logs its identifiers too, which is how a log line is still joined to the requests around it. See [Logging](/docs/fundamentals/logging). ## Request metrics `AppOptions.Observer` is told about every request once its response is written. It is the hook a Prometheus histogram or an OpenTelemetry meter is fed from, and Muzak needs no dependency on either. ::code-group ```go [core/metrics.go] // RequestMetrics records a histogram of request durations per endpoint. type RequestMetrics struct { duration *prometheus.HistogramVec } func (m *RequestMetrics) ObserveRequest(o muzak.RequestObservation) { m.duration.WithLabelValues(o.Method, o.Route, strconv.Itoa(o.Status)). Observe(o.Duration.Seconds()) } ``` ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: settings.AppName, Observer: core.NewRequestMetrics(prometheus.DefaultRegisterer), }) ``` :: | Field | What it holds | |---|---| | `Method` | The method, or `_OTHER` for one no standard defines that no route registered for it answered, which includes every such method a handler mount answered | | `Route` | The route template or a handler mount's prefix, and empty when neither answered: a 404, a 405, an `OPTIONS` answered from the route table, the documentation, a frontend, a health probe | | `Status` | The status sent | | `Duration` | From the request reaching the application to the response finishing | | `RequestSize` | Bytes of the request body the server read | | `ResponseSize` | Bytes of the response body written, before compression by middleware outside the application | | `Aborted` | The connection was cut after the response started | | `SpanContext` | The server span, when tracing is on, for an exemplar | Every field a metric could be labelled by has a bounded set of values, so labelling by all of them cannot grow a time series per request. The path is not in the observation at all. The observer is called **exactly once** per request: a 404, a 405, a handler that failed or panicked, a response that was aborted, an event stream when it ends and a WebSocket when it closes. [Health probes](/docs/deployment/health-checks) are answered after it, so each one is observed, and traced, as a request no route answered: a metric labelled by route counts them under the empty route, and a `Sampler` can leave them out of the trace by path, as above. It runs on the request's goroutine before the connection is handed back, so keep it quick and safe for concurrent use. `muzak.RequestObserverFunc` adapts a plain function. ## A Tracer or observer that panics Both run on the request path, above panic recovery. A `Tracer` that panics costs the request its span, a sampler that panics leaves the trace unsampled, and an observer that panics misses one observation; each is logged with its stack, and the client sees nothing. ## The OTLP exporter `muzak.dev/framework/otlp` sends spans over OTLP/HTTP with the JSON encoding, and imports nothing but the standard library and the framework. | Option | Default | What it does | |---|---|---| | `Endpoint` | required | The collector's base URL; spans go to `/v1/traces` under it. `http` or `https` only, and a URL with credentials in it is refused | | `ServiceName` | `unknown_service:` | The `service.name` resource attribute | | `ServiceVersion` | none | The `service.version` resource attribute | | `ResourceAttributes` | none | Further resource attributes, such as `deployment.environment.name` | | `Headers` | none | Sent with every request. Validated when the exporter is built, never logged | | `Gzip` | off | Compresses every body | | `Timeout` | 10s | Bounds one attempt | | `QueueSize` | 2048 | Ended spans waiting to be sent; at most 1048576 | | `BatchSize` | 512 | Spans per request | | `BatchInterval` | 5s | How long a partial batch waits | | `RetryInitialInterval` | 1s | The first wait before a retry | | `RetryMaxInterval` | 30s | The longest wait, a `Retry-After` included | | `RetryMaxElapsedTime` | 1m | How long one batch is retried for | | `Client` | its own | For a proxy, a custom CA or a client certificate. A copy is used, which never follows a redirect. Its transport is shared with whatever else uses it, so `Stop` leaves its idle connections alone | | `Logger` | `slog.Default()` | Where refusals and drops are reported | ### What it promises a request Nothing it does is waited on. A span that ends goes into a bounded queue; when the queue is full the span is dropped and counted, never waited for. One goroutine sends the batches. ### What it promises the collector - A batch answered with `429`, `502`, `503` or `504`, or that could not be delivered at all, is retried with exponential backoff and jitter. A `Retry-After` that asks for longer than the backoff lengthens the wait, up to `RetryMaxInterval`; one that asks for less, zero included, never shortens it, so a collector cannot have a batch sent again without pause. Every other answer, every other 4xx and 5xx included, is final. - An answer is read up to 64 KiB, so a collector that sends more holds nothing. - A redirect is not followed, because it would carry the headers, credentials included, to wherever it pointed. It counts as a refusal. - A partial success, a collector accepting the request and rejecting some spans in it, is counted and its message logged. ### Stopping `Stop` sends what is queued within its context's deadline. A retry the exporter is waiting to make is made when it falls due before the deadline, and given up at once when it does not. When the deadline passes, the request in flight is abandoned, nothing more is sent, and `Stop` returns an error saying how many spans were lost. Either way it returns only once the exporter's goroutine has exited and, for an exporter on a client of its own, that client's idle connections are closed. Spans the collector refused are logged and counted, but are not an error of `Stop`'s. Calling `Stop` again returns at once. `Flush` sends what is queued without stopping, and returns `otlp.ErrNotRunning` for an exporter that is not running. ### Knowing what it did ```go stats := exporter.Stats() // stats.Exported: accepted by the collector // stats.Dropped: never sent (queue full, stopped, or out of time at shutdown) // stats.Failed: sent and refused, given up on, or rejected one by one // stats.Queued: waiting ``` Drops are logged at most once per `BatchInterval`, with the count, however many there are. ### Bounds on one span A span is your code's to fill, so it is bounded: 128 attributes, 128 events with 128 attributes each, strings and names cut to 4 KiB, lists to 128 elements, nesting to four levels, and 64 KiB in all. The 64 KiB counts every value a span keeps, not only its text, so a list of numbers or of empty strings is charged for what it occupies. What does not fit is dropped and counted in `droppedAttributesCount` and `droppedEventsCount`, which is how OpenTelemetry reports a limit being reached. The queue's memory is therefore bounded by `QueueSize` times about 64 KiB: the span's own name, status description and `tracestate` are held beside the budget, which adds a few KiB at most. ### What it does not do It does not read the `OTEL_EXPORTER_OTLP_*` environment variables, speak gRPC or protobuf, or export metrics or logs. Request metrics are the observer's. ## Bringing another tracer `Tracer` and `Span` are small interfaces, so an adapter to another SDK is a short file. ```go type Tracer interface { StartSpan(ctx context.Context, start muzak.SpanStart) muzak.Span } type Span interface { SetName(name string) SetAttributes(attrs ...slog.Attr) AddEvent(name string, attrs ...slog.Attr) SetStatus(code muzak.SpanStatusCode, description string) End() } ``` Muzak decides a span's identity before the `Tracer` sees it: `SpanStart.SpanContext` already holds identifiers drawn from `crypto/rand`, and those are what is propagated and logged. A `Tracer` records them rather than choosing its own. `StartSpan` runs on the request's goroutine and is only called for a sampled trace; it must be safe for concurrent use and must not block on the network. A `Tracer` or an observer that also implements `Lifecycle` is started and stopped with the application. ## Limits - The default `Parent`, `TraceParentAccept`, lets any client choose its trace and force its requests to be sampled. Set `TraceParentFromTrustedProxies` or `TraceParentIgnore` on a service the internet reaches, as above. - An application that failed to build answers every request with a `500` ahead of its chain, so those requests are neither traced nor observed. `Run` refuses to start such an application; this only matters when it is served through `ServeHTTP`. - One exporter belongs to one application. Named as the `Tracer` of two, it is stopped by the first one to shut down, and the other's spans are not sent after that. Build one per application. ## Where to go next [Logging](/docs/fundamentals/logging) covers the access log and the request logger the trace is written to, [Behind a Proxy](/docs/deployment/behind-a-proxy) covers naming the proxies whose `traceparent` is believed, [HTTP Client](/docs/techniques/http-client) covers the client that carries the trace to the next service, [Middleware](/docs/getting-started/middleware) covers where the tracing middleware sits in the chain, and [Lifecycle](/docs/getting-started/lifecycle) covers how the exporter is started and stopped. -------------------------------------------------------------------------------- title: "OpenAPI" description: "An OpenAPI 3.1 document and a self-contained documentation page, derived from the routes, the types and the validation rules you already wrote." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/openapi" -------------------------------------------------------------------------------- # OpenAPI Every Muzak application describes itself. The OpenAPI 3.1 document is served at `/openapi.json` and a documentation page at `/docs`, both derived from the registrations themselves: path templates, tags, categories, titles, summaries, the schemas of the `In` and `Out` types, the declared statuses, the validation constraints, the security schemes you declare and the entries added by `WithResponseDoc` and `WithResponseModel`. All of that reflection happens once, while the application is being built. Nothing on the request path inspects a type. ## Describing the API `AppOptions` embeds `OpenAPIOptions`, so its fields are set inline. ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", Description: "The example service, rebuilt on Muzak.", TermsOfService: "https://example.com/terms", Contact: &muzak.Contact{ Name: "API team", Email: "api@example.com", URL: "https://example.com/support", }, License: &muzak.License{ Name: "Apache 2.0", Identifier: "Apache-2.0", }, Servers: []muzak.Server{ {URL: "https://api.example.com", Description: "production"}, {URL: "http://localhost:8080", Description: "development"}, }, Addr: ":8080", }) ``` `Title` defaults to `Muzak API` and `Version` to `0.1.0`. `Description` is rendered as CommonMark by documentation tools. In OpenAPI 3.1 a licence carries either an `Identifier` or a `URL`, not both. With no `Servers` at all, tools treat the document's own origin as the server. ## Where it is served | Option | Default | Effect | |---|---|---| | `DocsUI` | nil | The page to serve. With none, the document is published and no page is | | `DocsPath` | `/docs` | Where that page is served | | `OpenAPIPath` | `/openapi.json` | Where the document is served | | `DisableDocs` | off | Serves neither, for a deployment that must not describe itself | ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", DocsUI: ui.Files(), DocsPath: "/reference", OpenAPIPath: "/reference/openapi.json", }) ``` Both paths are absolute and matched exactly. Where they ended up is the last thing the server says as it starts, as a URL you can open: ``` INFO [Server] Listening on [::]:8080 scheme=http INFO [Docs] Documentation at http://localhost:8080/reference openapi=http://localhost:8080/reference/openapi.json ``` A socket bound to every interface is reported as `localhost`, because `[::]` is where the process listens rather than somewhere a browser can go. With no `DocsUI`, the line names the document alone and says how to add a page; with `DisableDocs` set, nothing is announced and neither path is registered. Three ways of configuring a path that could not work are build errors rather than a page nobody can reach: ``` muzak: AppOptions.DocsPath is "reference", which is not an absolute path and so would answer no request; write it as "/reference" muzak: AppOptions.DocsPath and AppOptions.OpenAPIPath are both "/docs", but the page and the document it reads need an address each muzak: AppOptions.DocsPath is "/docs", which a route of this application already answers; move one of the two, or set AppOptions.DisableDocs to serve no documentation at all ``` That last one matters: the documentation is matched before the router, so without the check a route of your own at `/docs` would be shadowed silently. `DocsPath` is only checked when a page is actually served there, so an application that configures no `DocsUI` may use it for a route of its own, and one that sets `DisableDocs` may use both paths. The document, the page and its assets answer GET and HEAD. Any other method is answered `405` with `Allow: GET, HEAD`, rendered by the error renderer as a route's `405` is, so in the application's error envelope, or as a problem under `ProblemDetails`. On guarded documentation the rate limit and the guards run first, so a client the guards refuse learns nothing more from a `POST` than from a `GET`. ### Guarding the documentation The guards and providers given to `muzak.New` run in front of the document, the page and its assets too, so an application-wide token covers the description of the API as well as the API. A refusal is rendered as it would be for a route, and a guarded response is sent `Cache-Control: private, no-cache`. Guards and providers declared on a router or a route do not apply: the documentation belongs to the application, not to a router. A `WithSecurity` given to `muzak.New` covers the documentation in the same way when it names a verifying scheme (see [Declaring how a route authenticates](#declaring-how-a-route-authenticates)): a request for the document or the page without a valid credential is refused before anything is served. The document and the page also count against the application's rate limit, in the same budget the routes spend. Without that, `/openapi.json` behind a token would be an endpoint anyone could guess the token against as fast as they could send. To exempt them, pass `muzak.SkipRateLimit()` to `muzak.New`, or serve no documentation with `DisableDocs`. To keep the documentation public while the API is private, declare the guard where the routers are included rather than on `New`: ```go app := muzak.New(muzak.AppOptions{Title: "Awesome API", DocsUI: ui.Files()}) app.Include(routers.Items(), muzak.WithDependencies(core.GetTokenHeader(settings))) ``` ## The documentation dashboard Generating the document is the framework's job. Rendering it is not, and a service that wants no page should not carry one, so the dashboard is a module of its own: ```bash go get muzak.dev/openapi ``` ```go import ( "muzak.dev/framework" "muzak.dev/openapi/ui" ) app := muzak.New(muzak.AppOptions{ Title: "Awesome API", DocsUI: ui.Files(), }) ``` Go downloads and links a module only when something imports it, so leaving `DocsUI` unset costs a binary nothing at all rather than embedding a page it will never serve. Adding it costs about two megabytes, and they are in the binary rather than fetched from anywhere: nothing is downloaded at run time, in either direction. The page fetches no script, stylesheet, font or image from a third party, and the only requests it makes are for this application's own document and, from the console, this application's own routes. That is what lets it run under a content security policy that hashes its own inline script and allows no network access beyond this origin. An air-gapped deployment gets working documentation with no further setup. What it gives a reader: - Operations grouped in a sidebar tree by **category** (`x-category`, else the first tag), each row labelled by the route's **title** (`x-title`, else its path), with a **Tags** index beneath for the labels that cut across categories, and a filter over all of it. See [Grouping operations](#grouping-operations). - Each schema as an outline that expands, carrying the constraints the application actually enforces, beside a generated example. - A **console** on every operation: fill in the parameters, the JSON body or the multipart form, send the request from the page, and read the status, timing, size, headers and body. An event stream is read as it arrives and stopped when you have seen enough. - Request snippets in nine languages, and **copy as curl** for the requests the page cannot make itself, such as one to another origin. - **Authorize**, which adds a bearer token, an API key header or basic credentials to what the console sends, for the schemes the document declares (see [Declaring how a route authenticates](#declaring-how-a-route-authenticates)). They are held in the tab, never stored, and forgotten when the page is closed. - A light, dark or system theme. The page, its assets and the document are rendered, hashed and compressed once while the application is built, so a request for any of them is a header write and a copy of bytes that never change. Each carries an `ETag`, so a reader who reloads transfers nothing, and a client that accepts gzip gets a fraction of the bytes. ### Serving a dashboard of your own `DocsUI` takes any `fs.FS`, so the dashboard above is one implementation of a small contract rather than the only choice: - an `index.html` at the root of the file system; - every absolute URL in it written under `/__muzak_docs__/`, which is rewritten to `DocsPath` when the application is built; - the OpenAPI document fetched from `/__muzak_spec__`, rewritten to `OpenAPIPath`. No other file may carry an absolute URL, because only the page is rewritten. Assets are served beneath `DocsPath`, so the whole tree moves with it. ## Grouping operations There are two ways to group operations, and they do different jobs. A **tag** says what an operation is about, and several add up. A **category** files a router, or a single route, under one heading. With a hundred routers a list of tags is unreadable in a sidebar, and a tag is the wrong tool for it, because an operation that carries three of them appears three times. ### Tags A router's tags are inherited by every route beneath it: ```go r := muzak.NewRouter(muzak.WithTags("items")) ``` ```go app.Include(routers.Admin(), muzak.WithPrefix("/admin"), muzak.WithTags("admin")) ``` A route can name a tag of its own, which **adds to** what it inherited rather than replacing it, so the operation is documented under every group it names: ```go r.Post("/", handlers.AdminAction, muzak.Status(http.StatusCreated), muzak.WithTags("audit"), muzak.Summary("Admin action")) ``` ```json "tags": ["admin", "audit"] ``` Duplicates are removed while first-seen order is preserved. ### Describing a tag Naming a tag is enough to group by it, but a group with no explanation and no place in the running order is a heading and nothing more. `OpenAPIOptions.Tags` gives it both: ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Tags: []muzak.Tag{ {Name: "items", Description: "Everything the catalogue holds."}, {Name: "admin", Description: "Operations that need a staff token."}, {Name: "audit", Description: "Anything that leaves a trace."}, }, }) ``` Described tags lead, in the order they are declared. A tag only a route names follows, in the order the routes named it. A tag described here that no route carries is left out rather than rendering as an empty group, so deleting the last route in a group also removes the group. ### Categories and titles `muzak.WithCategory` is a shared option, so it works at both levels: every route beneath a router inherits its category, and a single route may set its own. `muzak.Title` is a route option beside `Summary`, giving a route a human name for the sidebar to list in place of its path. ```go billing := muzak.NewRouter(muzak.WithCategory("Billing")) billing.Get("/users/{id}", handlers.Profile, muzak.Title("Fetch User Profile")) billing.Get("/invoices", handlers.Invoices, muzak.Title("List Invoices")) billing.Get("/reports", handlers.Reports, muzak.WithCategory("Reports")) ``` Unlike tags, a category is a single value and never a list. Many routers may share one, and a router that is included into another, or a route itself, **replaces** the category it would have inherited rather than adding to it. A route no category reaches has none and is listed as it always was. Tags are untouched: an operation may carry both, and tags behave exactly as above. Both reach the document as vendor extensions on the operation, written only where one was set, and the document carries one list at its root: ```json "/users/{id}": { "get": { "operationId": "get_users_by_id", "x-category": "Billing", "x-title": "Fetch User Profile" } }, "x-categories": ["Billing", "Reports"] ``` `x-categories` names every category some operation in the document carries, each once, in the order they were first registered. A route counts where it was declared and a router included into another counts where the `Include` call was made, so a hundred routers list in the order the application mounted them and not in the sorted order of their paths. A category met only on a hidden route is left out. The category and the title are also exposed on the registered route as `Route.Category` and `Route.Title`, and generic, WebSocket and event stream routes carry them like any other. The documentation dashboard files each operation under its `x-category`, falling back to its first tag and then to `default`, orders the groups by `x-categories`, and labels a row with `x-title`, falling back to the path. A document with none of the three is grouped and sorted by tag as before, and an application that sets neither option publishes the same document it always did. Both values are checked when the application is built, and the error names the route, or the router by its mount prefix. After surrounding whitespace is trimmed the value must not be empty, must be valid UTF-8 of at most 64 characters for a category and 120 for a title, and must hold no control character, line break or bidirectional control, because it is shown as one line. Two spellings of a category that differ in case are two categories, and titles need not be unique. ## Documenting one operation ```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.WithResponseDoc(http.StatusConflict, "An item with that identifier already exists")) ``` `Summary` is the one-line description shown beside the operation. `Description` is the long-form one, carried verbatim and rendered as CommonMark. ### Operation identifiers Client generators use the operation identifier to name the method they emit. Without one, Muzak derives it from the method and path: `GET /items/{item_id}` becomes `get_items_by_item_id`. ```go r.Get("/items/{item_id}", handlers.ReadItem, muzak.OperationID("readItem")) ``` Identifiers must be unique across the application. A collision is reported when the application is built. The framework's own generators use it too. [TypeScript](/docs/fundamentals/typescript) names the types it writes for an operation after it, and an [MCP endpoint](/docs/techniques/mcp-tools) names the tool it offers for a route after it, so `OperationID` also chooses the name an MCP client calls the route by. With versioning enabled, a version is folded into the identifier so that two versions of one operation stay distinct: `get_v1_cats` under URI versioning, `get_cats_1` under the header-based schemes, and `listCats_1` where an explicit `OperationID("listCats")` was given. See [Versioning](/docs/fundamentals/versioning). ### Hiding and deprecating ```go r.Get("/healthz", handlers.Health, muzak.Hidden(), muzak.SkipRateLimit()) ``` `Hidden` leaves a route fully routable but out of the document and the documentation page, which is what a health check or an internal endpoint wants. `Deprecated` marks an operation as no longer recommended and changes nothing at runtime. Both are shared options, so a whole router can carry either. A platform's liveness and readiness probes need no route of your own: `AppOptions.Health` serves `/livez` and `/readyz` ahead of routing, outside the document, and takes readiness down when a shutdown begins. See [Health Checks](/docs/deployment/health-checks). ## Declaring how a route authenticates A guard is a function. Muzak can run it and cannot read it, so the document could never say whether a route wants a bearer token, an API key or a session cookie, and every guarded operation looked public. Say so once, and the document carries it: the dashboard then has something to offer under **Authorize**, and a client generator knows which operations need credentials. Declare the schemes by name in `AppOptions.SecuritySchemes`, then say which one a router or a route sits behind with `WithSecurity`: ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", SecuritySchemes: map[string]muzak.SecurityScheme{ "bearer": muzak.BearerAuth("JWT"), "key": muzak.APIKeyHeader("X-API-Key"), "oauth": muzak.OAuth2(muzak.OAuthFlows{ AuthorizationCode: &muzak.OAuthFlow{ AuthorizationURL: "https://auth.example.com/authorize", TokenURL: "https://auth.example.com/token", Scopes: map[string]string{"items:read": "Read items"}, }, }), }, }) admin := muzak.NewRouter( muzak.WithSecurity(muzak.Require("bearer")), muzak.WithDependencies(requireStaff), // the guard that actually refuses ) admin.Get("/report", handlers.Report) admin.Get("/status", handlers.Status, muzak.Public()) items := muzak.NewRouter() items.Get("/", handlers.ListItems, muzak.WithSecurity(muzak.Require("oauth", "items:read"), muzak.Require("key"))) ``` The constructors cover the common cases: | Constructor | Describes | |---|---| | `BearerAuth(format)` | A bearer token in `Authorization`. The format, such as `"JWT"`, is a hint and may be empty | | `BasicAuth()` | HTTP Basic | | `HTTPAuth(scheme)` | Any other registered HTTP scheme, such as `"digest"` | | `APIKeyHeader(name)`, `APIKeyQuery(name)`, `APIKeyCookie(name)` | An API key in a header, a query parameter or a cookie. A key in a URL is written to logs and browser history, so prefer a header | | `OAuth2(OAuthFlows{...})` | OAuth 2.0, with the flows and scopes it offers | | `OpenIDConnect(url)` | OpenID Connect, discovered at the URL | A scheme the constructors do not reach can be written as a `muzak.SecurityScheme` literal. Two more constructors describe a scheme in the same way and also verify it: `JWTBearer`, documented as `BearerAuth("JWT")` is, and `APIKeyVerifier`, documented as the `APIKey` constructor for its location is. [Authentication](/docs/security/authentication) covers them. `Require(scheme, scopes...)` is one requirement. Several requirements given to `WithSecurity` are **alternatives**: a client satisfies any one of them. A requirement that needs several schemes together is written as a literal, `muzak.SecurityRequirement{"key": nil, "session": nil}`. `Public()` marks a route, or a router, that needs no credentials, which is how one route beneath a guarded router says it is an exception. It is emitted as an empty `security` list, so the document states it and does not leave it to be assumed. A declaration on a route **replaces** the one its router made rather than adding to it, because alternatives and combinations cannot be told apart once merged. The document carries `components.securitySchemes` and a `security` list on each operation that declared one; an application that declares none emits neither, exactly as before. **Whether a scheme is enforced depends on how it was built.** A descriptive scheme, built by any constructor in the table above or written as a literal, only describes. It is never consulted while a request is served, so naming one protects nothing. The route still needs the guard that refuses, and the two are only as truthful as whoever wrote both. Nothing is inferred from a guard and nothing is checked against it. A verifying scheme, built by `JWTBearer` or `APIKeyVerifier`, is enforced as the document states it. A route whose `WithSecurity` names one refuses a request that satisfies none of its requirements before its guards, its providers or its handler run, and before a `CaptureBody` route reads its body: `401` when no valid credential was presented (with a `WWW-Authenticate` challenge for a bearer token), `403` when one was but lacks a required scope, and `503` while a credential cannot be judged, such as before a key set has been fetched. Alternatives, schemes needed together and scopes are checked as written, and an empty requirement among the alternatives admits a request with no credentials, though one that presents a credential is still judged by it. The HEAD a GET route answers is covered with the route, and so are a `Mount`, `Static` or `Frontend` beneath the router and, for a declaration on `New`, the documentation. `Public()` exempts a route from a verifying scheme its router named, while the guards it inherits still run. Since a declaration replaces the one it inherited, a `Public()` or a descriptive `WithSecurity` on a route or an inner router also lifts a verifying requirement declared above it. What is checked when the application is built is that the declaration can be described and, for a verifying scheme, enforced. Each of these is a build error: - a scheme name outside `[A-Za-z0-9._-]`; - a type OpenAPI does not define; - an `http` scheme with no `Scheme`, or a `BearerFormat` on one that is not `bearer`; - an `apiKey` with no valid `In` or `Name`; - an `oauth2` scheme with no flow, or a flow without the URL its kind needs; - an `openIdConnect` URL, or a flow URL, that is not `http` or `https`; - a route naming a scheme that was not declared, or a scope an OAuth 2.0 scheme does not offer; - a `WithSecurity()` that names nothing. Use `Public()` for that; - alternatives that mix verifying and descriptive schemes, since a request satisfying the described one could not be told from one satisfying nothing; - a `muzak.Dep[*muzak.Claims]` or `muzak.Dep[*muzak.APIKeyPrincipal]` on a route whose security does not fill it on every way in. ## What the schemas are built from ### Parameters A field's location tag becomes the parameter's `in`, its `doc` tag becomes the description, its `default` tag becomes the default, and its requiredness follows the rules in [Request Data](/docs/getting-started/request-data). ```go type UserListQuery struct { Limit int `query:"limit" default:"20" doc:"Maximum number of users to return"` Cursor string `query:"cursor" doc:"Opaque cursor from a previous page"` } ``` ```json { "name": "limit", "in": "query", "description": "Maximum number of users to return", "schema": { "type": "integer", "format": "int64", "default": 20, "minimum": 1, "maximum": 100 } } ``` The default is written as the JSON type of the field, `20` and not `"20"`, for query, header, form and body members alike. It is not written on a member of a nested model or on a response type, because nothing applies it there. A parameter that has a `default` is not required, even if a `Required()` rule names it: the default is written before any rule runs, so leaving the parameter out is not a failure. A query parameter and a body member with the same name are two fields. The rules are keyed by where a field is read from as well as by its name, so a body member's `Required` or `MaxLen` never lands on the parameter of the same name, or the other way round. The `minimum` and `maximum` above were never written twice. They come from `v.Number(&in.Limit).Between(1, 100)` in the model's `Validate` method, which is why the document cannot drift from what the code enforces. The full mapping is in [Validation](/docs/fundamentals/validation). That guarantee is the reason numeric rules changed in 0.2.7. A bound that excludes zero is now applied to zero, because until then `minimum: 1` was published while the server accepted `0`. That was the one case where this document and the code did drift, and the reader who trusted the document was the one who got it wrong. ### Request bodies A named struct becomes a component and is referenced by name, so a type used by several operations is described once. ```json "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ItemCreateIn" } } } } ``` What the document lists under `required` is what the server refuses, and the decoder itself accepts a body that leaves any member out. So a member of a JSON request body is **required exactly when a `Required()` rule is declared for it** and it has no `default`. A member that no rule speaks for is optional however the Go type looks, whether it is a pointer or not. Telling a client, or a gateway that validates requests against the document, that a request the server accepts is malformed was the one place the document said more than the code did. ```go type ItemCreateIn struct { Name string `json:"name"` Note string `json:"note"` Nick *string `json:"nick"` } func (in *ItemCreateIn) Validate(v *muzak.Validation) { v.String(&in.Name).Required() v.String(&in.Nick).Required() } ``` ```json "ItemCreateIn": { "type": "object", "required": ["name", "nick"], "properties": { "name": { "type": "string" }, "note": { "type": "string" }, "nick": { "type": "string" } } } ``` `Required()` on a pointer refuses `null` as well as absence, so `nick` is required and is no longer described as nullable. A pointer nothing requires still says it may be `null`. A struct embedded as a pointer contributes its members at the top level, as optional members, which is what the decoder accepts, and not as one nullable member named for the type. That holds at every depth. An object nested in a request body requires only what the rules of a model nested with `Nested`, or a rule on a nested member's path, enforce, and nothing otherwise. The decoder also refuses a member it does not know, at every depth, so every object a request is read into says `additionalProperties: false`, unless the route uses `AllowUnknownFields`. An object that collects unknown members in an embedded map says what it takes instead, and a type that decodes itself is left open. A response is never closed, so that a member added to it later breaks no client. The response side keeps its shape, since a member that is not a pointer and not `omitempty` is always written, and a response's component cannot say `additionalProperties: false`. A type used both as a request and as a response therefore always gets two schemas: the component keeps its shape for the response, and the request refers to a copy named for it with `Input` after (`Item` and `ItemInput`). A nested type a response also uses gets its own copy (`OwnerInput`), which the request's copy of its parent refers to. A type only requests use keeps its name. Form values keep the binder's own rule, to which a rule can only add. A rule on a member of a struct the body holds by value narrows that member for this use of the struct alone. It is written beside the `$ref`, which JSON Schema 2020-12 applies together with it, so the shared component, and every other route and response that uses it, is left as it was. Here `Money` is also a response, so the request refers to `MoneyInput`: ```go func (in *RefundIn) Validate(v *muzak.Validation) { v.Number(&in.Price.Amount).Positive() } ``` ```json "price": { "$ref": "#/components/schemas/MoneyInput", "properties": { "amount": { "exclusiveMinimum": 0 } } } ``` The rules of every model a `Nested` call reaches from a zero value are written onto the schema of its type. A model nested once per element of a collection is not among them. A client generator will now type an unruled request member as optional. Declare `Required()` on the members that must be sent, and the document follows. Two types with the same name in different packages are told apart by qualifying the later one with as much of its import path as tells it apart, so `Item` in `v1/models`, `v2/models` and `v3/models` is `Item`, `models.Item` and `v3.models.Item`. Types the path cannot tell apart, as three declared inside three functions of one package are, are numbered instead. The names come out the same on every run. ### Well-known types | Go type | Schema | |---|---| | `time.Time` | `{"type": "string", "format": "date-time"}` | | `uuid.UUID` | `{"type": "string", "format": "uuid"}` | | `time.Duration` | a string with a `pattern` that agrees with `time.ParseDuration` (`"1500ms"`), and no `format` | | `[]byte` in a JSON body | a base64 string (`format: byte`, `contentEncoding: base64`) | | `[N]byte` in a JSON body | a base64 string of exactly four characters for every three bytes, rounded up, which is the only length json/v2 reads back | | a slice of a named byte type | an array, as json/v2 writes it | | `[]byte` in a query, header, cookie or form value | an array of integers from 0 to 255 | | a number tagged `json:",string"` | a string, with a `pattern` matching the number json/v2 reads from inside it | | a struct field tagged `json:",embed"` | its members, at the top level of the object holding it | | a `map[string]T` or `jsontext.Value` tagged `json:",embed"` | the object's `additionalProperties`, since it collects every member the struct does not name | | a generic type such as `Page[app.Item]` | a component keyed `Page_app.Item`, which a `$ref` can resolve | | a type implementing both `encoding.TextMarshaler` and `encoding.TextUnmarshaler` | a string | | a map | an object with `additionalProperties` | | an interface | an empty schema, which accepts any JSON value | A `time.Duration` is written as a string such as `"1500ms"` in a body, both ways. It carries no `format: duration`, because that is ISO 8601 (`PT1.5S`) in JSON Schema and the binder and the codec read `1500ms`, so a client that checks formats would have refused the value the document told it to send. A `[]byte` parameter is an array of integers because the binder reads each value of a parameter as one element: `?data=65` is `[]byte{65}`, and `?data=aGk=` is a `422`. Members are the ones json/v2 resolves. `omitempty` makes a response member optional only when its value can encode as empty: json/v2 leaves out only what encodes as `null`, `""`, `{}` or `[]`, so a number, a bool, a time and an array of at least one element are always written, and are listed as required whatever their tag says. A member that a shallower field of the same name shadows is not described, nor one that two fields at the same depth both claim, since json/v2 neither writes nor reads it. json/v2 refuses a `format:` tag option, which on a request body is a build error. A type that converts to and from text is a string everywhere it appears: in a parameter, where the binder parses it with `UnmarshalText`, and in a body, where the encoder writes it with `MarshalText`. Describing it by walking its fields would document the Go struct rather than the value on the wire. ### Responses | Route kind | What is documented | |---|---| | Ordinary | The route's declared status, carrying the `Out` schema as `application/json` | | `Out` is `muzak.HTML` | The same status, carrying `text/html` | | `Out` is `muzak.Empty` | The same status, with no content | | `Out` is `muzak.Bytes`, `muzak.Stream` or `muzak.FileResponse` | The same status, carrying a string of `format: binary` under each media type `Produces` names, or under `application/octet-stream` when it names none | | `Out` is `muzak.Redirect` | The redirect status, with no content and a `Location` header | | `Router.SSE` | `200` carrying `text/event-stream`, whose schema describes one event's data, and the `503` a draining or full server answers with | | `Router.WS` | `101 Switching Protocols`, because the conversation continues off the document, and what a handshake is refused with: `400` for a malformed one, `403` for a foreign origin (left out for a route that skips the check) and `503` for a draining or full server, beside `426` and `422` | [Files, Streams and Redirects](/docs/techniques/files-streams-redirects) covers the response types that are not JSON, and how `muzak.Produces` names the media types a binary response is sent as. Alongside that, every route that binds anything documents `422` with the error envelope, every declared outcome is added, and a `default` response carrying the error envelope is added unless one was declared. A route with a `muzak.Timeout` also documents the `503` with `Retry-After` that its deadline is answered with, unless it declared that status itself. A verifying security scheme adds no response of its own, so declare its `401` and `403` with `WithResponseDoc` where a client should read them. With `AppOptions.ProblemDetails` set, every one of those error responses is described as an RFC 9457 problem, `application/problem+json`, instead; see [Problem Details](/docs/techniques/problem-details). A declared outcome is either `WithResponseDoc(code, description)`, described as the error envelope because that is what a returned error produces, or `WithResponseModel[T](code, description)`, described as `T` exactly as an `Out` type would be. The last declaration of a status code wins, so a route replaces what it inherited from its router, and an empty description falls back to the code's standard reason phrase. A status a WebSocket or event stream route declared itself keeps its own description. See [Response Models](/docs/techniques/response-models). ### Multipart bodies A route binding `form` or `file` fields is described as `multipart/form-data`. A route that binds form values and no files at all is also described as `application/x-www-form-urlencoded`, because that is what a plain HTML form posts. ### Versioned routes Under `VersioningURI` each version is a distinct path, so `/v1/cats` and `/v2/cats` are two entries and every version is described. Under `VersioningHeader`, `VersioningMediaType` and `VersioningCustom` several routes share one path and method, and an OpenAPI path item has a single slot per method. The last registration wins, so only one version of that operation reaches the document. Routing is unaffected; only the description is. That is the practical argument for URI versioning whenever the generated document matters, and [Versioning](/docs/fundamentals/versioning) covers the alternative of generating one document per version. Under `VersioningURI`, adding a version adds paths, which no client notices, and retiring one removes them, which breaks every client still calling it. [API Compatibility](/docs/fundamentals/api-compatibility) reports both, and names the version still served when one is removed. ## Reading the document from Go ```go document, err := app.Document() if err != nil { return err } for path, item := range document.Paths { if item.Get != nil { fmt.Println("GET", path, item.Get.OperationID) } } ``` `Document` builds the application if necessary. It returns nil when documentation is disabled with `DisableDocs`, and an error when the application does not build. A document that cannot be encoded as JSON is one way not to build. The document is encoded once, while the application is built, and text that is not valid UTF-8 in a `doc` tag, a summary, a description or an example makes that fail with a build error, rather than leaving `/openapi.json` silently unserved as it used to: ``` muzak: the OpenAPI document cannot be encoded as JSON, so it could not be served: ... ``` ## Committing the document `Document.Marshal` renders indented JSON with map keys in sorted order, so the output is byte-for-byte reproducible. That is what lets a generated document be committed and diffed, and a diff in a pull request is how an accidental breaking change gets noticed. ```go [cmd/openapi/main.go] // Command openapi writes the generated OpenAPI document to standard output. package main import ( "log" "os" "awesome-api/routers" "muzak.dev/framework" ) func main() { app := muzak.New(muzak.AppOptions{Title: "Awesome API", Version: "1.0.0"}) app.Include(routers.Users()) app.Include(routers.Items()) document, err := app.Document() if err != nil { log.Fatal(err) } out, err := document.Marshal() if err != nil { log.Fatal(err) } if _, err := os.Stdout.Write(out); err != nil { log.Fatal(err) } } ``` A composition shared between `cmd/main.go` and this command is worth factoring into a function of its own, so the document is generated from exactly the application that runs. ```bash go run ./cmd/openapi > openapi.json git diff --exit-code openapi.json ``` A diff says that the document changed, not whether the change breaks a client. [API Compatibility](/docs/fundamentals/api-compatibility) compares the committed document with the one the application generates and fails a test on a change that would, through `testclient.AssertCompatible`. The [`muzak` command](/docs/getting-started/command-line) reads a document from a file, standard input or a running application: `muzak routes` lists its operations, `muzak diff` compares two documents and exits non-zero for a CI step to gate on, and `muzak ts` writes TypeScript from one. The same document is what a client generator reads: ```bash curl -s http://localhost:8080/openapi.json > openapi.json ``` For a frontend written in TypeScript, [TypeScript](/docs/fundamentals/typescript) generates the declarations, and optionally a small fetch client, from it. ## Where to go next [Validation](/docs/fundamentals/validation) covers the constraints that reach the schemas, and [Testing](/docs/fundamentals/testing) covers asserting on the document in a test. -------------------------------------------------------------------------------- title: "TypeScript" description: "TypeScript declarations, and an optional fetch client, generated from the OpenAPI document, so a frontend is typed by the same models as the Go handlers." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/typescript" -------------------------------------------------------------------------------- # TypeScript The OpenAPI document already says everything a frontend needs to know about the API: every model, every parameter, every response. The `tsgen` package turns it into a TypeScript file, so the code that calls the API is checked against the same types the Go handlers are written against, and a change to a model shows up as a type error in the frontend rather than as an `undefined` at run time. ```go import "muzak.dev/framework/tsgen" ``` `tsgen` is a package of its own, linked only into the program that imports it, and like the rest of Muzak it uses nothing outside the standard library. ## Generating the file `tsgen.Generate` takes the document and returns the file. The quickest way to run it is `muzak ts`, which reads the document from a file or a running application and writes what `Generate` returns; see [Command Line](/docs/getting-started/command-line): ```bash muzak ts -url http://localhost:8080/openapi.json -client -o web/src/api.ts ``` In a repository that commits the file, a small command beside the application does the same from the application itself, with no server running, and a test keeps the file current: ::code-group ```go [cmd/tsgen/main.go] func main() { // app.New is the constructor cmd/main.go serves, so the file describes // exactly the application that runs. doc, err := app.New().Document() if err != nil { log.Fatal(err) } ts, err := tsgen.Generate(doc, tsgen.Options{Client: true}) if err != nil { log.Fatal(err) } if err := os.WriteFile("web/src/api.ts", ts, 0o644); err != nil { log.Fatal(err) } } ``` ```go [api_test.go] // The committed file is what the application generates, or the test fails // and says how to refresh it. func TestTypeScriptIsCurrent(t *testing.T) { doc, err := app.New().Document() if err != nil { t.Fatal(err) } want, err := tsgen.Generate(doc, tsgen.Options{Client: true}) if err != nil { t.Fatal(err) } got, _ := os.ReadFile("web/src/api.ts") if !bytes.Equal(got, want) { t.Fatal("web/src/api.ts is stale; run go run ./cmd/tsgen") } } ``` :: A document another Muzak service publishes works the same way: read it with `muzak.ReadDocument` and pass it in. `ReadDocument` reads what Muzak writes and refuses the rest with an error saying what and where, so a document that is not OpenAPI 3.1, or that uses a keyword Muzak never writes, such as `oneOf`, or an extension, is refused rather than half translated. The output is deterministic. The same document gives the same bytes on every run, whatever order Go walks its maps in, so the file can be committed and its diff reviewed with the Go change that caused it. The [OpenAPI](/docs/fundamentals/openapi#committing-the-document) page covers committing the document itself. ## What is written Every component schema becomes an exported declaration named after it: an `interface` for an object, a `type` alias for anything else. ```ts export interface Pet { children: Pet[]; /** * What the pet answers to. */ name: string; owner?: Owner | null; tags?: string[]; } ``` Members are written in sorted order, as the document holds them. Every operation becomes four types, named after its `operationId`: | Type | What it holds | |---|---| | `GetPetsByIdParams` | The parameters, grouped as `path`, `query`, `header` and `cookie` | | `GetPetsByIdBody` | The request body, or `undefined` for an operation that takes none | | `GetPetsByIdResponse` | What a success answers with | | `GetPetsByIdError` | What any other status answers with | ```ts export interface ListPetsParams { query: { cursor?: string | null; kind: string; limit?: number; }; } ``` A group none of whose parameters is required is optional itself. A parameter the document describes twice, as two fields that bind one query parameter describe it, is one member whose type is the intersection of both, required if either requires it. An `Operations` interface maps every operation to its method, path and four types, for a client of your own to be typed by. ### How JSON Schema reads in TypeScript | Schema | TypeScript | |---|---| | `$ref` | The name of the type it points at, never expanded | | `enum` | A union of literals: `"cat" \| "dog"`, `1 \| 2` | | `type: ["string", "null"]`, or `anyOf` with `null` | `string \| null` | | A member not in `required` | An optional member, `name?:` | | `additionalProperties` | `Record`, or an index signature beside members | | `allOf` | An intersection | | `anyOf` | A union | | `array` | `T[]` | | `integer`, `number` | `number` | | `string` with `format: binary` | `Blob` in a form body and a response, `string` elsewhere | | A response of `text/*` | `string` | | A response of any other media type that is not JSON | `Blob` | | No content | `undefined` | A reference is always written by name, which is what makes a recursive type such as a tree work, and what keeps the file as long as the document and no longer. ## The client With `Options.Client`, the file also holds a client built on `fetch`: `createClient` returns one method per operation, named after its `operationId`, and `ApiError` is what a method rejects with when the status is not a success. ```ts import { createClient, ApiError } from "./api"; const api = createClient({ baseUrl: "https://api.example.com", headers: { Authorization: "Bearer " + token }, }); try { const pets = await api.listPets({ query: { kind: "cat", limit: 10 } }); const pet = await api.postPets({}, { name: "Tom", kind: "cat" }); } catch (err) { if (err instanceof ApiError && err.status === 404) { // err.body is the error envelope, parsed } } ``` Each method takes the operation's `Params`, its `Body` when it has one, and a `RequestInit` for anything else `fetch` accepts, such as an `AbortSignal`. JSON is sent as JSON, a multipart form as `FormData`, with a `Blob` member as a file, a form offered only URL-encoded as `URLSearchParams`, and the answer is parsed by its kind: JSON, text, a `Blob`, or nothing. A path parameter is escaped one segment at a time, and one that is empty, `.` or `..`, or that holds a `/`, throws a `TypeError` rather than reaching another route, as the Go client refuses it. The document writes a trailing `{name...}` wildcard as an ordinary parameter, so the generated client cannot tell one apart and holds every parameter to one segment; send a value with `/` in it to a wildcard with a request of your own. The path's own text is held to the same rule. The client joins it to `baseUrl` and hands the whole to `URL`, which would read a `?` or `#` as the start of a query or a fragment, a `\` as a `/`, and drop a tab or a line break, so each of those, and every other control character, is written percent-encoded. A dot segment cannot be escaped that way, since `URL` decodes `%2e` too, and could walk a request out of the `baseUrl`, so a path holding one makes `Generate` return an error when `Client` is set. Cookie parameters are typed but not sent: a browser sends its cookies itself and refuses a `Cookie` header from a script. Leave `Client` off to write the types alone and send requests with whatever client the frontend already uses, typed by `Operations`. ## Generating from a document you did not write `tsgen` is safe to run over a document from anywhere, and its output is safe to compile: - Every name is reduced to an identifier made of ASCII letters, digits and `_`, renamed when it is a reserved word, a global the file uses, such as `Record` or `Promise`, or a name the client declares for itself, such as `ApiError`, `Fields` or `Sent`, and numbered when two names reduce to the same identifier. Names are reserved whether or not the client is written, so a component is named the same either way. - A description cannot end the comment it is written into. `*/` is written `*\/`, and every line terminator JavaScript knows, `U+2028` and `U+2029` among them, starts a new comment line. - An enum value, a property name or a path cannot end the string literal it is written into: quotes and backslashes are escaped, and everything outside printable ASCII is written as a `\u` escape. - Control characters, the Unicode controls that reorder text, as a "Trojan Source" comment does, and bytes that are not UTF-8 are written as escapes, never as themselves. - Generation is bounded, and linear in what a document repeats: names that reduce to one identifier, enum values, required names, union members, responses and path parameters cost no more for being repeated. A schema nested more than 64 levels deep is an error, and so is a document built in Go whose schemas, shared in memory, would be walked more than a million times. A reference to a schema the document does not define, or to one outside it, is an error rather than a type that does not compile. ## Limits - A trailing `{name...}` wildcard is held to one segment by the generated client, as above. - Cookie parameters are typed but never sent. - A property named after a member every JavaScript object has, such as `toString`, is kept as it is, since it is a valid property name, but TypeScript then refuses to assign an object literal to the interface. Rename the member in the Go model if a frontend needs to build one. ## Where to go next [OpenAPI](/docs/fundamentals/openapi) covers the document the file is generated from, [Command Line](/docs/getting-started/command-line) the `muzak ts` command that writes it, and [Typed Endpoints](/docs/techniques/typed-endpoints) the same API called from Go. -------------------------------------------------------------------------------- title: "API Compatibility" description: "Keep the generated OpenAPI document as a baseline, and fail a test when a change would break a client written against it." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/api-compatibility" -------------------------------------------------------------------------------- # API Compatibility A committed OpenAPI document is a promise to every client written against it. Muzak compares the document your application generates now with a baseline you keep in the repository, and fails a test when a change would break one of those clients: an operation that is gone, a request member that became required, a response member that may now be `null`. ```go [api_test.go] package main import ( "testing" "muzak.dev/framework/testclient" ) func TestAPICompatibility(t *testing.T) { testclient.AssertCompatible(t, buildApp(), "testdata/openapi.json") } ``` The comparison knows how Muzak describes an API, so it judges each change the way a client meets it rather than as a difference of text. Reordering members, renaming a component or adding an optional request member passes; dropping a response member does not. ## Recording the baseline The first run fails, because there is nothing to compare with yet, and says how to create it: ``` testclient: there is no OpenAPI baseline at testdata/openapi.json; run the test once with MUZAK_UPDATE_OPENAPI=1 to record the application's current document there, and commit the file ``` ```bash MUZAK_UPDATE_OPENAPI=1 go test -run TestAPICompatibility ./... git add testdata/openapi.json ``` With `MUZAK_UPDATE_OPENAPI=1` set (exactly `1`), the test records the current document instead of comparing, and logs the breaking and possibly breaking changes the new baseline accepts, so the run that accepts a change also says what it accepted. The file is what `Document.Marshal` writes, indented with sorted keys, so its diff in a pull request is readable. The baseline is written to the path given and nowhere else. The directories it needs are created, the document is written to a temporary file beside it and renamed into place, so an interrupted run never leaves half a document behind, and a symbolic link at the path is replaced rather than followed. The file is readable by everyone (`0644`), as a file in a repository is. ## What a run reports | The change is | The test | |---|---| | Breaking | Fails, listing every breaking change | | Possibly breaking | Passes, and logs the changes for a person to judge | | Compatible | Passes, and notes that the baseline is out of date if the document differs | A failure lists each change on one line, with where it is and what kind it is: ``` testclient: the API breaks clients written against the baseline at testdata/openapi.json: Breaking (2): /components/schemas/ItemInput/properties/sku: In requests, the member "sku" is new and required, which a client written against the old document does not send. [request-property-added] /paths/~1items~1{id}/delete: The operation DELETE /items/{id} is no longer served. [operation-removed] If the change is intended, run the test with MUZAK_UPDATE_OPENAPI=1 to record the new document as the baseline. ``` When a change is intended, record the new baseline and commit it with the change. The baseline's diff is then the part of the pull request a reviewer reads to see what clients will notice. ## How a change is judged Every schema is judged by the direction it travels in. - **What a client sends may only widen.** The server has to keep accepting every request it accepted before. A new optional member, a relaxed bound or a longer enum is compatible; a new required member, a narrower type, a tighter bound, a new pattern or format, or `null` no longer being accepted is breaking. - **What a client reads may only narrow.** The client has to keep understanding every response. A member that is gone, that may now be absent, or that may now be `null`, and a changed type, are breaking; a new member is compatible, because a response is left open. So the same change can be judged both ways. A member that became optional is compatible in a request, where a client may now leave it out, and breaking in a response, where a client relied on it being there. ### Schemas Each kind starts with `request-` or `response-`, the direction it was judged in. | Kind | In a request | In a response | |---|---|---| | `property-added` | Breaking if required, compatible if optional | Compatible (possibly breaking if the object said it held no other members) | | `property-removed` | Breaking when the object refuses unknown members, possibly breaking when it ignores them | Breaking if it was required, possibly breaking if it was optional | | `property-became-required` | Breaking | Compatible | | `property-became-optional` | Compatible | Breaking | | `unknown-members-allowed` / `unknown-members-refused` | Compatible / breaking | Possibly breaking / compatible | | `type-widened` / `type-narrowed` / `type-changed` | Compatible / breaking / breaking | Breaking / compatible / breaking | | `nullable-added` / `nullable-removed` | Compatible / breaking | Breaking / compatible | | `enum-added` / `enum-removed` | Breaking / compatible | Compatible / possibly breaking | | `enum-values-added` / `enum-values-removed` | Compatible / breaking | Possibly breaking / compatible | | `minimum-`, `maximum-`, `min-length-`, `max-length-`, `min-items-`, `max-items-`, `multiple-of-tightened` | Breaking | Compatible | | the same, `-relaxed` | Compatible | Possibly breaking | | `unique-items-added` / `unique-items-removed` | Breaking / compatible | Compatible / possibly breaking | | `pattern-added` / `pattern-removed` / `pattern-changed` | Breaking / compatible / breaking | Compatible / possibly breaking / possibly breaking | | `format-added` / `format-removed` | Breaking / compatible | Compatible / breaking | | `format-widened` / `format-narrowed` / `format-changed` | Compatible / breaking / breaking | Breaking / compatible / breaking | | `content-encoding-changed` | Breaking | Breaking | | `any-of-added` / `any-of-removed` | Breaking / compatible | Compatible / breaking | | `any-of-alternative-added` / `any-of-alternative-removed` | Compatible / breaking | Breaking / compatible | | `default-added`, `default-removed`, `default-changed` | Possibly breaking | Compatible | | `deprecated-added`, `deprecated-removed` | Compatible | Compatible | | `schema-renamed` | Compatible | Compatible | A few of these deserve a sentence: - **A removed request member breaks clients** because every object Muzak reads a request into says `additionalProperties: false`: the decoder refuses a member it does not know, so a client that still sends it gets a 422. On a route registered with `AllowUnknownFields` the member is ignored instead, which is possibly breaking. - **A new value in a response enum is possibly breaking.** A client that switches over the values exhaustively, as generated TypeScript often does, breaks; one that stores the string does not. - **A relaxed response bound is possibly breaking.** Only a client that validates what it reads refuses the values it now admits. - **`int32` to `int64` is a widening.** A client generator picks the width of a number by its format, so it is compatible in a request and breaking in a response. - **A changed pattern is treated as a narrowing** in a request, since whether one regular expression admits everything another does cannot be decided in general. - **A pattern moved into `allOf`** beside a second one, which Muzak does when two rules ask for a pattern each, is compared as the one new pattern it is, not as a keyword that went away. ### Operations | Kind | Severity | |---|---| | `path-removed`, `operation-removed` | Breaking; possibly breaking when every removed operation was deprecated | | `path-added`, `operation-added` | Compatible | | `path-renamed` | Compatible: `/users/{id}` and `/users/{userID}` match the same requests | | `operation-id-changed` | Possibly breaking: a generated client renames the method | | `operation-deprecated`, `operation-undeprecated` | Compatible | | `operation-tag-added`, `operation-tag-removed`, `tag-added`, `tag-removed` | Compatible | | `parameter-removed` | Breaking: a client that sends it is ignored | | `parameter-added` | Breaking if required, compatible if optional | | `parameter-became-required` / `parameter-became-optional` | Breaking / compatible | | `parameter-location-changed` | Breaking, such as a query parameter that moved to a header | | `request-body-added` | Breaking if required, compatible if optional | | `request-body-removed`, `request-body-became-required` | Breaking | | `request-body-became-optional` | Compatible | | `request-media-type-removed` / `request-media-type-added` | Breaking / compatible | | `response-status-removed` | Breaking for a 1xx, 2xx or 3xx status; compatible for an error or `default` | | `response-status-added` | Possibly breaking for a 1xx, 2xx or 3xx status; compatible for an error | | `response-media-type-removed` / `response-media-type-added` | Breaking / compatible | | `response-header-removed` | Breaking if the header was required, possibly breaking if it was optional | | `response-header-added` | Compatible | | `response-header-became-optional` / `response-header-became-required` | Breaking / compatible | | `security-tightened` / `security-loosened` | Breaking / compatible | | `security-scheme-changed` | Breaking: clients configured for it present credentials the old way | | `security-scheme-added`, `security-scheme-removed` | Compatible | | `server-removed` / `server-added` | Possibly breaking / compatible | | `comparison-incomplete` | Breaking; see [Limits](#limits) | A parameter's value travels as text, so `?id=` going from an integer to a string widens it. A form value is compared the same way. A response header is read the way a member of the body is: one that is gone, or that may now be absent, breaks a client that relied on it, as a client relies on the `Location` of a redirect. Header names are compared without regard to case, and a header's schema is compared as a response schema, so its value may only narrow. Security is compared by what a client presents. An operation accepts any one of its requirements, and each requirement asks for every scheme it names with every scope it lists. The requirements were tightened when a client holding exactly what one old requirement asks for is no longer accepted by any new one: a route that declared nothing and now declares `WithSecurity`, a dropped alternative, or a new scope. A scheme is identified by what it asks for, its type, where the credential goes and under which name, so renaming a scheme in `SecuritySchemes` changes nothing, while moving an API key from `X-Key` to `X-Token` breaks every client configured for it. ### Item and ItemInput A type used both as a request and as a response is described twice: the component keeps the response's shape, and the request refers to a copy named `ItemInput` ([OpenAPI](/docs/fundamentals/openapi) explains why). The first time a response starts using a type that only requests used, the request body's reference moves from `Item` to `ItemInput`. The comparison follows references into components, so it compares the two schemas and finds them the same, and reports only a compatible `request-schema-renamed`. Any component renamed without changing shape is treated the same way. ### Deprecate, then remove Removing an operation is breaking. Removing one the baseline already marked deprecated is possibly breaking: clients were told to move, and the change is logged rather than failed. That makes the usual sequence two releases long: ```go // Release 1: still served, marked deprecated. r.Get("/items/{id}/legacy", handlers.LegacyItem, muzak.Deprecated()) ``` ```go // Release 2: removed. The baseline from release 1 says it was deprecated. ``` The same holds for a member of a schema marked deprecated. ### Versions Under `VersioningURI` each version is its own path, so adding `/v2/cats` beside `/v1/cats` is a compatible `path-added`, and dropping `/v1/cats` later is a breaking `path-removed` whose message names the version still served: ``` /paths/~1v1~1cats: The path "/v1/cats" is no longer served; another version of it is served at "/v2/cats". [path-removed] ``` Under the header-based schemes only one version of an operation reaches the document, as [OpenAPI](/docs/fundamentals/openapi) explains, so only that version is compared. ## Where a change is located `Location` is a JSON pointer into the older document, with `~1` for a slash in a path: | Location | Points at | |---|---| | `/paths/~1items/post` | The operation `POST /items` | | `/paths/~1items/get/parameters/query/limit` | The query parameter `limit`, by location and name rather than by its index | | `/paths/~1items/post/requestBody/content/application~1json/schema` | A schema written inline in the operation | | `/components/schemas/Item/properties/sku` | A member of a named schema | A change inside a named schema is located in components and reported once, however many operations reach it. A component used by requests and by responses can therefore carry two changes at one location, one per direction, with their own severities. ## Comparing documents from Go `AssertCompatible` is built from three functions you can call yourself, for a command that checks a pull request or compares two deployed services. `muzak diff old.json new.json` is that command already written, with `-fail-on breaking|possibly|never` to choose what fails it (see [Command Line](/docs/getting-started/command-line)). The program below shows the three functions at work. ```go [cmd/apicheck/main.go] // Command apicheck compares two OpenAPI documents and exits non-zero when // the second would break a client of the first. package main import ( "log" "os" "muzak.dev/framework" ) func main() { before := read(os.Args[1]) after := read(os.Args[2]) changes := muzak.CompareDocuments(before, after) if err := muzak.WriteChanges(os.Stdout, changes); err != nil { log.Fatal(err) } for _, change := range changes { if change.Severity == muzak.Breaking { os.Exit(1) } } } func read(path string) *muzak.Document { f, err := os.Open(path) if err != nil { log.Fatal(err) } defer f.Close() doc, err := muzak.ReadDocument(f) if err != nil { log.Fatal(err) } return doc } ``` | Function | Does | |---|---| | `muzak.ReadDocument(r)` | Reads a document Muzak wrote, strictly and within bounds | | `muzak.CompareDocuments(before, after)` | Lists every change a client of `before` can observe in `after`, most serious first | | `muzak.WriteChanges(w, changes)` | Prints them grouped by severity, one per line, escaped for a terminal | An `APIChange` carries `Severity` (`muzak.Breaking`, `muzak.PossiblyBreaking` or `muzak.Compatible`), a `Kind` from the tables above that never changes once released, a `Location` and a one-sentence `Message`. The list is sorted, so the same two documents always give the same output. ## What is not compared Prose is not compared: summaries, descriptions, titles, contact details, categories and tag descriptions change nothing a client can observe, and reporting them would bury the one change that matters. A component no operation reaches is not compared either, since it is not part of the API. `ReadDocument` reads the document Muzak writes and refuses anything else with an error that says what and where, rather than comparing a document it only half understood: a document that is not OpenAPI 3.1, a member `Document` has no field for (an extension of your own, or a keyword such as `oneOf` that Muzak never writes), a duplicate member, invalid UTF-8, a type JSON Schema does not define, a schema, response or header that is `null` where one is needed, and a `$ref` to anything but a schema in the document's own components. Nothing is ever fetched, so a reference to another file or a URL is refused rather than followed. The names an error quotes from the document have every character a terminal would act on escaped, since a failing test prints the error. ## Limits A baseline may come from anywhere, so reading and comparing one is bounded. | Bound | Value | When it is reached | |---|---|---| | Document size | 16 MiB | `ReadDocument` refuses the document before decoding it | | Nesting depth | 128 levels | `ReadDocument` refuses the document before decoding it | | Steps of one comparison | 65,536 plus 32 per node of the two documents | The result holds `comparison-incomplete` | | Changes listed | 100,000, holding at most 64 MiB of text | The result holds `comparison-incomplete` | Each pair of components is compared once per direction, however many operations share it and however deeply it refers to itself, so an honest comparison spends a small constant per node and finishes in time linear in the size of the two documents. The steps pay for everything a schema holds, not only for visiting it: reading a schema costs a step, one more for every 16 values, members, required names or alternatives it lists, and one for every 256 bytes of text in them, each time it is read, and a node of the documents is weighed the same way. A schema of a few hundred enum values shared by many fields stays well inside the bound, and one holding megabytes cannot be read again at every position for free. Only a document built to be expensive, such as one component compared against thousands of different ones, reaches a bound. The comparison then stops, lists only what it finished comparing, and adds a breaking `comparison-incomplete` change, so a check fails rather than passing on half an answer. `WriteChanges` escapes control characters, invalid UTF-8 and other unprintable characters in the names it prints, so a hostile document cannot write terminal escape sequences into a CI log. ## Where to go next [Command Line](/docs/getting-started/command-line) covers `muzak diff`, which runs the same comparison on two documents and exits non-zero for a CI step to gate on. [OpenAPI](/docs/fundamentals/openapi) covers what the document describes and why a request schema is closed, [Versioning](/docs/fundamentals/versioning) covers serving a breaking change beside the old one, and [Testing](/docs/fundamentals/testing) covers the rest of the test client. -------------------------------------------------------------------------------- title: "Testing" description: "Serve an application in-process on a loopback port, through its own server, and exercise middleware, routing, binding, dependencies and error rendering together." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/testing" -------------------------------------------------------------------------------- # Testing `muzak.dev/framework/testclient` serves an application in-process and issues real requests against it, so a test exercises the whole stack rather than any one part of it in isolation. ```go [handlers/items_test.go] package handlers_test import ( "net/http" "testing" "muzak.dev/framework/testclient" ) func TestReadItem(t *testing.T) { client := testclient.New(t, buildApp()) res := client.Get("/items/foo", testclient.Header("X-Token", "coneofsilence")) res.AssertStatus(http.StatusOK) res.AssertJSON(`{"id":"foo","title":"Foo","description":"There goes my hero"}`) } ``` The application is served on a loopback port the operating system chooses, by the same server and `ServerOptions` that `Run` uses, so a request production refuses (a 100 KB header, say) is refused in a test too. It speaks plain HTTP whatever TLS is configured. ## What `New` does ```go client := testclient.New(t, buildApp()) ``` The application is built, its lifecycle components are started, and both the server and those components are released through `t.Cleanup` when the test finishes. A build failure or a component that refuses to start fails the test immediately, because every later assertion would be meaningless. The cleanup shuts the application down as `App.Shutdown` does: it reports the application unready to readiness probes and waits `ServerOptions.DrainDelay`, ends the event streams and WebSockets and waits for the requests still running, waits for the background tasks registered with `ctx.AfterResponse`, and then stops the components. A test may call `app.Shutdown` itself to assert on what a shutdown does, and the cleanup then has nothing left to do. An application is served by one client at a time, as it is run by one run method at a time, so build one `App` per client. A single client is safe for concurrent use. A cookie jar is enabled by default, which lets a login followed by an authenticated call work the way it would in a browser. | Client option | Effect | |---|---| | `WithHeader(name, value)` | A header sent with every request, so a token is not repeated on each call | | `WithTimeout(d)` | Bounds a single request, defaulting to ten seconds | | `WithoutCookies()` | Disables the jar, so each request is independent | | `WithoutRedirects()` | Stops the client following redirects, so a test can assert on the 3xx itself | | `Override(provide)`, `OverrideAcquire(provide)` | Replaces every provider of one type before the application is built. See [Replacing a dependency](#replacing-a-dependency) | ## Building the application under test Write a helper that returns the application, and quiet its logger. ```go [handlers/helpers_test.go] // buildApp returns an application with a shared secret guard and a small // in-memory store. func buildApp() *muzak.App { store := core.NewItemStore() app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone}, }, muzak.WithDependencies(muzak.RequireHeaderToken("X-Token", "coneofsilence")), muzak.WithSingleton(store, store.Lifecycle()), ) app.Include(routers.Items()) return app } // authorized is the header every request in this suite needs. func authorized() testclient.Option { return testclient.WithHeader("X-Token", "coneofsilence") } ``` Because `main` does nothing but compose, the same routers, handlers and dependencies the real service uses are what the test exercises. ## Replacing a dependency A test that should not reach the real database, clock or caller replaces the provider of that type, and leaves the routes that use it alone: ```go client := testclient.New(t, buildApp(), testclient.Override(func(*muzak.Context) (CurrentUser, error) { return CurrentUser{Username: "alice"}, nil })) ``` Every provider of exactly that type in the application is replaced: on every route, on a handler given to `Mount`, on a file mount and on the documentation. The replacement keeps the lifetime of the provider it stands in for, so an overridden singleton is still computed once. Guards are not providers and still run. An override for a type nothing provides fails the test with the application's build error, which is what catches a test overriding `CurrentUser` where the application provides `*CurrentUser`. `testclient.OverrideAcquire` does the same for a fake that has to be released, and `App.Override` and `App.OverrideAcquire` are the same calls made on the application before it is built. [Dependencies](/docs/getting-started/dependencies) covers them with the providers they replace. A security scheme built by `muzak.JWTBearer` or `muzak.APIKeyVerifier` is not a provider, so no override lets a request past it: like a guard, it refuses in a test exactly as it does for a client. A test that wants to be let in presents a credential, such as a token it signed with a key the test configured. [Authentication](/docs/security/authentication) shows how. ## Issuing requests ```go client.Get("/items/foo") client.Post("/items/", testclient.JSON(item)) client.Put("/items/foo", testclient.JSON(rename)) client.Patch("/items/foo", testclient.JSON(patch)) client.Delete("/items/foo") client.Head("/items/foo") client.Options("/items/") client.Do(http.MethodPropfind, "/items/") ``` | Request option | Effect | |---|---| | `JSON(value)` | Encodes the value as a JSON body and sets `Content-Type` | | `RawJSON(body)` | Sends the body verbatim, for JSON a Go value could not produce | | `Body(contentType, r)` | An arbitrary body under a given content type | | `Header(name, value)` | Sets a header on this request, replacing any client-level value. A `Host` header sets the host the request is sent for, which is how a test reaches host-based routing | | `Query(name, value)` | Adds a query parameter, repeatable to send several values | | `Cookie(c)` | Sends a cookie in addition to whatever the jar holds. Only the name and value are sent, so a cookie from `res.Cookies` can be passed back as it is | | `Subprotocols(...)` | Offers WebSocket subprotocols on a `WS` call | | `KeepComments()` | Delivers an event stream's comment lines as messages of their own | ## Asserting on a response ```go func TestReadNonexistentItem(t *testing.T) { client := testclient.New(t, buildApp(), authorized()) res := client.Get("/items/baz") res.AssertStatus(http.StatusNotFound) if got := res.Error().Error.Message; got != "Item not found" { t.Errorf("message = %q", got) } } ``` | Assertion | Checks | |---|---| | `AssertStatus(want)` | The status code | | `AssertJSON(want)` | The body, compared semantically so member order and whitespace do not matter | | `AssertHeader(name, want)` | One response header | | `AssertErrorCode(want)` | The `code` of an error envelope, such as `validation_error` | Every assertion returns the response, so they chain: ```go client.Get("/items/foo"). AssertStatus(http.StatusOK). AssertHeader("Content-Type", "application/json; charset=utf-8"). AssertJSON(`{"id":"foo","title":"Foo","description":"There goes my hero"}`) ``` A `Response` carries `Status`, `Header`, `Body` and `Cookies` directly, is read fully into memory so it can be inspected more than once, and offers `RequestID()`, `String()`, `JSON(target)` and `Error()`. ## Decoding into a typed value ```go item := client.Get("/items/foo").Decode[ItemOut]() if item.Title != "Foo" { t.Errorf("title = %q, want %q", item.Title, "Foo") } ``` The type argument is written at the call site, which keeps the expected shape visible in the test and checked by the compiler. `testclient.Decoded[ItemOut](client.Get("/items/foo"))` is the free-function form, for chaining directly off a request. ## Calling a typed endpoint An operation declared with `muzak.NewEndpoint` can be tested the way its Go callers use it, through `Endpoint.Call` against the application under test: ```go func TestGetItemEndpoint(t *testing.T) { server := testclient.New(t, buildApp()) items := muzak.NewClient(muzak.ClientOptions{ AllowPrivateNetworks: true, BaseURL: server.URL(), }) item, err := itemapi.GetItem.Call(t.Context(), items, itemapi.GetItemIn{ID: "foo"}, muzak.CallHeader("X-Token", "coneofsilence")) if err != nil { t.Fatalf("Call = %v", err) } if item.Title != "Foo" { t.Errorf("title = %q, want %q", item.Title, "Foo") } } ``` The test client serves on a loopback address, which a `muzak.Client` refuses unless it is built with `AllowPrivateNetworks`. The same holds for a test that points code under test at an `httptest.Server`: build the client that code uses from options the test can set, rather than relaxing the client production uses. [Typed Endpoints](/docs/techniques/typed-endpoints) covers what a call writes and returns, and [HTTP Client](/docs/techniques/http-client) covers the address policy. ## Testing failures ```go func TestCreateExistingItem(t *testing.T) { client := testclient.New(t, buildApp(), authorized()) res := client.Post("/items/", testclient.RawJSON( `{"id":"foo","name":"The Foo ID Stealers"}`)) res.AssertStatus(http.StatusConflict) res.AssertErrorCode(muzak.CodeConflict) } func TestReadItemBadToken(t *testing.T) { client := testclient.New(t, buildApp()) res := client.Get("/items/foo", testclient.Header("X-Token", "hailhydra")) res.AssertStatus(http.StatusUnauthorized) res.AssertErrorCode(muzak.CodeUnauthorized) } ``` `RawJSON` is what tests a body a Go value could not produce, such as a duplicate member, an unknown member or a malformed document: ```go res := client.Post("/items/", testclient.RawJSON(`{"id":"foo","nmae":"Foo"}`)) res.AssertStatus(http.StatusUnprocessableEntity) if got := res.Error().Error.Details[0].Issue; got != "is not a field this endpoint accepts" { t.Errorf("issue = %q", got) } ``` ## Testing another language A locale is chosen by a header, so testing one is a header on the request. The response reports what it answered in, which is worth asserting alongside the body: a test that only checks the words passes for the right reason and for the wrong one. ```go func TestRejectionInSpanish(t *testing.T) { client := testclient.New(t, buildApp(t)) res := client.Post("/items/", testclient.Header("Accept-Language", "es"), testclient.RawJSON(`{"name":""}`)) res.AssertStatus(http.StatusUnprocessableEntity) res.AssertHeader("Content-Language", "es") if got := res.Error().Error.Details[0].Issue; got != "es obligatorio" { t.Errorf("issue = %q", got) } } ``` An application that has not been localized needs no change to its tests. With no store configured every message reads exactly as it did, which is the property worth having a test of its own for. ## Sessions and cookies The jar carries whatever a sign-in established, so a flow reads the way it happens. The example application signs in with a [session](/docs/security/sessions): `POST /login/` regenerates the session, records who signed in and answers with the username, and `POST /logout/` destroys the session. ```go func TestSignInThenOut(t *testing.T) { client := testclient.New(t, buildApp(), authorized()) form := url.Values{"username": {"muzak"}, "password": {"correct-horse-battery"}} client.Post("/login/", testclient.Body( "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))). AssertStatus(http.StatusOK). AssertJSON(`{"username":"muzak"}`) // The jar sends the session cookie with every request from here on. client.Post("/logout/").AssertStatus(http.StatusOK) } ``` The session itself never appears in a response body, only in its cookie, so a test asserts on what a signed-in request is allowed to do rather than on a session identifier. ## WebSockets ```go func TestItemSocket(t *testing.T) { client := testclient.New(t, buildApp()) conn := client.WS("/items/plumbus/ws", testclient.Query("token", "jessica")) if err := conn.WriteText(t.Context(), "hello"); err != nil { t.Fatalf("WriteText = %v", err) } reply, err := conn.ReadText(t.Context()) if err != nil { t.Fatalf("ReadText = %v", err) } if !strings.Contains(reply, "jessica") { t.Errorf("reply = %q", reply) } } ``` `Client.WS` fails the test if the handshake is refused. The connection is closed when the test finishes, so a handler blocked on a read is released even if the test forgets. Closing waits up to `CloseGracePeriod` (250 ms) for the server to hang up first, so a handler that is not reading when the test ends costs that much at cleanup. To assert on a handshake that is *meant* to be refused, use `TryWS`, which returns whatever came back: ```go _, response := client.TryWS("/items/plumbus/ws") response.AssertStatus(http.StatusUnauthorized) ``` The connection is a real `*muzak.WSConn`, driven by the same engine the server uses, so a route is tested end to end rather than against a second implementation. ## Server-sent events ```go func TestStreamItems(t *testing.T) { client := testclient.New(t, buildApp()) stream := client.SSE("/items/stream", testclient.Query("token", "jessica")) client.Post("/items/", testclient.Query("token", "jessica"), testclient.JSON(map[string]string{"id": "plumbus", "name": "Plumbus"})). AssertStatus(http.StatusCreated) item := stream.Decode[ItemOut]() if item.ID != "plumbus" { t.Errorf("id = %q, want plumbus", item.ID) } } ``` | Call | Effect | |---|---| | `stream.Next()` | The next event, failing the test if the stream ends or fails first | | `stream.Decode[T]()` | The next event's data decoded into `T` | | `stream.TryNext()` | The next event and whatever ended the stream, for a test that expects an ending | | `stream.LastEventID()` | The identifier of the last complete event that carried one; an event cut off part way leaves it where it was | | `stream.Close()` | Ends the stream, which is how a test checks that the handler notices | | `stream.Response` | The status, headers and cookies the stream opened with | Reads are bounded by the client's timeout, so a stream that never sends the event a test is waiting for fails the test rather than hanging it. ```go _, err := stream.TryNext() if !errors.Is(err, muzak.ErrSSEStreamEnded) { t.Fatalf("err = %v, want the stream to have ended", err) } ``` `Client.SSEDo` opens a stream with another method, which is what a stream answering a posted document takes, and `Client.TrySSE` is the counterpart of `TryWS`: ```go stream := client.SSEDo(http.MethodPost, "/chat/stream", testclient.JSON(prompt)) ``` ```go _, response := client.TrySSE(http.MethodGet, "/items/stream") response.AssertStatus(http.StatusServiceUnavailable) ``` Resuming a dropped stream is a header: ```go resumed := client.SSE("/items/stream", testclient.Header("Last-Event-ID", stream.LastEventID())) ``` ## Testing a handler on its own A handler is an ordinary typed function, so a unit test can call it directly with a constructed input, with no router, server or HTTP request in sight. That works whenever the handler reads nothing from the context. A handler that calls `muzak.From` needs the dependency, which is what the test client provides. Validation rules are testable the same way, without a request at all: ```go if err := validateTag().Check("Not A Tag"); err == nil { t.Error("want a spaced tag to be rejected") } if err := NotACommonPassword("password1234"); err == nil { t.Error("want a common password to be rejected") } ``` ## Catching configuration errors in a test ```go func TestAppBuilds(t *testing.T) { if err := buildApp().Build(); err != nil { t.Fatalf("Build = %v", err) } } ``` `Build` reports every route problem at once: a duplicate path, an unbindable input type, a path parameter no field binds or a template that names one twice, a duplicate operation identifier, two mounts at one path, a certificate without its key, a `Nested` that would validate nothing, an unreachable location tag, and a category, title or security declaration that cannot be written to the document. One test keeps all of them out of a deployment. Configure the application before the first call to `Build`, `Document`, `ServeHTTP` or a run method. `Use`, `Include`, route registration and the mounts panic when called afterwards, naming the call, so a helper that builds the application for a test and then adds a guard fails the test instead of quietly running without it. ## Catching a breaking change in a test An application that builds can still break its clients, by removing an operation or adding a required member to a request. One more test compares the document the application generates with a baseline committed beside it: ```go func TestAPICompatibility(t *testing.T) { testclient.AssertCompatible(t, buildApp(), "testdata/openapi.json") } ``` A breaking change fails the test and lists what breaks, and a change that may break some clients is logged for a person to judge. Run it once with `MUZAK_UPDATE_OPENAPI=1` to record the baseline, and again whenever a change it reports is intended. [API Compatibility](/docs/fundamentals/api-compatibility) covers how each change is judged, and `muzak diff` in the [command line](/docs/getting-started/command-line) makes the same comparison between two files for a CI step outside the test suite. ## Concurrency A `Client` is safe for concurrent use, which lets a test fire parallel requests to check that request-scoped state stays isolated. ```go func TestConcurrentRequestsStayIsolated(t *testing.T) { client := testclient.New(t, buildApp(), authorized()) var wg sync.WaitGroup for i := range 50 { wg.Add(1) go func() { defer wg.Done() client.Get("/items/foo").AssertStatus(http.StatusOK) _ = i }() } wg.Wait() } ``` ```bash go test ./... -race ``` ## Where to go next [Lifecycle](/docs/getting-started/lifecycle) covers the components the client starts for you, and [Configuration](/docs/fundamentals/configuration) covers pinning settings a test depends on. -------------------------------------------------------------------------------- title: "Internationalization" description: "A locale resolved once per request from the Accept-Language header or wherever else you say, with every string in the response written in it, including the ones the framework produces itself." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/fundamentals/internationalization" -------------------------------------------------------------------------------- # Internationalization A request's locale is resolved once, before the handler runs, and every string in the response is written in it. That includes the strings Muzak produces: the wording of each validation rule, what the request binder says about a value it could not read, and the sentence behind each HTTP status. ::code-group ```go [core/i18n.go] //go:embed locales var locales embed.FS func LocaleOptions() muzak.I18nOptions { return muzak.I18nOptions{Store: i18n.MustLoad(locales, "locales")} } ``` ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ I18n: core.LocaleOptions(), }) ``` ```yaml [core/locales/es.yml] es: greeting: hello: "Hola, %{name}" items: zero: "No tienes artículos" one: "Tienes un artículo" other: "Tienes %{count} artículos" ``` :: A handler translates through its context, which already knows the locale. ```go [handlers/greeting.go] func Greeting(ctx *muzak.Context, in schemas.GreetingParams) (schemas.GreetingOut, error) { return schemas.GreetingOut{ Locale: ctx.Locale(), Greeting: ctx.T("greeting.hello", "name", in.Name), Items: ctx.T("greeting.items", "count", in.Items), Today: ctx.L(time.Now(), "as", "date", "format", "long"), }, nil } ``` ``` $ curl -H 'Accept-Language: es' 'localhost:8080/greeting?name=Ada&items=1' {"locale":"es","greeting":"Hola, Ada","items":"Tienes un artículo", "today":"24 de agosto de 2026"} ``` ## A locale is passed, never set Go has no per-goroutine storage, and a package-level locale shared by every goroutine would leak one request's language into another: a Spanish request setting it, and an English one two microseconds later reading it. On a server handling more than one request at a time, which is every server, a current locale is not a thing that can exist. So the locale is passed. Muzak resolves it once per request, carries it on the request's context, and `ctx.T` reads it from there. Code with no request in hand names the locale itself. ```go i18n.T("es", "greeting.hello", "name", "Ada") ``` Anywhere there is a `context.Context` but no Muzak one, such as a repository or a goroutine started from a handler, the same value is one call away. ```go locale, ok := muzak.LocaleFromContext(ctx) ``` ## Where the locale comes from The choice is usually written by hand in a filter that runs per request. Here it is declared, so it is visible in the application's options rather than buried in code. Sources are tried in order, and the first that yields a locale the application actually answers in wins. ```go muzak.I18nOptions{ Store: i18n.MustLoad(locales, "locales"), Sources: []muzak.LocaleSource{ muzak.LocaleFromQuery, muzak.LocaleFromAcceptLanguage, }, } ``` | Source | Reads | Configured by | |---|---|---| | `LocaleFromAcceptLanguage` | The `Accept-Language` header, quality values honoured | Nothing. It is the default | | `LocaleFromPath` | A path segment, as `/pt/books` | `PathIndex`, counting from zero | | `LocaleFromQuery` | A query parameter | `Query`, defaulting to `locale` | | `LocaleFromHeader` | A named header | `Header`, defaulting to `X-Locale` | | `LocaleFromCookie` | A cookie | `Cookie`, defaulting to `locale` | | `LocaleFromCustom` | Whatever you write | `Extractor` | `LocaleFromAcceptLanguage` is the default because every browser sends the header and nothing has to be added to a URL for it to work. The client's quality values order the candidates, a range matches by language as well as in full, so `pt-BR` is served `pt` when that is what you have, and `en;q=0` refuses English outright even when English is all there is. A quality is read only between 0 and 1: `q=NaN` is unreadable and counts as one, anything above one is one, and a negative value is a refusal, so `q=Inf` and `q=1e9` cannot outrank every honest range. The header is compared with the available locales using forms computed once when the middleware is built, so what a request costs does not grow with the number of locales. A locale in a cookie is invisible in the URL, so two people opening the same link see different pages. Prefer a source that travels with the address unless you have a reason not to. ### Nothing a request says is trusted A value arriving from a client is matched against the locales the application declared, and discarded if it matches none. What comes out of resolution is always one of your own strings, never one of theirs, which is the single rule that keeps `?locale=../../etc/passwd` out of a filesystem path and a locale carrying a line break out of a response header. ## Locale files YAML or JSON, with the locale at the top level. Files are merged key by key rather than file by file, so a locale may be spread across as many files as suits it. ```yaml [core/locales/es.yml] es: errors: messages: blank: "es obligatorio" too_short: one: "debe tener al menos 1 carácter" other: "debe tener al menos %{count} caracteres" ``` Muzak has no third-party dependencies, so the YAML is read by a parser written for this and nothing else. It reads the subset locale files are written in, which is deliberately the subset the published locale corpora use, so a file taken from one loads here unchanged, formats and plural forms included. Anything outside that subset is refused with the line and column it was found at, rather than guessed at. A locale file is input the framework did not write, so what it can ask of the process is bounded: - **A key reached two ways is refused.** A key that contains a dot (`"a.b": one`) beside a namespace that spells the same path out (`a: {b: two}`) is the key `a.b` twice, and which of the two a lookup returned was decided by the order Go ranged the map in, so the same file answered differently after each restart. Loading it is an error naming the key, whether the two are in one file or in two files loaded into the same locale, and the locale is left as it was. Write each key one way. Dotted keys that collide with nothing keep working. - **A tree nested more than 100 levels is refused**, which is the depth the YAML reader already stops at. - **YAML aliases are bounded.** The aliases of one document may copy at most 100,000 values between them, and an alias adds the depth of the value it names to its own, so it counts against the depth limit. A document that exceeds either is refused with a located syntax error. Sharing a block of defaults between locales, which is what aliases are for, is far below both. The limit counts values, not bytes; what those values cost under their keys is bounded below. - **What a locale costs to hold is bounded.** Every key is stored under its full dotted path, so a long key above many values is paid for once per value. A locale, or all the locales of one file together, that would take more than 64 MiB to hold that way is refused before any of it is built, and the locale is left as it was. This covers JSON and `StoreTranslations` as well as YAML. The locale Muzak ships comes to under a tenth of a megabyte. Files are embedded rather than read from disk by default. A service that shipped without its locale directory would start cleanly and then answer every request in the wrong language, which is the kind of failure that reaches production. Point `Dir` at a directory instead when translations should be editable without a rebuild. ## What you have to translate, and what you do not A locale file names the strings your service adds and the rules you want worded differently. Everything else falls through to the locale Muzak ships, which covers every string the framework produces. A translation grows as a service is translated rather than having to be complete before it is useful. ``` $ curl -H 'Accept-Language: es' localhost:8080/items -d '{"name": "", "cost": 10, "price": 5}' {"error":{ "code":"validation_error", "message":"La solicitud no pudo ser validada.", "status":422, "details":[ {"field":"name","location":"body","issue":"cada artículo necesita un nombre"}, {"field":"price","location":"body","issue":"el precio no puede ser inferior al coste"} ]}} ``` The code is not translated, and never will be. Clients branch on it, so a locale file that reworded one would break every client reading it. An application that renders errors as [problem details](/docs/techniques/problem-details) gets the same translated message and details, and a `title` too: the status's reason phrase, looked up at `muzak.status.` (`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: "%03d of %03d" ``` ```go ctx.T("product_price", "price", 10) ctx.T("progress", "done", 7, "total", 250) ``` Arguments are alternating names and values, the way `slog` reads them, because a translation takes an open set of values under names the translator chose and Go has no keyword arguments. A handful of names mean something to the lookup rather than to the sentence: `count`, `scope`, `default` and `locale`. A translation that names `scope` or `default` is an error rather than a silent substitution. A value the translation expects and the call did not supply is also an error. A message missing a number is worse than no message, because it looks correct. The exception is a translation of a message the **framework** writes: one that names a value the call site never passes falls back to English, where it used to render as an empty string and send a blank summary or status message. A `%` directive with a width or precision above 64, or a `*` width, is left as literal text rather than rendered. ## Dates and numbers `ctx.L` writes a value the way the locale writes it. ```go ctx.L(time.Now()) // Mon, 24 Aug 2026 09:05:03 +0000 ctx.L(time.Now(), "as", "date", "format", "long") ctx.L(1234.5, "precision", 2) // 1,234.50 in en, 1.234,50 in es ``` ```yaml es: date: formats: long: "%d de %B de %Y" month_names: [~, enero, febrero, marzo, abril, mayo, junio, julio, agosto, septiembre, octubre, noviembre, diciembre] number: format: separator: "," delimiter: "." ``` Patterns are strftime rather than Go layouts, and that is not a matter of taste. Go formats a time by example, and the reference layout hard-codes `January`, so a Go layout can only ever produce an English month name. A strftime pattern names the field, which leaves Muzak free to fill it from `date.month_names` in whatever locale was asked for. It is also what the published locale corpora are written in. Expansion is bounded, because a locale file is input the framework did not write and `%c` and `%x` read their patterns from it. A width is at most 64, a pattern's nesting through `%c` and `%x` at most four levels (a directive is written out as it stands past that, where a locale whose default format was `%c` used to recurse until the process died), and one expansion reads at most 16,384 characters and writes at most 16 KiB, cut at a character boundary. A format name with no pattern under it is used as the pattern itself, so what a caller passes as `format` is read as a strftime pattern. The bounds are what make that safe to reach with a value the caller did not write, though a name from a client is still best checked against the formats the application offers. A pattern beginning `go:` is handed to Go's own formatter, for the formats meant to be read by a machine. ```yaml en: date: formats: iso: "go:2006-01-02T15:04:05Z07:00" ``` The number helpers from the same scope are on the store: `NumberWithDelimiter`, `NumberWithPrecision`, `NumberToCurrency`, `NumberToPercentage`, `NumberToHumanSize`, `DistanceOfTimeInWords` and `ToSentence`. ## Caching A response reports the locale it was written in, and says so in `Vary` when the header took part in choosing it. ``` Content-Language: es Vary: Accept-Language, Accept-Encoding ``` Without the `Vary`, a cache in front of the service will serve one language to a client that asked for another. It is added by default and `DisableVary` turns it off, for a service whose cache key is already settled by something else. The `Vary` names what actually chose the language. With `LocaleFromHeader` it names the header, and with `LocaleFromCookie` it names `Cookie`, where before only `Accept-Language` was added and a shared cache could be made to serve an attacker-chosen language to everyone. A source listed after the one that decided is not read and so not named, and the path and query need nothing because they are already part of the address. A `LocaleFromCustom` extractor reads inputs the framework cannot know, so one that reads a header or a cookie names it in `Vary` itself. The field is recorded on the response and merged into `Vary` when the response is written, so a handler that sets its own `Vary` adds to it rather than erasing it. ## Leaving it out Leave `AppOptions.I18n` unset and none of this exists. No middleware is installed, no locale is resolved, and every message reads exactly as it does without this feature. It goes further than configuration. The framework reaches a translation engine only through an interface, so a binary that never mentions `muzak.dev/framework/i18n` links neither the engine, nor the YAML parser, nor the table of plural rules. A service that answers in one language does not carry the machinery for ninety. ```go type Translator interface { Translate(locale, key string, args ...any) string Localize(locale string, value any, args ...any) string Exists(locale, key string) bool AvailableLocales() []string DefaultLocale() string } ``` Anything satisfying it can be the store, which is how translations come from a database or a service rather than from a file. ## Running this at scale Nothing here holds per-process state that a request can change, which is what makes it safe on a platform that runs many requests at once on an instance it may destroy a second later. A locale is resolved from the request, put on the request's context, and copied onto the pooled `Context` when that is taken from the pool. It is cleared when the `Context` goes back. Nothing writes to the store after start-up: the translations, the plural rules and the fallback chains are all settled while the application is being built, and only read afterwards. The tests run 64 goroutines through five locales under the race detector, half of them naming no locale at all, and require every answer to match its own request. ### Cold start On this machine, building an application with five locale files costs about **83 microseconds** more than building the same application without translations, and about **69 kilobytes**. The rest of a cold start is the framework and the runtime. ``` BenchmarkColdStartWithoutI18n 1.007 ms 6.90 MB BenchmarkColdStart 1.090 ms 6.97 MB BenchmarkStoreOnly 0.048 ms 0.07 MB ``` Locale files are parsed once, when the store is built, and the locale the framework ships is parsed once per process and shared by every store after that. A warm request that translates a message with nothing to interpolate allocates nothing at all. ### Embed the locale files Use `FS` rather than `Dir` anywhere the filesystem is not yours. A container built from scratch has no locale directory, and an instance that starts without one will serve every request in the default language rather than failing, which is the kind of fault that is noticed a week later by a customer. ```go //go:embed locales var locales embed.FS ``` ### Time zones need a database `Timezone()` resolves a name against the zone database, and a Go binary does not carry one unless it is asked to. On a scratch or distroless image there is no `/usr/share/zoneinfo` either, so the rule refuses every value including the correct ones, and nothing says why. Either carry the database, at a cost of about 450 kilobytes: ```go import _ "time/tzdata" ``` or run on an image that has one. Check at start-up either way, so that a missing database stops the process rather than every request: ```go if !validate.TimezoneDataAvailable() { log.Fatal("no time zone database: import _ \"time/tzdata\" or use an image that has one") } ``` ### Caches in front of the service A response says which language it is in and, when the header decided it, that the header decided it. ``` Content-Language: es Vary: Accept-Language, Accept-Encoding ``` Without the `Vary`, a shared cache will hand one language to a client that asked for another. It is written by default; `DisableVary` turns it off for a service whose cache key is already settled some other way. ## One thing deliberately left out `errors.format`, and the full messages built from it, have no counterpart here. The idea is to join an attribute name to a message to make one sentence, as "Email is required". Muzak reports `field`, `location` and `issue` as separate members of the envelope and never concatenates them, so there is nothing for a format to join. A client that wants the sentence builds it from the three, in whatever order its own language puts them, which is the part a fixed format gets wrong outside English. Everything else is here: scopes, default chains including keys that name other keys, bulk lookup, fallbacks, swappable and chained backends, and the exception handlers. ## Where to go next - [Validation](/docs/fundamentals/validation) for the rules whose messages these are. - [Error handling](/docs/getting-started/error-handling) for the envelope they arrive in. - [Middleware](/docs/getting-started/middleware) for where the locale is resolved in the chain. -------------------------------------------------------------------------------- title: "Headers" description: "Bind request headers into typed fields, read them directly, and set response headers that a cache and a client can rely on." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/headers" -------------------------------------------------------------------------------- # Headers A header is bound like any other parameter. Tag a field with `header:"Name"` and it is read from the request, converted to the field's type, and documented. ```go type ClientHeaders struct { // Host is the authority the client addressed, which is what absolute URLs // in the response are built from. Host string `header:"Host" doc:"The authority the request was addressed to"` // SaveData is the client's data saver preference. It is the string "on" // when set, not a boolean, so it is bound as one. SaveData string `header:"Save-Data" default:"off" doc:"Set to on by a client asking for a smaller payload"` // Traceparent carries the caller's trace context, echoed back so a client // can correlate its span with this response. Traceparent string `header:"traceparent" doc:"W3C trace context of the calling span"` // Tags filter the feed. The header may be repeated or written as a // comma-separated list, and every value is bound in the order it arrived. Tags []string `header:"X-Tag" doc:"Restrict the feed to these tags; may be repeated"` } ``` Names are matched case-insensitively, as HTTP requires, so `traceparent` and `Traceparent` name the same header. A header is optional unless the field says `required:"true"`, and a `default` supplies the value when the client sends nothing. ```go type PagingHeaders struct { APIVersion string `header:"X-API-Version" required:"true" doc:"The API version this client was written against"` PageSize int `header:"X-Page-Size" default:"50"` } ``` ## Repeated headers Declare a slice, and every value arrives in the order it was sent. A header bound to a slice is split on its commas as well as across its lines, because RFC 9110 says a list written as several lines and one written as a comma-separated line mean the same thing. The spaces around each element are dropped, an empty element is skipped, and a comma inside a quoted string, such as an entity tag, stays part of its element. ```bash curl 'http://localhost:8080/feed' -H 'X-Tag: go' -H 'X-Tag: http' curl 'http://localhost:8080/feed' -H 'X-Tag: go, http' ``` ```go in.Tags // []string{"go", "http"}, either way ``` A `[]string` header whose values contain commas therefore gets several elements; declare a `string` field to read the line whole. A non-slice header sent on more than one line is a `422`, `must be given only once`, rather than one of its values being chosen, since a proxy in front may have read a different one. ## Headers that are not plain text A header whose format is not one Go already parses gets a type of its own. Implementing `encoding.TextUnmarshaler` is all it takes. ```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"` } ``` An `*HTTPError` returned from the method is the issue the client sees, so phrase its message to read after the field name. Any other error reads `is not in the expected format`, because a parser's own error is no fit for a client: ```json { "field": "If-Modified-Since", "location": "header", "issue": "must be an HTTP date, such as Wed, 21 Oct 2026 07:28:00 GMT" } ``` ## Validating a header Rules bind to the field, not to the location, so a header is checked exactly as a query parameter is and its failure is reported against the header name the client sent. ```go func (in *FeedIn) Validate(v *muzak.Validation) { v.String(&in.SaveData).Trim().Lower().OneOf("on", "off") v.String(&in.Traceparent).Matches(`^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$`). Message("must be a W3C trace context, such as 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") v.Slice(&in.Tags).MaxItems(5).Each(validate.String().MaxLen(24)) } ``` Transforms run before the checks, which is what lets `Save-Data` be compared against a single spelling in the handler afterwards. ## Reading a header directly ```go ctx.Header("X-Request-Id") // first value, or "" when absent ctx.Request().Header.Values("X-Tag") // every value, through net/http ``` Prefer binding. A bound header is typed, documented, validated and reported the same way every other parameter is; a header read by hand is none of those things. ## Setting response headers ```go func Feed(ctx *muzak.Context, in schemas.FeedIn) (schemas.FeedOut, error) { // The caller's trace context is echoed so its span and this response can be // tied together, and the request identifier is what ties it to our log. if in.Traceparent != "" { ctx.SetHeader("traceparent", in.Traceparent) } // The response varies by all three, so caches must be told. This adds to // whatever the framework recorded for the response, such as Origin. ctx.SetHeader("Vary", "Save-Data, X-Tag, Cookie") ctx.SetHeader("Last-Modified", feedUpdatedAt.UTC().Format(http.TimeFormat)) // ... } ``` Echoing `traceparent` only hands the caller's value back. Tracing itself is `AppOptions.Tracing`, which parses the header, continues the caller's trace and needs no field for it; see [Observability](/docs/fundamentals/observability). `SetHeader` replaces any value already present. `AddHeader` appends, which is what repeated headers require: ```go ctx.AddHeader("Vary", "Accept-Encoding") ctx.AddHeader("Link", `; rel="next"`) ``` Headers must be set before the handler returns. Once the response has begun, `net/http` ignores further changes, which is the same rule that governs [middleware](/docs/getting-started/middleware). ## Conditional responses ```go func Feed(ctx *muzak.Context, in schemas.FeedIn) (schemas.FeedOut, error) { // A conditional request is answered without a body when nothing changed. // The header was parsed into a time by the binder, so this is a comparison // rather than a parse that could fail here. if !in.IfModifiedSince.IsZero() && !feedUpdatedAt.After(in.IfModifiedSince.Time) { ctx.SetStatus(http.StatusNotModified) return schemas.FeedOut{}, nil } // ... } ``` `304` writes no body at all, whatever the handler returned. Document the outcome so it appears in the generated reference, with the model that says the response is empty: ```go r.Get("/feed", handlers.Feed, muzak.WithResponseModel[muzak.Empty](http.StatusNotModified, "The feed has not changed since If-Modified-Since")) ``` `WithResponseDoc` would have described that status as the error envelope, which is right for an outcome a handler reports by returning an error and wrong for this one. See [Response Models](/docs/techniques/response-models). ## Headers Muzak sets for you | Header | Set by | Value | |---|---|---| | `X-Request-Id` | `RequestID` | The identifier assigned to the request | | `X-Content-Type-Options` | `SecurityHeaders` | `nosniff` | | `X-Frame-Options` | `SecurityHeaders` | `DENY` | | `Referrer-Policy` | `SecurityHeaders` | `strict-origin-when-cross-origin` | | `Strict-Transport-Security` | `SecurityHeaders` | `max-age=31536000`, on a response to a request that arrived over TLS, not for localhost or an address | | `Content-Type`, `Content-Length` | the response encoder | For the body it wrote | | `Allow` | the router | On an automatic `OPTIONS` and on a `405` | | `Vary` | `Compress`, the CORS policy, `Locale`, versioning, sessions | `Accept-Encoding`; `Origin` (and the preflight request headers on `OPTIONS`) under a named-origin policy; the header or cookie that chose the language; the version header or `Accept`; `Cookie` when the handler read its session. A handler's own `Vary` adds to these | | `Cache-Control` | the router, file mounts and sessions | `private, no-cache` behind a guard or a request-scoped provider, or when the handler read its session, unless the handler set one, and on a response carrying the session cookie in place of any value that would let a shared cache keep it. `no-store` on an error | | `Retry-After`, `RateLimit-*` | the rate limiter | On a refusal, and on every counted request | `SecurityHeaders` never overwrites a value already set, so a handler or a later middleware can opt out per response. ```go // This one page is allowed to be framed by the parent site. ctx.SetHeader("X-Frame-Options", "SAMEORIGIN") ``` Turn the whole set off with `AppOptions.DisableSecurityHeaders` if something in front of the service sets them instead. ## Forwarding headers `X-Forwarded-For` and its relatives are not believed by default, because a header any client can write is an identity any client can claim. Naming the proxy is what makes it believable. See [Behind a Proxy](/docs/deployment/behind-a-proxy). ## Where to go next [Cookies](/docs/techniques/cookies) covers the other half of what a browser sends, and [Compression](/docs/techniques/compression) covers the `Vary` header that goes with it. -------------------------------------------------------------------------------- title: "Cookies" description: "Bind cookies into typed fields, set them on a response, and choose the attributes that decide whether a session is safe." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/cookies" -------------------------------------------------------------------------------- # Cookies A cookie is bound like any other parameter. ```go [schemas/feed.go] // SessionCookies groups the cookies the browser sends back. type SessionCookies struct { // SessionID identifies the signed-in reader and must be present. SessionID string `cookie:"session_id" required:"true" doc:"The reader's session"` // The trackers are declared so they are documented and so a reader can be // told what is being read, not because the feed needs them. 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"` } ``` Embed that group into any input that needs it: ```go type FeedIn struct { ClientHeaders SessionCookies Limit int `query:"limit" default:"20" doc:"How many entries to return"` } ``` A cookie is optional unless the field says `required:"true"`. A missing required cookie is a `422` naming it: ```json { "field": "session_id", "location": "cookie", "issue": "is required" } ``` Cookies are converted by the same setters every other parameter uses, so a cookie holding a number or a UUID can be bound as one. ```go type PreferenceCookies struct { Theme string `cookie:"theme" default:"system"` PageSize int `cookie:"page_size" default:"25"` DeviceID uuid.UUID `cookie:"device_id"` } ``` Validate them the way you validate anything else: ```go func (in *FeedIn) Validate(v *muzak.Validation) { v.String(&in.SessionID).Trim().MinLen(4).MaxLen(64) v.String(&in.Theme).OneOf("system", "light", "dark") } ``` ## Reading one directly ```go cookie, err := ctx.Cookie("theme") if errors.Is(err, http.ErrNoCookie) { // nothing was sent under that name } ``` That is what a provider reading a cookie for several routes does, because it runs before binding and serves every route that declares it: ```go [core/dependencies.go] // Theme is the reader's display preference, read once for every route that // needs it. type Theme string func GetTheme(ctx *muzak.Context) (Theme, error) { if cookie, err := ctx.Cookie("theme"); err == nil && cookie.Value == "dark" { return "dark", nil } return "light", nil } ``` The session of `AppOptions.Sessions` is not read this way. Its cookie carries the session encrypted, or an identifier into a store, and neither means anything to a handler: read the session through `ctx.Session()`, as the `GetCaller` provider in [Dependencies](/docs/getting-started/dependencies) does. ## Setting a cookie Muzak writes the cookie of `AppOptions.Sessions` itself, as described below. Every other cookie is set with `SetCookie`, here an identifier for a session an application keeps by hand: ```go session := uuid.NewV4().String() ctx.SetCookie(&http.Cookie{ Name: "session_id", Value: session, Path: "/", // HttpOnly keeps the session 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: 3600, }) ``` `SetCookie` adds a `Set-Cookie` header for the cookie as given. Muzak does not modify it: the attributes are the caller's to choose, and choosing them is the whole security decision. | Attribute | Why it matters | |---|---| | `HttpOnly` | Keeps the value out of reach of scripts, so a cross-site scripting bug cannot read the session | | `Secure` | Refuses to send the cookie over plain HTTP. Set it wherever the service is served over TLS | | `SameSite` | `Lax` stops the cookie riding along on cross-site requests, which is most of what CSRF needs | | `Path` | Narrows where the cookie is sent. `/` is right for a session, narrower is better for anything else | | `MaxAge` | Bounds how long the credential is useful. A session with no expiry is a credential with no expiry | A session cookie is exactly the kind of ambient authority a cross-origin WebSocket handshake can borrow, which is why the origin check in [WebSockets](/docs/realtime/websockets) exists and why `AppOptions.CORS` cannot substitute for it. A session identifier set by hand, as above, is a credential you then store, look up and expire yourself. `AppOptions.Sessions` does that for you: the session lives in a cookie it encrypts, or in a store the cookie points into, named `__Host-session` and sent `HttpOnly`, `Secure` and `SameSite=Lax` by default. See [Sessions](/docs/security/sessions). It requires `AppOptions.CrossOriginProtection`, which refuses a state-changing request a browser sends from another origin, including a form posted from a sibling subdomain, which `SameSite` lets through because it is the same site. See [Cross-Origin Requests](/docs/security/cross-origin-requests). ## Clearing a cookie Send it back with an expiry in the past and the same `Path`, so the browser drops it. ```go ctx.SetCookie(&http.Cookie{ Name: "session_id", Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) ``` A session of `AppOptions.Sessions` is not cleared by hand. Signing out calls `ctx.Session().Destroy()`, which removes the cookie the same way and, with a server-side store, deletes the entry too, so a copy of the cookie taken earlier is worthless. See [Sessions](/docs/security/sessions). ## Several cookies on one response `SetCookie` appends rather than replacing, so more than one is fine. ```go ctx.SetCookie(&http.Cookie{Name: "session_id", Value: session, Path: "/", HttpOnly: true}) ctx.SetCookie(&http.Cookie{Name: "theme", Value: in.Theme, Path: "/", MaxAge: 31536000}) ``` ## Cookies in logs `cookie` and `set-cookie` are in `muzak.DefaultRedactedKeys`, so an attribute under either key is replaced with `[redacted]` before a record is written. The access log records no headers at all. See [Logging](/docs/fundamentals/logging). ## Cookies in tests The test client keeps a cookie jar, so a sign-in followed by a call that needs the session works the way it would in a browser. ```go func TestLoginThenLogout(t *testing.T) { client := testclient.New(t, buildApp()) form := url.Values{"username": {"muzak"}, "password": {"correct-horse-battery"}} client.Post("/login/", testclient.Body( "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))). AssertStatus(http.StatusOK) // The jar carries the __Host-session cookie set above, so the sign-out // ends that session, and its response removes the cookie. res := client.Post("/logout/") res.AssertStatus(http.StatusOK) if len(res.Cookies) != 1 || res.Cookies[0].MaxAge >= 0 { t.Errorf("want the session cookie removed, got %v", res.Cookies) } } ``` The application listens on a loopback address, which the jar treats as a secure origin as browsers do, so the `Secure` session cookie is sent back over the test's plain HTTP. `testclient.WithoutCookies()` disables the jar when each request should be independent, and `testclient.Cookie(c)` sends one cookie on a single request. ## Where to go next [Sessions](/docs/security/sessions) covers a session cookie Muzak encrypts and expires for you, [Authentication](/docs/security/authentication) covers verifying who sent a request, and [Forms and HTML](/docs/techniques/forms-and-html) covers the sign-in form that sets one. -------------------------------------------------------------------------------- title: "JSON" description: "How request bodies are decoded and responses encoded, which struct tags matter, and why the defaults are strict rather than forgiving." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/json" -------------------------------------------------------------------------------- # JSON Muzak reads and writes JSON with `encoding/json/v2`. The defaults are strict, because a request that is nearly right is a bug somewhere, and finding it in a `422` beats finding it in a support ticket. ## Decoding a request A field with no location tag is a member of the JSON body. ```go 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"` } ``` What the decoder refuses: | Refused | Why | |---|---| | An unknown member | A client's typo becomes an immediate `422` instead of a value that silently disappears | | A duplicate member | Two values for one field means the client and the server disagree about which one won | | Invalid UTF-8 | A string that is not text is not a string | | A member of the wrong type | `"age": "42"` is not `"age": 42`. A value of the right kind that does not fit, `300` for an `int8` or `1.5` for an `int`, is described as a parameter of the same type would be | | A member naming a located field | A path, query, header or cookie field is never a body member, so naming one is an unknown member, `422` | | An empty body, when the route binds one | Reported as `{"field": "", "location": "body", "issue": "is required"}` | | A media type that is not JSON | `415`, unless the type is `application/json` or ends in `+json` | | A body with no `Content-Type` | `415`. Accepting an untyped body let any page drive a JSON route cross-site without a CORS preflight, by posting an untyped `Blob`. A request with no body and no `Content-Type` is unaffected | ```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" }, { "field": "age", "location": "body", "issue": "has the wrong type, a string is not accepted here" } ] }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` The issue follows the field's type, with the same wording and translation keys a parameter uses: `300` for an `int8` reads `must be between -128 and 127`, and a time that does not parse reads `is not in the expected format`. Only a value of a kind the field cannot hold at all, a string for a number, reads `has the wrong type`. The `field` is the member's full path, `addr.zip` for a member of an object and `items[1].zip` for one inside an array, as a validation failure names it. Every one of these messages, `is not a field this endpoint accepts` and `is not valid JSON` included, can be translated. The Go type behind a failure is deliberately dropped from the message. Naming server-side types in a response tells a client more about the implementation than it needs to know. A body type `encoding/json/v2` cannot decode into, whatever a request sends, is a build error naming the field or type at fault: an embedded struct tagged `json:",omitzero"`, a `format:` option, two fields claiming one name, a channel, function, complex or non-empty interface field, or the `string` option on a field that is not a number. Such a type used to build, and every request that reached it got a `422` blaming the client, with nothing logged. A failure of the same kind that can only show up while a request is decoded, such as an embedded pointer to an unexported struct, is a `500` with the reason logged. Fix the tag or the type the error names, or tag the field `json:"-"`. A self-referential collection type, such as `type Tree map[string]Tree`, is accepted as a body field and described once. Registering one used to hang or overflow the stack. As a query, header, cookie or form parameter it is refused with an error naming the field. ### Accepting unknown members ```go r.Post("/webhooks/stripe", handlers.StripeWebhook, muzak.AllowUnknownFields()) ``` Reach for it where forward compatibility with a client that sends extra members matters more than catching typos, which is what a third-party webhook receiver looks like. It is a shared option, so a whole router can carry it. Duplicate members and invalid UTF-8 stay rejected either way. ### A body next to a path parameter ```go type ItemRenameIn struct { ID string `path:"item_id" doc:"The item to rename"` Name string `json:"name" doc:"The item's new display name"` } ``` The body can only ever reach `Name`. 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"}` 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`. ## Encoding a response The handler's return value is the body, serialized as it stands. ```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"` } ``` ```json {"id":"foo","name":"Foo"} ``` `Content-Type: application/json; charset=utf-8` and `Content-Length` are set for you. The body is encoded fully into a pooled buffer before anything is written, so a failure part way through encoding still produces a clean error response instead of a truncated body. ### The tags that matter | Tag | Effect | |---|---| | `json:"name"` | The member name. Without a tag, the Go field name is used | | `json:"name,omitzero"` | Omit the member when the value is the zero value of its type | | `json:"-"` | Never encoded, and never a body member on the way in. On a field with a location tag it only keeps the field out of the body and the schema; its header or query value, default and required check still apply | | `doc:"..."` | The member's description in the generated schema | | `default:"..."` | The value used when a parameter, or a top-level member of the body, is absent, and its default in the generated schema, written as the JSON type of the member (`10`, not `"10"`) | `omitzero` is what keeps a response clean without a pointer: ```go Owner string `json:"owner,omitzero"` ``` ```go [handlers/feed.go] if trimmed { // The client asked for less, so the summary is left out. The field is // omitzero, so it disappears from the response entirely. entry.Summary = "" } ``` It does not decide what a request must contain. The decoder accepts a body that leaves any member out, so the generated schema lists a **request** member as required only when a `Required()` rule refuses a body without it (and it has no `default`). The response side keeps the shape: a member that is not a pointer and not `omitzero` or `omitempty` is always written. See [OpenAPI](/docs/fundamentals/openapi#request-bodies). ## Types on the wire | Go type | JSON | |---|---| | `time.Time` | An RFC 3339 string, documented as `"format": "date-time"` | | `uuid.UUID` | A string, documented as `"format": "uuid"` | | `time.Duration` | a string such as `"1500ms"`, both ways, documented as a string with a `pattern` | | a type implementing `encoding.TextMarshaler` and `encoding.TextUnmarshaler` | a string, both ways | | a map | an object | | a slice | an array | | a pointer | the value, or `null` | | an interface | whatever it holds, documented as accepting any JSON value | A type that converts to and from text is a string everywhere: the binder parses it with `UnmarshalText` in a parameter, the encoder writes it with `MarshalText` in a body, and the schema calls it a string rather than describing the Go struct behind it. ```go type Currency string type PriceOut struct { Amount int64 `json:"amount_minor"` Currency Currency `json:"currency"` } ``` ## Response models are not storage models ::code-group ```go [core/store.go] // Item is one stored item. It is the store's own type, deliberately separate // from the response models in the schemas package: 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 } ``` ```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 } ``` :: Constructing the output is more typing than returning the stored record. In exchange, a column added to that record tomorrow cannot appear in a response, because it is not part of the response type, and the compiler is what tells you rather than a bug report. ## Nesting Named structs become named components in the document and are referenced by name, so a type used by several operations is described once. ```go type FeedEntry struct { ID string `json:"id"` URL string `json:"url"` Title string `json:"title"` Tags []string `json:"tags"` Summary string `json:"summary,omitzero"` } type FeedOut struct { Reader string `json:"reader"` Trimmed bool `json:"trimmed"` Tracked bool `json:"tracked"` Entries []FeedEntry `json:"entries"` UpdatedAt time.Time `json:"updated_at"` } ``` Embedding promotes members the way JSON encoding does, so a shared group of fields is declared once and appears inline on every model that embeds it. ## When the body is not JSON - Return `muzak.HTML` for a page. See [Forms and HTML](/docs/techniques/forms-and-html). - Return `muzak.Empty` with `muzak.Status(http.StatusNoContent)` for no body at all. - Return `muzak.Bytes`, `muzak.Stream` or `muzak.FileResponse` for a file, an image or a download, and `muzak.Redirect` to send the client elsewhere. See [Files, Streams and Redirects](/docs/techniques/files-streams-redirects). - Take `ctx.ResponseWriter()` for anything else Muzak should not encode. See [Responses](/docs/getting-started/responses). - Reach for [Server-Sent Events](/docs/realtime/server-sent-events) for a body that never ends. ## Deterministic output for the document `Document.Marshal` renders the OpenAPI document with map keys in sorted order, so the output is byte-for-byte reproducible. That is what lets a generated document be committed and diffed. Ordinary responses are not sorted; a Go struct already has a field order, and that order is what goes out. ## Where to go next [Validation](/docs/fundamentals/validation) covers checking a decoded body, and [Compression](/docs/techniques/compression) covers making a large one smaller. -------------------------------------------------------------------------------- title: "Forms and HTML" description: "Bind a form body into a typed input, and return an HTML document from the same application that serves the API." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/forms-and-html" -------------------------------------------------------------------------------- # Forms and HTML A field tagged `form:"name"` is bound from a form body, converted by the same setters that convert a query parameter. A route that binds form values and no files at all is not a special kind of route: it validates, documents and fails exactly like any other. ## Binding a form ```go [schemas/login.go] // LoginIn is the sign-in form. // // The two fields carry no location tag beyond `form`, so this route reads a // form body rather than JSON and accepts both encodings a browser can produce: // application/x-www-form-urlencoded, which a plain HTML form posts, and // multipart/form-data, which one with an enctype does. type LoginIn struct { Username string `form:"username" doc:"The account to sign in to"` Password string `form:"password" doc:"The account's password"` // Next is where to send the browser afterwards. A form value is required // like the body is, so an optional one says so. Next string `form:"next" required:"false" doc:"Where to redirect after signing in"` } ``` A form value is body content, so it is **required by default**, unlike a query parameter. Mark an optional one `required:"false"` or give it a `default`. ```go type SearchForm struct { Query string `form:"q"` Page int `form:"page" default:"1"` Safe bool `form:"safe" default:"true"` } ``` ## What a form route accepts | The input binds | Accepted media types | |---|---| | `form` fields only | `application/x-www-form-urlencoded` and `multipart/form-data` | | any `file` field | `multipart/form-data` only, because urlencoded cannot carry a file | That is why a plain HTML form with no `enctype` posts to a form route without being told to. An input that binds `form` or `file` fields cannot also declare JSON members. A field with no location tag on such an input is a build error telling you to tag it with `form` or move it to the path, query, header or cookie. ## Validating a form ```go [schemas/login.go] // Validate bounds the credentials before any comparison is attempted. // // The rules are deliberately shape-only. A password that is too short is worth // rejecting outright, but nothing here may hint at whether the account exists; // that answer belongs to the handler, which gives the same one either way. func (in *LoginIn) Validate(v *muzak.Validation) { v.String(&in.Username).Trim().Lower().MinLen(2).MaxLen(32) v.String(&in.Password).MinLen(8).MaxLen(128) } ``` Failures are reported with `"location": "body"`, because a form value is body content. ## The handler ::code-group ```go [handlers/login.go] // Login establishes a session from a submitted form. // // By the time this runs the form has been read, the username trimmed and // lower-cased and both lengths checked, so the handler is left with the one // decision that is actually its own. func Login(ctx *muzak.Context, in schemas.LoginIn) (schemas.LoginOut, error) { expected, known := accounts[in.Username] // The comparison runs even for an unknown account, and the same answer is // given either way. Returning "no such user" would turn this endpoint into // a way to enumerate accounts, and returning early would let its timing do // the same thing more quietly. matches := subtle.ConstantTimeCompare([]byte(expected), []byte(in.Password)) == 1 if !known || !matches { return schemas.LoginOut{}, muzak.Unauthorized("the username or password is incorrect") } session := ctx.Session() // Regenerating comes first, before anything that says who the user is, so // a session someone else planted in the browser is worthless at the moment // it would have become valuable. if err := session.Regenerate(); err != nil { return schemas.LoginOut{}, err } if err := session.Set("user", in.Username); err != nil { return schemas.LoginOut{}, err } return schemas.LoginOut{ Username: in.Username, Next: in.Next, }, nil } // Logout ends the session: the cookie is removed from the browser. func Logout(ctx *muzak.Context, _ muzak.Empty) (muzak.Empty, error) { ctx.Session().Destroy() return muzak.Empty{}, nil } ``` ```go [schemas/login.go] // LoginOut reports who signed in. The session travels in its cookie, // encrypted and out of reach of scripts, and is deliberately not repeated // in the body, where any script on the page could read it. type LoginOut struct { Username string `json:"username"` Next string `json:"next,omitzero"` } ``` ```go [routers/login.go] func Auth() *muzak.Router { r := muzak.NewRouter(muzak.WithTags("auth")) r.Get("/login", handlers.LoginForm, muzak.Summary("Serve the sign-in form")) r.Post("/login/", handlers.Login, muzak.Summary("Exchange a username and password for a session"), muzak.WithResponseDoc(http.StatusUnauthorized, "The username or password is incorrect"), // Stricter than the rest of the application, because guessing a // password is the one request worth making a hundred times a minute. muzak.RateLimit(muzak.Quota{Name: "login", Window: time.Minute, Limit: 5})) r.Post("/logout/", handlers.Logout, muzak.Summary("End the session")) return r } ``` ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ // Sessions, kept in a cookie encrypted under the configured secrets. Sessions: &muzak.SessionOptions{Secrets: settings.SessionSecrets}, // A session cookie rides along on requests other sites make, so a // state-changing request from another origin is refused. Sessions // cannot be configured without it. CrossOriginProtection: &muzak.CrossOriginOptions{}, }) ``` :: The rate limit on a sign-in route is not optional in practice. See [Rate Limiting](/docs/techniques/rate-limiting) for why the count belongs before the guards. The cookie the session travels in is named `__Host-session` and is sent `HttpOnly`, `Secure` and `SameSite=Lax` without being asked; see [Sessions](/docs/security/sessions). A form is the request another site can make a browser send, which is why sessions require `AppOptions.CrossOriginProtection`: a form posted from another origin is refused with `403` before the handler runs. See [Cross-Origin Requests](/docs/security/cross-origin-requests). ## Returning 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 [handlers/login.go] // LoginForm serves the page that posts to Login. // // It is a plain form with no enctype, so the browser posts it as // application/x-www-form-urlencoded. The route accepts that without being told // to, because it binds form values and no files. func LoginForm(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) { return muzak.HTML(`
`), nil } ``` Everything else about the route is unchanged: status, headers, cookies and errors work exactly as they do for a JSON route. ### Escaping The value is written as given. Muzak does not escape it, because it cannot tell markup the handler meant from text it did not. Anything a client supplied has to be escaped by the handler. ```go func Greeting(ctx *muzak.Context, in GreetIn) (muzak.HTML, error) { return muzak.HTML("

Hello, " + html.EscapeString(in.Name) + "

"), nil } ``` For anything larger than a fragment, use `html/template`, which escapes by construction: ```go var page = template.Must(template.ParseFiles("templates/profile.html")) func Profile(ctx *muzak.Context, in ProfileIn) (muzak.HTML, error) { var buf strings.Builder if err := page.Execute(&buf, in); err != nil { return "", err } return muzak.HTML(buf.String()), nil } ``` Parse the templates once and publish the set with `muzak.WithSingleton` or `muzak.Singleton` rather than parsing per request. See [Dependencies](/docs/getting-started/dependencies). ## Redirecting after a post ```go func Login(ctx *muzak.Context, in schemas.LoginIn) (muzak.Empty, error) { // ... establish the session ... ctx.SetHeader("Location", in.Next) ctx.SetStatus(http.StatusSeeOther) return muzak.Empty{}, nil } ``` `Next` came from the client, so treat it as input: validate it against a set of known destinations, or require it to be a relative path, before putting it in a `Location` header. A handler that returns `muzak.Redirect{To: in.Next}` has the second check made for it: a target that is not a path on this origin is refused with a `500` and never sent, and a redirect answering a `POST` is a `303` by default. See [Files, Streams and Redirects](/docs/techniques/files-streams-redirects). ## Body limits What bounds a form body depends on what the route binds: | The input binds | Bounded by | Default | |---|---|---| | a `file` field | `MaxUploadSize` | 32 MiB | | `form` fields only, urlencoded | `MaxBodySize` | 1 MiB | | `form` fields only, multipart | `MaxBodySize` | 1 MiB | A login or sign-up form that binds only `form:` values used to take its `multipart/form-data` body under the 32 MiB upload limit, so `net/http` held up to 20 MiB of text values (about 49 MiB of heap per request in flight) before any rule could run, and a file sent to it, which nothing reads, was spooled to disk first. A form-only route that legitimately receives more than `MaxBodySize` as multipart must raise `MaxBodySize` for it: ```go r.Post("/import", handlers.Import, muzak.MaxBodySize(8<<20)) ``` A body that declares a `Content-Length` over the limit is refused before any of it is read. A route that declares a `file:` field keeps `MaxUploadSize`, declared once on the router that holds those routes: ```go r := muzak.NewRouter( muzak.WithTags("uploads"), muzak.MaxUploadSize(32<<20), muzak.MaxFileSize(10<<20), ) ``` A malformed form body is answered with a `422` and is not written to the error log: the parse error quoted the bad part header whole, so a 1 MiB request wrote a 5 MB log line. ## Testing a form route ```go func TestLogin(t *testing.T) { client := testclient.New(t, buildApp()) form := url.Values{"username": {"muzak"}, "password": {"correct-horse-battery"}} res := client.Post("/login/", testclient.Body( "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))) res.AssertStatus(http.StatusOK) isSession := func(c *http.Cookie) bool { return c.Name == "__Host-session" } if !slices.ContainsFunc(res.Cookies, isSession) { t.Error("want the session cookie") } } ``` ## Where to go next [File Uploads](/docs/techniques/file-uploads) covers the other half of a multipart body, and [Sessions](/docs/security/sessions) covers the session a sign-in establishes. -------------------------------------------------------------------------------- title: "File Uploads" description: "Bind an uploaded file into a typed field, choose between bytes in memory and a handle to the content, and bound what a client can send." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/file-uploads" -------------------------------------------------------------------------------- # File Uploads A field tagged `file:"name"` is bound from the part the client sent under that name, and its Go type decides what the handler is handed. | Field type | What the handler gets | |---|---| | `[]byte` | The content read straight into memory | | `muzak.File` | The metadata, with the content left where the parser put it | | `[]muzak.File` | Every file sent under the name | | `[][]byte` | The content of every file sent under the name | ```go [schemas/uploads.go] // FileBytesIn binds one upload straight into memory, which suits a file small // enough that holding all of it at once is not a decision worth thinking // about. type FileBytesIn struct { File []byte `file:"file" doc:"A file read as bytes"` } // UploadFileIn binds one upload alongside a form value, which is what an HTML // form with a file input and a text input sends. type UploadFileIn struct { File muzak.File `file:"file" doc:"A file read as an upload"` Note string `form:"note" doc:"An optional note filed with the upload" required:"false"` } // MultiUploadIn binds every file sent under one name. type MultiUploadIn struct { Files []muzak.File `file:"files" doc:"One or more files"` } ``` A file is body content, so it is **required by default**. Mark an optional one `required:"false"`. ## Handlers ```go [handlers/uploads.go] // FileSize reports the size of a file read into memory. Binding it as []byte is // what makes that the whole handler. func FileSize(ctx *muzak.Context, in schemas.FileBytesIn) (schemas.FileOut, error) { return schemas.FileOut{FileSize: len(in.File)}, nil } // UploadFile reports what a client sent with one file. Nothing is read here: // muzak.File carries the metadata and leaves the content where the parser put // it, so a handler that only needs the name never touches the bytes. func UploadFile(ctx *muzak.Context, in schemas.UploadFileIn) (schemas.UploadFileOut, error) { return schemas.UploadFileOut{ Filename: in.File.Filename, ContentType: in.File.ContentType, Size: in.File.Size, Note: in.Note, }, nil } // UploadFiles reports every file sent under one name. func UploadFiles(ctx *muzak.Context, in schemas.MultiUploadIn) (schemas.MultiUploadOut, error) { out := schemas.MultiUploadOut{Filenames: make([]string, len(in.Files))} for i, file := range in.Files { out.Filenames[i] = file.Filename out.TotalSize += file.Size } return out, nil } ``` ## Working with `muzak.File` ```go type File struct { // Filename is the name the client reported for the file. It is arbitrary // client-supplied text and must never be used as a path, a database key or // anything else with meaning on the server without being checked first. Filename string // ContentType is the media type declared for the part, which is likewise // what the client claimed rather than what the bytes contain. ContentType string // Size is the number of bytes the file holds. Size int64 } ``` | Method | What it does | |---|---| | `Open()` | A reader over the content, positioned at the start. The caller owns and must close it. Opening more than once is allowed, and each reader has its own position | | `Bytes()` | The whole file in memory, as a fresh slice that stays valid after the request ends | | `Save(path)` | Copies the file to a path, creating or truncating it, and reports the bytes written | | `Header()` | The MIME headers of the part, for the occasional client that sends more than a filename and a content type | | `Present()` | Whether a file was uploaded at all. Only ever false for a field marked `required:"false"` | `Open` and `Bytes` report `muzak.ErrNoFile` when nothing was uploaded. ```go func StoreAvatar(ctx *muzak.Context, in AvatarIn) (AvatarOut, error) { user := muzak.From[core.CurrentUser](ctx) source, err := in.Avatar.Open() if err != nil { return AvatarOut{}, err } defer source.Close() // The destination is built from a directory the server controls and a name // the server generates. in.Avatar.Filename is never part of a path. name := uuid.NewV4().String() + extensionFor(in.Avatar.ContentType) destination := filepath.Join(storageDir, user.Username, name) written, err := in.Avatar.Save(destination) if err != nil { return AvatarOut{}, err } return AvatarOut{Name: name, Size: written}, nil } ``` ## Lifetime A part larger than 10 mebibytes spills to a temporary file while the body is parsed, and every temporary file is removed once the handler returns. `Open` and `Bytes` are therefore only valid while the handler runs: anything that must outlive the request has to be copied out of it first, with `Save` or otherwise. The removal happens at the end of **every** request, whoever parsed the form. A guard or a handler that read the form through `ctx.Request()` parsed it on a copy of the request that `net/http`'s own cleanup never saw, and every such upload stayed on disk. That threshold is deliberately well below the default upload limit, so a handful of concurrent large uploads cannot be turned into memory pressure. ## Nothing the client sends is trusted `Filename` and `ContentType` are text the client chose. A client may send `../../etc/passwd`, a name that means something to the local filesystem, or a `Content-Type` that has nothing to do with the bytes behind it. - Build a destination from a directory the server controls and a name the server generates. - Sniff the content if the type matters, rather than believing the declaration. - Echo the filename back if you like, but never route on it. - Serve a stored upload back with `muzak.FileResponse`, which takes its type from the extension and never from the content. See [Files, Streams and Redirects](/docs/techniques/files-streams-redirects) for what a browser does with a file whose type a client chose. ## Limits Two limits bound what a route accepts, and both are declared rather than remembered. ```go [routers/uploads.go] // Uploads returns the router for the endpoints that accept files. // // The two limits are declared once for the whole router, so a route added here // later cannot forget them: MaxUploadSize bounds what the server will read at // all, and MaxFileSize bounds any single file inside it. func Uploads() *muzak.Router { r := muzak.NewRouter( muzak.WithTags("uploads"), muzak.MaxUploadSize(32<<20), muzak.MaxFileSize(10<<20), ) r.Get("/upload", handlers.UploadForm, muzak.Summary("Serve a form that posts files")) r.Post("/files/", handlers.FileSize, muzak.Summary("Report the size of a file read as bytes")) r.Post("/uploadfile/", handlers.UploadFile, muzak.Summary("Report what was sent with one file")) r.Post("/uploadfiles/", handlers.UploadFiles, muzak.Summary("Report what was sent with several files")) return r } ``` | Limit | Applies to | Default | Behaviour when exceeded | |---|---|---|---| | `MaxUploadSize` | The whole form body of a route that declares a `file` field | `DefaultMaxUploadSize`, 32 MiB | `413`: from the declared `Content-Length` before any of it is read, or while the body is being read, so the server never buffers more than the limit | | `MaxFileSize` | Any single file in it | unset, so each file is bounded only by the upload limit | `413` before the handler runs | Both are settable application-wide through `AppOptions.MaxUploadSize` and `AppOptions.MaxFileSize`, and both are shared options, so a router or a single route can narrow them. A route that declares a `file` field uses `MaxUploadSize` in place of `MaxBodySize`, because an upload is expected to be larger than a JSON document and the two limits should not have to be traded off against each other. A route that binds only `form` values, and no file, is bounded by `MaxBodySize` for its urlencoded **and** its multipart body: it has nothing to spool to disk, and a file sent to it, which nothing reads, is not worth 32 MiB. See [Forms and HTML](/docs/techniques/forms-and-html#body-limits). The refusal names the field rather than the client's filename, which is text the client chose and would otherwise be echoed straight back: ```json { "error": { "code": "payload_too_large", "message": "a file uploaded as \"file\" exceeds the 10485760 byte limit for a single file on this route", "status": 413 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` ## A form that posts them ```go [handlers/uploads.go] // UploadForm serves the page that posts to UploadFiles. Returning muzak.HTML // is what bypasses JSON encoding; the document is written as it stands. func UploadForm(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) { return muzak.HTML(`
`), nil } ``` A route that binds any file accepts `multipart/form-data` only, which is why the form above carries an `enctype`. A route binding form values and no files also accepts `application/x-www-form-urlencoded`. See [Forms and HTML](/docs/techniques/forms-and-html). ## Validating an upload Form values alongside a file validate like anything else, and a rule on the file itself is a `Value` rule. ```go func (in *UploadFileIn) Validate(v *muzak.Validation) { v.String(&in.Note).Trim().MaxLen(280) v.Value(&in.File).Must(func(f muzak.File) error { switch f.ContentType { case "image/png", "image/jpeg", "image/webp": return nil default: return errors.New("must be a PNG, JPEG or WebP image") } }) } ``` A declared content type is a hint, not proof. Where it matters, read the first bytes and check them. ## Testing an upload ```go func TestUploadFile(t *testing.T) { client := testclient.New(t, buildApp()) var body bytes.Buffer form := multipart.NewWriter(&body) part, err := form.CreateFormFile("file", "notes.txt") if err != nil { t.Fatalf("CreateFormFile = %v", err) } if _, err := part.Write([]byte("hello")); err != nil { t.Fatalf("Write = %v", err) } if err := form.Close(); err != nil { t.Fatalf("Close = %v", err) } res := client.Post("/uploadfile/", testclient.Body(form.FormDataContentType(), &body)) res.AssertStatus(http.StatusOK) res.AssertJSON(`{"filename":"notes.txt","content_type":"application/octet-stream","size":5,"note":""}`) } ``` ## Where to go next [Forms and HTML](/docs/techniques/forms-and-html) covers the rest of a multipart body, and [Static Files and Frontends](/docs/techniques/static-files) covers serving files back out. -------------------------------------------------------------------------------- title: "Static Files and Frontends" description: "Serve a built frontend and a directory of assets from the same binary as the API, with routes always matched first." version: "0.3.1" last_updated: "2026-10-10T11:16:37.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/static-files" -------------------------------------------------------------------------------- # Static Files and Frontends Two mounts, one machinery. `Router.Frontend` serves the output of a frontend build and resolves a path with no file behind it; `Router.Static` serves a directory and lets a miss stay a miss. ```go [cmd/main.go] // Assets that belong to no particular route. A static mount serves what it // finds and nothing else, so a miss here stays a miss rather than being // answered with the application document by the frontend below. app.Static("/static", muzak.StaticOptions{Dir: "static"}) // The built frontend is served last: every route above is matched first, so // mounting at the root cannot shadow the API. app.Frontend("/", muzak.FrontendOptions{Dir: "dist"}) ``` Nothing is rendered on the server and nothing is built here. Both serve files that already exist, which is what `npm run build` and its equivalents produce. ## Routes win A request is matched against every registered route first and reaches a mount only when none of them answered, so mounting a frontend at `/` cannot shadow an API. Middleware still applies, and so does what the router the mount was registered on would run before a route's handler: its **rate limit**, then the **security requirements** it declared with `WithSecurity` when they name a verifying scheme (one built by `JWTBearer` or `APIKeyVerifier`), then its **guards**, then every `Needs` and `Singleton` provider it declares. An error from any of them is rendered exactly as it would be for a route, which is what lets a frontend sit behind the same authentication and the same budget as everything else. A router declared `Public()` exempts its mounts from a verifying scheme named above it, while the guards they inherit still run; see [Authentication](/docs/security/authentication). ```go ui := muzak.NewRouter(muzak.WithDependencies(core.RequireSession)) ui.Frontend("/", muzak.FrontendOptions{Dir: "dist"}) app.Include(ui) ``` A mount used to run only the guards it inherited, so a router authenticated with `Needs(...)` served its files to anyone, and no quota counted a file request. To keep assets public, put them on a router without those options. To keep a mount outside a quota, use `SkipRateLimit()`. A mount that inherits any guard or provider sends `Cache-Control: private, no-cache` on every response, replacing one the file would have carried, so a cache shared between clients keeps none of them. Mounting under a prefix works the way everything else does, through the router the mount is registered on: ```go ui := muzak.NewRouter() ui.Frontend("/", muzak.FrontendOptions{Dir: "dist"}) app.Include(ui, muzak.WithPrefix("/app")) ``` ## Serving a frontend ```go app.Frontend("/", muzak.FrontendOptions{Dir: "dist"}) ``` A request for a path with no file behind it falls back to one, resolved from what the build actually produced: 1. A `404.html` in the frontend's root is served with `404`. 2. Failing that, an `index.html` is served with `200`, but only for a `GET` or `HEAD` that asks for HTML, which is what a browser navigation does. That second rule is what a client-side router needs in order to take over. The restriction to navigations is what keeps a missing script or stylesheet answering `404`, because handing those an HTML document only turns a missing file into a confusing parse error somewhere further from the cause. A directory is served by its `index.html` with or without a trailing slash, but a file only without one. `/app.js/` names a directory that is not there, so it is a path with no file behind it like any other, rather than a second URL for the file against which every relative reference in it resolves somewhere else. | Option | Effect | |---|---| | `Dir` | The directory holding the build, or the subdirectory within `FS` when both are set | | `FS` | Serve from an `io/fs.FS` rather than from disk | | `Fallback` | Name the file served with `200` for a navigation, instead of resolving it | | `NotFound` | Name the file served with `404`. It takes precedence over `Fallback` and applies to any `GET` or `HEAD` | | `NoFallback` | Serve a plain `404` for anything with no file behind it | | `SkipCheck` | Do not verify the directory when the application is built, for one something else fills in later | | `AllowDotfiles` | Serve files and directories whose name begins with a dot | Since the answer depends on `Accept` when there is a fallback and no `NotFound` page, every response of such a mount carries `Vary: Accept`, so a cache does not hand one to the other. ## Shipping the frontend inside the binary ```go [cmd/main.go] //go:embed all:dist var assets embed.FS func main() { app := muzak.New(muzak.AppOptions{Title: "Awesome API"}) app.Include(routers.Items()) app.Frontend("/", muzak.FrontendOptions{FS: assets, Dir: "dist"}) log.Fatal(app.RunSignals()) } ``` Setting both reads `Dir` as a subdirectory of `FS`, which is what the directory embed above produces. The deployment is then one file. Use `all:dist` rather than `dist`, or the embed skips files whose names begin with `.` or `_`, which is exactly what several build tools emit. ## Serving assets ```go app.Static("/static", muzak.StaticOptions{Dir: "static"}) ``` `Static` is the same machinery without the part that makes a frontend work: nothing stands in for a path with no file behind it, so a miss is a `404` and stays one. | Option | Effect | |---|---| | `Dir` | The directory holding the files, or the subdirectory within `FS` | | `FS` | Serve from an `io/fs.FS`, which is what an `embed.FS` of assets looks like | | `Index` | Serve a directory with the `index.html` inside it, as a web server does for a site of pages | | `SkipCheck` | Do not verify the directory when the application is built | | `AllowDotfiles` | Serve files and directories whose name begins with a dot | | `AllowActiveContent` | Serve HTML, SVG and other XML as ordinary documents rather than sandboxed | `Index` is off by default, because a mount of scripts and stylesheets has no index and asking for a directory is a mistake worth reporting. Reach for `Static` to publish assets, and for `Frontend` to serve an application whose routing happens in the browser. Two neighbours cover what neither does. A single file a handler chooses, such as an export a user asked for, is a route that returns `muzak.FileResponse`, served with ranges and conditional requests and typed by its extension as a mount's files are; see [Files, Streams and Redirects](/docs/techniques/files-streams-redirects). A handler written for `net/http` rather than a directory, such as pprof or a metrics endpoint, is served with `Router.Mount`, behind the same rate limit, guards and providers as a route; see [Mounting net/http Handlers](/docs/techniques/mounting-handlers). ## What both refuse | A request that... | ...gets | |---|---| | asks for a directory | never a listing. A `Static` mount answers its `index.html` only when `Index` asked for it | | names a file followed by a slash (`/app.js/`) | a miss, as any path with no file behind it: `404`, or from a `Frontend` the 404 page or the `index.html` fallback for a navigation | | names a file or directory beginning with a dot, anywhere in the path | `404`, with no fallback offered in its place | | follows a symbolic link out of the mounted directory | refused | | reaches the directory of a mount beneath this one, by any name | `404` | | uses a method other than `GET` or `HEAD` on a file that exists | `405`, rather than the file | | arrives before a `SkipCheck` directory exists | `500`, with the reason logged | | names a file that vanishes, or cannot be read, after it was found | `404` or `500`, rendered by the error renderer like any other failure, problem details included, and the request counts as failed | A directory that does not exist is reported when the application is built rather than on the first request, unless `SkipCheck` asked otherwise. Two mounts at one path are a build error: a second `Static` or `Frontend` at a path used to lose to the first without a word, guards included. Replacing the directory while the application serves it goes as each system lets it. On Unix the mount holds the directory it opened, which keeps nothing from renaming or removing it, and serves that directory until the application restarts, so a new build renamed into its place is served once the application is restarted with it. On Windows an open directory cannot be renamed or removed, so nothing holds it between requests: each file is reached through the directory opened by its path for that file and closed once the file is, and a new build renamed into place is served from the next request. ### Dotfiles A path with any segment starting with `.` (`/.env`, `/.git/config`) answers `404`, as though the file were not there, and the fallback page is not offered in its place. A build output or a deployment directory is where a `.env`, a `.git` directory, an `.htpasswd` or an editor's swap file ends up by accident, and serving one publishes credentials and source history to anyone who guesses the name, which every scanner does. A leading `/.well-known/` is the one exception, because that is where a site publishes files meant to be found. Set `AllowDotfiles` for a directory whose dotfiles are all meant to be public. On Windows a path segment shaped like an 8.3 short name, such as `/ENV~1`, is refused whatever the options say, because the filesystem opens the long name it stands for without any check having seen that name. ### What a file is served as A file's type comes from its extension and **never from its content**, so a file with no extension, or an unknown one, is `application/octet-stream`. Sniffing let an upload stored without an extension be served as `text/html`, and an `.svg` served inline ran a client's markup as script on the API's own origin. A `Static` mount serves HTML, SVG, XHTML and other XML under `Content-Security-Policy: sandbox`, so a browser that opens one directly shows it in an isolated origin with scripts, forms and plugins turned off. An image referenced from a page is not affected. Set `AllowActiveContent` for a static site whose scripts must run, such as a site of pages served with `Index`. A `Frontend` mount does not sandbox, because its documents are the application itself, and a single page application belongs there. A file larger than 8 MiB from a filesystem whose files cannot seek is streamed, with its size as `Content-Length` when known and `Last-Modified`, and without range support. Smaller ones are buffered, so range and conditional requests keep working for them. ### Symbolic links, and mounts inside mounts The promise that a symbolic link cannot lead out of the directory is kept by `Dir` and by the `FS` of an `os.Root`, and **not** by `os.DirFS`, which follows a link wherever it points, so a link planted in a directory served through it serves the file it names. To serve a directory on disk, set `Dir`, or pass `os.OpenRoot(...).FS()`. A link that stays inside the directory is followed and its target served by the mount that owns the directory. A deployment step that links files into a served directory (`cp -l`, `rsync -H`, a build cache) therefore publishes them under the new name, dotfiles included, unless the link leads into the directory of a mount beneath. When mounts nest, the most specific one answers, and **a directory that has a mount of its own is reachable only through that mount**, under any name. Suppose a guarded mount at `/admin` serves `site/admin` beside a public mount at `/` over `site`. A request that reaches the inner directory through the outer mount gets `404`, whether the path differs in case (`/ADMIN/x`, on a case-insensitive filesystem), uses `\` as a separator, goes through a symbolic link, or reaches the directory by a mount path that differs from the directory's name (a guarded mount at `/x` over `site/staff`, beside a public mount at `/` over `site`, no longer serves the same files at `/staff/...`). The fallback page is not offered in its place. The outer mount compares each directory a path passes through with the directory of every mount beneath it **by file identity**, so the answer is exact on every platform and for every folding rule (a ligature, a sharp s spelled out, a composed or decomposed accent), with no table to keep, and hard links and bind mounts are covered because only the directory's identity matters. Where both directories are given as `Dir` the relationship is worked out when the application is built. A mount served from an `os.DirFS` is compared against every mount beneath it, since its path cannot be read back, and one served from an `embed.FS` has no second name to defend against. A request the inner mount already answers is unaffected, and so is every mount with no mount beneath it. To publish the same files at two paths, mount them at both. ## A single binary, front to back ``` awesome-api/ |-- cmd/ | `-- main.go |-- dist/ what the frontend build wrote | |-- index.html | |-- 404.html | `-- assets/ |-- static/ assets that belong to no route | `-- logo.svg |-- routers/ |-- handlers/ |-- schemas/ `-- core/ ``` ```go [cmd/main.go] app.Include(routers.Users()) app.Include(routers.Items()) app.Static("/static", muzak.StaticOptions{Dir: "static"}) app.Frontend("/", muzak.FrontendOptions{Dir: "dist"}) ``` `GET /users/` reaches the API. `GET /static/logo.svg` reaches the asset. `GET /dashboard` reaches no route and no file, so `index.html` is served with `200` and the browser's router takes it from there. `GET /assets/app-4f2a.js` with nothing behind it answers `404`, so a stale hashed asset fails loudly instead of returning HTML. ## Where to go next [Compression](/docs/techniques/compression) covers making those assets smaller, and [Server Configuration](/docs/deployment/server-configuration) covers deploying the binary that holds them. -------------------------------------------------------------------------------- title: "Compression" description: "Negotiate gzip or deflate in one line, leave alone what compressing would not help, and know the one case where compression and secrecy interact badly." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/compression" -------------------------------------------------------------------------------- # Compression ```go app.Use(muzak.Compress(muzak.CompressionOptions{})) ``` That is the whole installation. The middleware negotiates an encoding from `Accept-Encoding`, compresses the bodies worth compressing, and records `Vary` on every response either way. Install it with `App.Use`, which puts it inside the built-in chain, so it already has a request identifier and is already covered by panic recovery. ## What is negotiated gzip is preferred over deflate, and an explicit refusal such as `gzip;q=0` is honoured. A client that asks for neither gets an uncompressed body. `deflate` is answered in the **zlib** format, which is what RFC 9110 defines the coding to be: zlib around DEFLATE. The bare DEFLATE stream sent before was refused by strict decoders. A client that decodes it as raw DEFLATE, which was only ever a lenient guess, should decode zlib; every conforming client already does. A quality is read only between 0 and 1. `q=NaN`, `q=Inf` and `q=1e9` used to parse as valid, so an infinity outranked every honest range and NaN made the ordering meaningless. Now NaN is unreadable (one, as before), anything above one is one, and a negative value is a refusal. `Vary: Accept-Encoding` is set on every response whether or not it was compressed, so a cache cannot hand a compressed body to a client that cannot read it. ## What is left alone | A response that... | ...is not compressed | |---|---| | is smaller than `MinSize` | a body that already fits in one packet cannot arrive sooner by shrinking | | carries a media type that is not text-like | an image, a video or an archive is already compressed | | the handler encoded itself | a `Content-Encoding` is already set | | carries no body | there is nothing to compress | | is a range | a compressed range is not the range that was asked for | | is an event stream | holding events in a compressor's window until something forces them out is the one thing a stream cannot survive | `muzak.DefaultCompressionMinSize` is 1400 bytes, a little under one ethernet MTU. A response that already fits in a single packet cannot be made to arrive sooner by shrinking it, and compressing it spends CPU on both ends to save nothing. ## Entity tags and ranges A compressed response is a different representation from the one the handler described, and its validators are adjusted to say so. A strong `ETag` is weakened, `"v1"` to `W/"v1"`, on a compressed `200` and on a `304` answering a client that negotiated an encoding. A cache can then use the `304` to refresh the response it stored under the weak tag, which a strong tag in the `304` would not let it do. The weak tag still corresponds to a stored response that was not compressed, because tags correspond by weak comparison. The tag need not be one the handler set. `muzak.AutoETag()` gives a route a strong tag computed from its body and answers a client that already holds the body with `304`. The two compose: `Compress` weakens the tag of a body it compresses, and `AutoETag` compares `If-None-Match` weakly, so the weakened tag a client stored still earns the `304`. See [Files, Streams and Redirects](/docs/techniques/files-streams-redirects). `If-Match` is compared strongly, so a weak tag in it never matches, and a client that read a compressed response would have every conditional write refused with `412`. The `W/` is therefore taken off the tags of an incoming `If-Match` before the handler sees it. A handler that compares as RFC 9110 asks gets the right answer. One that issues weak tags of its own and compares `If-Match` by string equality, which RFC 9110 does not allow, now sees their strong form, and should compare the opaque tag instead. A compressed response does not advertise `Accept-Ranges`, since no range of the bytes it carries can be asked for. A `Range` request is answered from the uncompressed content, uncompressed. When an encoding was negotiated, a `Range` whose `If-Range` carries a date or a weak tag is answered with the whole content instead. A date cannot say whether the client holds the compressed bytes or the uncompressed ones, and a client appending a range of one to the other ends up with neither; a weak tag never matches `If-Range` at all. A `Range` with no `If-Range`, or with a strong tag, is still answered with an uncompressed range. A client that resumes a download through `Compress` sends a strong `ETag` in `If-Range`, or asks for no encoding along with its `Range`, as Go's client and most download tools already do. ## Options ```go app.Use(muzak.Compress(muzak.CompressionOptions{ Level: muzak.CompressionBest, MinSize: 2048, ContentTypes: []string{"text/", "application/json", "+json", "image/svg+xml"}, })) ``` | Field | Default | Effect | |---|---|---| | `Level` | `CompressionDefault` | How hard the compressor works | | `MinSize` | `DefaultCompressionMinSize` | The smallest body worth compressing. A response whose length is not known in advance is buffered up to this size before the decision is made | | `ContentTypes` | a built-in list of text-like types | Which media types are compressed | | Level | For | |---|---| | `CompressionDefault` | Balances speed against size, which is the right choice until a measurement says otherwise | | `CompressionFastest` | A service that is CPU bound, or serving large bodies to a fast network | | `CompressionBest` | A service whose clients are on slow or metered connections | There are three levels rather than an integer because the underlying range has invalid values in it and there is nothing useful to do with a level of 42 at run time. An entry in `ContentTypes` is matched as a prefix, so `text/` covers every text type, and an entry beginning with `+` matches a structured syntax suffix, so `+json` covers `application/problem+json`. ## What it is worth On the framework's own example application, compression takes 91% off the OpenAPI document and 68% off the documentation page. A JSON listing of any size behaves much the same. A small response, an image and an event stream are all untouched, which is the point of the exclusions above. ## Compression and secrecy Compression and secrecy interact badly, and it is worth knowing where. When a response mixes a secret with something the client controls, its compressed length leaks how much the two have in common. That is what the BREACH attack recovers a token from, over many requests. Muzak's own responses do not mix the two, but a handler that reflects a query parameter back alongside a CSRF token does. Where that is possible, either leave compression off for the route or stop reflecting the input. ## Ordering with your own middleware Middleware installed first is outermost, so a middleware that reports a duration sees the time spent compressing only if it is installed before the compressor. ```go [cmd/main.go] // ProcessTime is outermost of the two, so the duration it reports includes the // time spent compressing. app.Use(core.ProcessTime()) app.Use(muzak.Compress(muzak.CompressionOptions{})) ``` Anything that wraps the response writer needs to implement `Unwrap() http.ResponseWriter`, or it will break flushing and hijacking for everything inside it. See [Middleware](/docs/getting-started/middleware). `Compress` implements `Hijack` itself, so a handler beneath it that takes the connection over through `http.ResponseController` gets the connection with nothing written onto it afterwards: no held header, no flushed bytes, and no compressor closed onto a socket that has stopped speaking HTTP. The compressor reports a failed flush: it carries `FlushError`, so `http.ResponseController` can tell a stream that its client has gone, where a bare `Flush()` returned nothing whatever became of the bytes underneath. A panic below `Compress` is answered with a `500` and not an empty `200`; its deferred finish used to send the pending header while the panic unwound, so recovery could no longer write its error. ## Checking it ```bash curl -s -H 'Accept-Encoding: gzip' -o /dev/null -D - http://localhost:8080/openapi.json ``` ``` HTTP/1.1 200 OK Content-Type: application/json Content-Encoding: gzip Vary: Accept-Encoding ``` ```go func TestOpenAPIIsCompressed(t *testing.T) { client := testclient.New(t, buildApp()) res := client.Get("/openapi.json", testclient.Header("Accept-Encoding", "gzip")) res.AssertStatus(http.StatusOK) res.AssertHeader("Vary", "Accept-Encoding") } ``` ## Where to go next [Static Files and Frontends](/docs/techniques/static-files) covers the assets this makes smaller, and [Server-Sent Events](/docs/realtime/server-sent-events) covers the one response kind compression skips on purpose. -------------------------------------------------------------------------------- title: "Rate Limiting" description: "A policy of several quotas counted together, a storage you choose, a tracker that decides whose budget is spent, and defaults that fail closed." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/rate-limiting" -------------------------------------------------------------------------------- # Rate Limiting Rate limiting is built in and off until a policy names a quota. There is no limit that is right for every application, and a default one would be a number nobody chose refusing traffic nobody expected. What is safe by default is what happens once a policy exists: the counters are bounded, the address is not taken from a header anyone can write, and a storage that stops answering stops traffic rather than stopping the limit. ## A policy is several quotas ```go type Quota struct { Name string // the namespace its counters live under, reported to clients Window time.Duration // how long one counting period lasts Limit int // how many requests are allowed within one window } ``` One number cannot tell a burst from sustained abuse. Three requests a second is generous for a person clicking and impossible for a script; a hundred a minute is the reverse. A policy that means "quick but not tireless" needs both. ::code-group ```go [core/ratelimit.go] // RateLimitPolicy is the application-wide budget every route inherits. // // Three windows rather than one, because a single number cannot tell a person // clicking from a script that never stops. The quotas are counted longest // window first, so a client that overruns the short window has already accrued // against the long one. func RateLimitPolicy() muzak.RateLimitOptions { return muzak.RateLimitOptions{ Tracker: UserOrIPTracker, Quotas: []muzak.Quota{ {Name: "short", Window: time.Second, Limit: 3}, {Name: "medium", Window: 10 * time.Second, Limit: 20}, {Name: "long", Window: time.Minute, Limit: 100}, }, } } ``` ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{Title: settings.AppName, Addr: settings.Addr}, muzak.WithDependencies(core.GetQueryToken), // The application-wide budget, counted before the guard above runs so that // a request the guard rejects still costs the client something. muzak.WithRateLimit(core.RateLimitPolicy()), ) ``` :: The quotas are counted longest window first, whatever order they are declared in, and the first to refuse a request ends the count. A request refused by the short quota has already been counted against the long one, so pausing between bursts launders nothing. A request the long quota refuses is not counted against the shorter ones, whose budget it would only waste, and `Retry-After` is the wait of the quota that refused. `RateLimit-Policy` still lists the quotas as they were declared. A window is **fixed**, not sliding. A client's counter starts with its first request and ends `Window` later, and the next request opens a new one, so a client can spend a whole `Limit` at the end of one window and another at the start of the next: up to twice `Limit` in a span of `Window`. The short quota in the policy above is what bounds that. A quota's `Name` is the namespace its counters are stored under, so two quotas that share a name share a budget and must agree on their window and limit. The application refuses to build when they do not. It must be a valid HTTP token, because it is reported to clients. ## What is counted A route counts against the policy it inherits. So does everything else that answers a request on the application's behalf: - A `Frontend` or `Static` mount applies the rate limit of the router it was registered on, ahead of its guards, so a file request costs budget like any other. Use `SkipRateLimit()` to keep a mount outside a quota. - A handler served with `Router.Mount` is counted the same way, against the policy of its routers and of the options given to `Mount`. See [Mounting net/http Handlers](/docs/techniques/mounting-handlers). - A tool call made through an `App.MCP` endpoint is served in-process by the route it calls, so it is counted against that route's policy, under the calling client's address, as a request to the route would be. The message that carries it is counted against the endpoint's policy as well. See [MCP Tools](/docs/techniques/mcp-tools). - The documentation page and `/openapi.json` are counted in the same budget as the routes. Once the docs ran the application's guards but not its limit, `/openapi.json` was a place to guess the application's token as fast as a client could send. Exempt it by passing `muzak.SkipRateLimit()` to `muzak.New`, or serve no documentation. - `OPTIONS` and `405` answers are **not** counted. They are answered from the route table alone, as `net/http.ServeMux` does, so they skip a route's guards and limit. Middleware installed with `App.Use` still covers them: put a limit there when the existence of a path is itself a secret. - The health endpoints of `AppOptions.Health` are **not** counted either. A probe is answered ahead of routing and of `App.Use` middleware, and runs no guard, provider or rate limit, since a probe that a quota refused would take the instance out of rotation. See [Health Checks](/docs/deployment/health-checks). ## Narrowing it ```go // A health check: exempt. A monitor polling every second is the one client that // should never be told to slow down. r.Get("/healthz", handlers.Health, muzak.SkipRateLimit()) // A login route: stricter. Guessing a password is the one request worth making // a hundred times a minute. r.Post("/login/", handlers.Login, muzak.RateLimit(muzak.Quota{Name: "login", Window: time.Minute, Limit: 5})) ``` `RateLimit` replaces the quotas and keeps the storage and tracker it inherits, which is the common case. `WithRateLimit` layers the whole options struct field by field on top of what an enclosing scope declared, so a route can change one thing without restating the rest. The quotas given **replace** those inherited rather than adding to them, so a route that wants both restates the ones it is keeping. An exemption cannot be undone by a narrower scope: once a router is exempt, every route beneath it is. ## When the count happens By default, **before** the route's guards and dependencies. That is the half that matters for brute force: a request a guard rejects is still counted, so failed sign-ins cost the attacker their budget rather than being free. It also means a client past its limit is refused before anything expensive runs on its behalf. `AfterDependencies` moves the count after them, which is what a tracker keying on a resolved identity needs, and gives the other half up. ```go [routers/items.go] r := muzak.NewRouter(muzak.WithTags("items"), // This is the one router whose routes resolve a caller, so it is the one // router where the budget is worth spending per caller rather than per // address. Deferring the count is what lets the tracker see the resolved // user; the quotas themselves are inherited unchanged. muzak.WithRateLimit(muzak.RateLimitOptions{AfterDependencies: true})) ``` Leave it off on a login route. A request rejected by a guard never reaches a limiter that defers, so a route that defers does not limit failed authentication at all, which is exactly the traffic a login route needs to limit. ## Limits that vary per caller A `Quota` is fixed when the application is built, and the same name may not carry two policies anywhere in it. That is right for a policy the application owns, and it cannot express one its customers do: a plan, a negotiated ceiling, a tier read from a database. `Resolver` supplies quotas per request. ```go [core/ratelimit.go] // The plan was resolved by a dependency, so the count has to happen after // them. It is read from whatever the dependency already fetched rather than // from the database, because this runs on every request. func PlanQuotas(ctx *muzak.Context) ([]muzak.Quota, error) { plan, ok := muzak.TryFrom[Plan](ctx) if !ok { // Nothing resolved, nothing enforced. An unauthenticated request // reaching this route is limited by the static quotas, if any. return nil, nil } return plan.Quotas, nil } ``` ```go [cmd/main.go] muzak.WithRateLimit(muzak.RateLimitOptions{ AfterDependencies: true, Tracker: core.UserOrIPTracker, Resolver: core.PlanQuotas, // A floor everyone shares, whatever their plan says. Quotas: []muzak.Quota{{Name: "burst", Window: time.Second, Limit: 20}}, }) ``` Resolved quotas are counted **alongside** the static ones, under their own names, in the same storage. A route can therefore carry both a floor everyone shares and a ceiling that varies, and `RateLimit-Policy` describes the union. Setting a resolver turns limiting on for the route even with no static quota, because what is enforced becomes a run time question. Returning nothing enforces nothing. A quota built at run time is not validated when the application is built, so one that breaks the rules a static quota follows fails the request with a `500` rather than being counted. Both paths share one check: a name that is not a valid token (a colon in `plan:x` for client `y` collides with `plan` for client `x:y` in the storage key recommended below, and a NUL does the same in the in-process storage), a zero window or limit, and a name repeated within one policy or repeating a static one. A repeated name used to count every request once per copy against one counter, so a limit of two refused the second request. Nothing compares a resolver's names against the ones declared statically beyond that, so a name used in both places must mean the same thing in both. ## Whose budget is spent `RateLimitTracker` is `func(ctx *muzak.Context) (string, error)`. It defaults to `muzak.IPTracker`, which keys on the address `Context.ClientIP` resolves. ```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. Keys from the two sources are // prefixed differently so that a username can never collide with an address. func UserOrIPTracker(ctx *muzak.Context) (string, error) { if user, ok := muzak.TryFrom[CurrentUser](ctx); ok { return "user:" + user.Username, nil } return muzak.IPTracker(ctx) } ``` A tracker that requires a credential can insist on one. Returning an error abandons the request, and the error becomes the response exactly as one returned from a handler would: ```go func APIKeyTracker(ctx *muzak.Context) (string, error) { key := ctx.Header("X-API-Key") if key == "" { return "", muzak.Unauthorized("an API key is required") } return "apikey:" + key, nil } ``` Two rules: the key must not be empty, and keys from different sources must be prefixed differently, so that a user identifier and an address can never collide into one budget. ### Addresses that are cheap to change `IPTracker` counts an IPv4 address exactly, under a key such as `ip:192.0.2.7`. An IPv6 address is counted by its **/56**, under a key such as `ip:2001:db8:1::/56`, because a client is delegated a whole range of IPv6 addresses and would otherwise get a fresh budget from every address in it. A /56 is what providers delegate to one home or small site (RFC 6177). Keying on the /64 still handed one subscriber 256 budgets: rotating the low bits of the fourth group of an address, all inside their own /56, turned a limit of 5 a minute into more than a thousand. The prefix is the one the per-client connection caps count, and one setting chooses both: `ClientIPOptions.ConnectionIPv6Prefix`, defaulting to 56, with `ConnectionIPv4Prefix` at 32. Any length from 32 to 128 is accepted. ```go muzak.AppOptions{ ClientIP: muzak.ClientIPOptions{ConnectionIPv6Prefix: 48}, } ``` - Where sites are delegated a /48, or the deployment is hostile to begin with, set 48. - Where many unrelated users share one /56 (a campus, or a carrier that gives each device a /64), set 64 to keep the old grouping. That also loosens the connection caps to match. - For one route only, `IPPrefixTracker` chooses its own lengths: 0 to 32 for IPv4 and 0 to 128 for IPv6. ```go muzak.RateLimitOptions{Tracker: muzak.IPPrefixTracker(32, 64)} ``` That keeps IPv4 addresses exact while collapsing an IPv6 source down to the allocation it actually came from. It panics if either length is out of range for its family, which is a mistake worth catching where the tracker is built rather than on the first request. `IPPrefixTracker(32, 128)` counts every IPv6 address on its own, which is only safe where something in front has already bounded them. A counter kept in a shared storage under an IPv6 client's old `ip:2001:db8:1:2::/64` key is not carried over, so IPv6 clients start with a full budget once after the upgrade. IPv4 keys are spelled as before. The address itself is only as trustworthy as the deployment makes it. A zone on an address (`fe80::1%eth0`) is dropped before a key is made, so one client cannot mint a key per zone suffix. See [Behind a Proxy](/docs/deployment/behind-a-proxy). ## Where the counters live ```go type RateLimitStorage interface { Increment(ctx context.Context, quota, key string, window time.Duration) (count int, reset time.Duration, err error) } ``` That is the whole of what the limiter needs from the outside world. ### The default, in memory An application that names no storage gets one that counts in the process that serves the requests. It is the right answer for a single process and the wrong answer for several: counters held in one process are not shared with the next, so a limit of a hundred a minute becomes a hundred a minute per process. The table is bounded in two directions, because it is keyed by something the client influences and an unbounded one would be a memory leak with a name. Expired counters are swept, a full table evicts the counter closest to expiring from the **quota the new counter belongs to**, and a tracker key the client chose the length of is hashed rather than truncated, so two clients cannot be merged into one budget. Only a quota holding no counter at all takes its first from the quota holding the most. Eviction used to be global, so flooding one quota with new keys, which is easy from one IPv6 range with a tracker that counts every address, evicted and so reset a per-account login limit. 0.2.8 took the counter from the quota holding the most, which held only while the flooded quota was the largest: a client could mint keys under a smaller quota keyed on something it chooses, such as an API key counted before any guard, and each key evicted the client's own spent counter in a larger per-address quota. Now a flood can only evict its own quota's counters. A full table therefore keeps each quota at the size it had when it filled, and a quota whose traffic grows afterwards gains room only as counters expire, so size `MaxEntries` for the keys the application really sees. A count saturates at the top of an `int` instead of wrapping under every limit, and a window near 292 years no longer makes `RateLimit-Reset` negative: seconds clamp to what a 32-bit int holds. ```go muzak.WithRateLimit(muzak.RateLimitOptions{ Storage: muzak.NewMemoryRateLimitStorage(muzak.MemoryRateLimitOptions{ MaxEntries: 10_000, SweepInterval: 30 * time.Second, }), Quotas: []muzak.Quota{{Name: "default", Window: time.Minute, Limit: 60}}, }) ``` `DefaultRateLimitMaxEntries` is 100000 and `DefaultRateLimitSweepInterval` is one minute. Naming a storage explicitly is only necessary to change those bounds. It implements `muzak.Lifecycle`, so it is started and stopped with the application, and stopping releases every counter it holds: a key is derived from whatever the tracker read, an address, a user identifier or an API key, and none of that should outlive the server that was counting it. `Len()` reports how many counters it holds, which is what a metric or a test asking whether anything is accumulating wants. ### A shared storage Anything running more than once wants counters the processes share. The interface is three arguments wide so that whatever you already run can satisfy it. ```go [core/redisratelimit.go] // Increment counts one request against a quota for one client. // // The increment and the expiry happen in one round trip, so that two requests // arriving together cannot both create the window. func (s *RedisRateLimitStorage) Increment( ctx context.Context, quota, key string, window time.Duration, ) (int, time.Duration, error) { // The script runs INCR, then PEXPIRE when the counter is new, then PTTL. res, err := s.script.Run(ctx, s.client, []string{"ratelimit:" + quota + ":" + key}, window.Milliseconds()).Result() if err != nil { return 0, 0, err } values := res.([]any) count := int(values[0].(int64)) reset := time.Duration(values[1].(int64)) * time.Millisecond return count, reset, nil } ``` ```go muzak.WithRateLimit(muzak.RateLimitOptions{ Storage: core.NewRedisRateLimitStorage(settings.RedisAddr), Quotas: core.RateLimitPolicy().Quotas, }) ``` Two rules an implementation must follow: - The count includes the request being counted, so the first request in a window returns one. - The window is how long a **newly created** counter should live. Never extend the life of a counter that already exists, because a limit whose window restarts on every request is a limit that never resets. The key is opaque and may contain any bytes. It is derived from client-supplied data and must never be logged, because it routinely carries an API key or a user identifier. If the implementation also satisfies `muzak.Lifecycle`, the application starts it before serving and stops it after draining, so a pool or a sweeper needs no separate registration. `Increment` **must return when its context ends**. The limiter cancels the context after `RateLimitOptions.StorageTimeout`, which is `muzak.DefaultRateLimitStorageTimeout` (2 seconds) unless it says otherwise. Every client library's context-taking call does return, so passing `ctx` through is all it takes. ## When the storage cannot answer By default the request is refused with `503` and `Retry-After: 5`, because a limiter that cannot count is a limiter that is not enforcing anything, and an attacker who can reach the storage can choose the moment it stops answering. The `Retry-After` is set even with `DisableHeaders`, so a refused client does not retry at once against a store trying to recover. ```go muzak.RateLimitOptions{FailOpen: true} ``` That trades the guarantee for availability: a storage outage lets traffic through unmetered instead of turning into an outage of its own. The failure is logged either way, without the key. A store that stops **answering** is a different failure from one that returns an error. A call that hangs never produces an error for `FailOpen` to act on, and used to hold every request that reached it until its client gave up. `StorageTimeout` bounds each call: one that outlives it has its context cancelled and counts as a storage failure, which is a `503` with `Retry-After: 5`, or an unmetered request with `FailOpen`. A negative value removes the bound. The process-local storage is called without a deadline. ```go muzak.RateLimitOptions{StorageTimeout: 500 * time.Millisecond} ``` With `FailOpen`, only the failing quota goes unmetered. The others in the policy still decide, so a request over the limit of a quota that answered is refused, and a store that fails for some counters only, one shard of a cluster for instance, does not stop every other limit of the route from holding. When no quota was counted at all, no `RateLimit` headers are sent. ## What a client sees Every counted request carries headers describing the budget, because a client that can see its own budget is a client that can stay inside it. | Header | Reports | |---|---| | `RateLimit-Limit` | The quota closest to being spent | | `RateLimit-Remaining` | How many requests are left in that quota's current window | | `RateLimit-Reset` | How many seconds until that window starts again | | `RateLimit-Policy` | Every quota the route enforces, as `limit;w=seconds` entries | | `Retry-After` | On a `429`, the wait of the quota that refused; on a storage `503`, five seconds | `RateLimit-Policy` is fixed for a route, so one response teaches a client the whole policy. ``` HTTP/1.1 429 Too Many Requests RateLimit-Limit: 3 RateLimit-Remaining: 0 RateLimit-Reset: 1 RateLimit-Policy: 3;w=1, 20;w=10, 100;w=60 Retry-After: 1 ``` ```json { "error": { "code": "too_many_requests", "message": "the \"short\" rate limit of 3 requests per 1 seconds has been exceeded; retry in 1 seconds", "status": 429 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` `Retry-After` is set even when `DisableHeaders` turns the `RateLimit` headers off. Refusing a client without telling it when to come back is what produces a client that comes back immediately, forever. ## Limiting a connected peer `ReadLimit` bounds what one WebSocket message costs and `MaxConnections` bounds how many peers there are, but neither bounds a peer that stays inside both and simply never pauses. `WSOptions.MessageLimits` applies the same quotas, storage and tracker to messages, counted in the same order as a request's: longest window first, stopping at the first quota that refuses. ```go r.WS("/items/{item_id}/ws", handlers.ItemSocket, muzak.WithWebSocket(muzak.WSOptions{ MessageLimits: []muzak.Quota{ {Name: "ws-messages", Window: time.Second, Limit: 10}, }, })) ``` A peer that goes over is closed with `1008 Policy Violation` rather than left connected and ignored, because a message silently dropped is a protocol nobody can debug. The budget belongs to the client rather than the connection, so opening a second one does not buy a second budget. Pings, pongs and empty fragments are not messages, but a connection that sends more than 65536 of them in a minute is closed with `1008` whatever else it does, so a peer cannot dodge the message count by never completing a message. Give those quotas names of their own unless a shared budget with the HTTP routes is what you want, since both live in one namespace. A route marked `SkipRateLimit` counts no messages either. ## Options reference | Field | Default | Effect | |---|---|---| | `Quotas` | none, so nothing is limited | The limits enforced, all of them, for every request, counted longest window first until one refuses | | `Storage` | a bounded in-process table | Where the counters live | | `Tracker` | `IPTracker` | Whose budget a request is spent from | | `Resolver` | none | Supplies quotas per request, counted alongside `Quotas` | | `FailOpen` | off | Serve a request the storage could not count | | `DisableHeaders` | off | Stop setting the `RateLimit` headers | | `AfterDependencies` | off | Count after the guards and providers rather than before | | `StorageTimeout` | `DefaultRateLimitStorageTimeout`, 2 seconds | Bounds one storage call. A call that outlives it counts as a storage failure. Negative removes the bound | ## Where to go next [Behind a Proxy](/docs/deployment/behind-a-proxy) covers which address a request is attributed to, and [WebSockets](/docs/realtime/websockets) covers the other bounds a connected peer runs into. -------------------------------------------------------------------------------- title: "Response Models" description: "The handler's return type describes the success response. Every other status code a route answers is declared at registration, with a schema of its own." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/response-models" -------------------------------------------------------------------------------- # Response Models A handler has one return type, and that type describes one outcome: the one where nothing went wrong. A route that answers `201` with the thing it just created, `400` with a complaint about the request and `409` with a conflict report is describing three different shapes, and only the first of them is in the signature. `Status`, `WithResponseDoc` and `WithResponseModel` are how the other two reach the document. None of them changes anything at run time. The success body is still enforced by the compiler; these describe what the route already does for everything else. ## The status a success is written with ```go r.Post("/users/", handlers.CreateUser, muzak.Status(http.StatusCreated)) ``` `Status` is the code written when the handler returns without an error. It defaults to `200`, and it is part of the route because it never varies: a creating route creates every time it succeeds. A status that depends on what happened is decided in the handler instead. ```go func CreateItem(ctx *muzak.Context, in schemas.ItemCreateIn) (schemas.ItemOut, error) { if in.Async { ctx.SetStatus(http.StatusAccepted) } return schemas.ItemOut{ID: in.ID}, nil } ``` The two never compete: `SetStatus` wins when both are used, and the document describes the declared one. Declaring the run-time alternative is what the rest of this page is for. ## A schema per status code `WithResponseModel[T]` documents one status code and the model its body carries. The type argument is written exactly as a handler's `Out` type would be. ::code-group ```go [schemas/users.go] type UserOut struct { ID int `json:"id"` Username string `json:"username" doc:"The name the account signs in with"` } // ErrorOut is this service's own error body, which is not Muzak's envelope. type ErrorOut struct { Message string `json:"message" doc:"What went wrong"` Code string `json:"code" doc:"A machine-readable classifier"` } ``` ```go [routers/users.go] r.Post("/users/", handlers.CreateUser, muzak.Status(http.StatusCreated), muzak.WithResponseModel[schemas.ErrorOut](http.StatusBadRequest, "The request was malformed"), muzak.WithResponseModel[schemas.ErrorOut](http.StatusInternalServerError, ""), ) ``` :: Each model is described once in the components section and referenced from every operation that names it, with the same `doc` tags, the same treatment of embedded structs and pointers, and the same well-known types a return type gets. Nothing about it is a second class of schema. A type that is also a request body is the one exception to "described once". A response keeps its shape, since a member that is not a pointer and not `omitempty` is always written, while a request lists a member as required only when a `Required()` rule refuses a body without it, and says `additionalProperties: false`, which a response never does. So a type used both ways always gets two schemas: the component keeps its shape for the response, and the request refers to a copy named for it with `Input` after (`Item` and `ItemInput`). See [OpenAPI](/docs/fundamentals/openapi#request-bodies). An empty description falls back to the status code's standard reason phrase, so the last line above reads as *Internal Server Error* in the document and in any page rendering it. ## When the envelope is already right A handler that reports a failure by returning an error produces Muzak's own error response. There is no model to choose, only an outcome to name. ```go r.Get("/items/{item_id}", handlers.ReadItem, muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist")) ``` `WithResponseDoc` documents that status as `ErrorResponse`, the shape [Error Handling](/docs/getting-started/error-handling) covers, because that is what the route actually answers with. Reach for `WithResponseModel` when the body is something else: a legacy error shape kept for older clients, a partial result, or a body the handler writes itself. Its description may be left empty too, so `WithResponseDoc(http.StatusNotFound, "")` is documented as *Not Found*. ## No body, and bodies that are not JSON A response model follows the same rules as a return type, including the types that are not JSON at all. | Model | What is documented | |---|---| | a named struct | `application/json`, referencing the component | | `muzak.Empty` | the status code, with no content | | `muzak.HTML` | `text/html` carrying a string | | `muzak.Bytes`, `muzak.Stream` or `muzak.FileResponse` | a binary string (`format: binary`) under `application/octet-stream` | | `muzak.Redirect` | the status code with no content, and a required `Location` header | ```go r.Get("/feed", handlers.Feed, muzak.WithResponseModel[muzak.Empty](http.StatusNotModified, "The feed has not changed")) ``` A route whose handler returns `Bytes`, `Stream` or `FileResponse` documents its success the same way, and `muzak.Produces("image/png", "image/webp")` names the media types that response is sent with in place of `application/octet-stream`. `Produces` describes the route's own success and nothing declared beside it, and is a build error on a route that answers with anything else, JSON included. See [Files, Streams and Redirects](/docs/techniques/files-streams-redirects). ## Declaring one for a whole router `WithResponseModel` and `WithResponseDoc` are both router options as well as route options, so an outcome every route shares is declared once. ```go api := muzak.NewRouter( muzak.WithResponseModel[schemas.ErrorOut](http.StatusUnauthorized, "No usable credential was presented")) api.Get("/items/{item_id}", handlers.ReadItem, muzak.WithResponseModel[schemas.ItemGoneOut](http.StatusGone, "The item was deleted")) ``` Every route beneath the router carries the `401`, and the same works at the point of inclusion: `app.Include(api, muzak.WithResponseModel[schemas.ErrorOut](418, "I'm a teapot"))`. Declarations are applied outermost first, and the last one for a status code wins, so a route replaces what it inherited by declaring the same code again. That also means a declaration naming the status the route *succeeds* with replaces the response derived from the return type, which is deliberate for a route that writes its own body and a mistake otherwise. A code outside `100` to `599` is not a status code, and fails the build rather than becoming a response nobody could receive. ## What ends up in the document For the creating route above: ```json "responses": { "201": { "description": "Created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserOut"}}} }, "400": { "description": "The request was malformed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorOut"}}} }, "422": { "description": "The request could not be validated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}} }, "500": { "description": "Internal Server Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorOut"}}} }, "default": { "description": "An unexpected error occurred.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}} } } ``` The `422` is added for you on every route that binds anything, since a request that fails validation never reaches the handler, and a `default` carrying the error envelope closes the list. Declaring `422` yourself replaces it, like any other code. The [documentation dashboard](/docs/fundamentals/openapi), if the application serves one, renders each of them as its own panel, with an example and an expandable schema per media type, so a client author can read the failure shapes without leaving the page. ## Keep it honest All of this is description. Nothing verifies at run time that a `404` really carries the model declared for it, so a declaration is a promise the handler has to keep. Document the outcomes the route already produces, and let the return type keep speaking for the one the compiler can check. ## Where to go next [Responses](/docs/getting-started/responses) covers the return type itself, headers, cookies and writing the response yourself. [Error Handling](/docs/getting-started/error-handling) covers the envelope `WithResponseDoc` describes, and [OpenAPI](/docs/fundamentals/openapi) covers the rest of the generated document. -------------------------------------------------------------------------------- title: "Files, Streams and Redirects" description: "Return bytes, a stream, a file or a redirect instead of JSON, with the document describing each, and let AutoETag answer a client that already has the body with 304." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/files-streams-redirects" -------------------------------------------------------------------------------- # Files, Streams and Redirects A handler's return type is encoded as JSON unless it is one of the types that say otherwise. Muzak recognises them when the route is registered, writes each the way it needs to be written, and describes each in the OpenAPI document as what it actually sends. | Return | Sends | Documented as | |---|---|---| | `muzak.HTML` | a page, under `text/html` | `text/html` | | `muzak.Bytes` | a body already in memory, under the media type it names | binary content of the types `Produces` lists | | `muzak.Stream` | a body read as it is sent, closed on every path | the same | | `muzak.FileResponse` | one file from an `fs.FS`, with ranges and conditional requests | the same | | `muzak.Redirect` | a `Location` and no body | the redirect status and a `Location` header | Everything else about the route is unchanged: guards, providers, the rate limit, errors and the access log work exactly as they do for a JSON route. ## Bytes ```go r.Get("/reports/{id}.csv", func(ctx *muzak.Context, in ReportIn) (muzak.Bytes, error) { csv, err := reports.Render(ctx.Context(), in.ID) if err != nil { return muzak.Bytes{}, err } return muzak.Bytes{ ContentType: "text/csv; charset=utf-8", Data: csv, Filename: "report-" + in.ID + ".csv", Download: true, }, nil }, muzak.Produces("text/csv")) ``` The body is written as given, with a `Content-Length`. The status follows `muzak.Status` and `ctx.SetStatus` as it does for JSON, and `204` and `304` send no body at all. `ContentType` is the media type the body is sent as. Left empty it is `application/octet-stream`, which a browser saves rather than renders. It must be a media type as RFC 9110 spells it, a type and a subtype with optional parameters; anything else, a line break or any other control character included, fails the request with a `500` and the cause is logged, rather than reaching the header. Every one of these responses carries `X-Content-Type-Options: nosniff`, even with the security headers turned off, so a browser believes the type it is given instead of guessing from the bytes. `Filename` and `Download` build the `Content-Disposition` header for you; see [Offering a download](#offering-a-download). An error renderer may answer with `Bytes` too, which is how an application sends its failures in a format of its own. ## Stream For a body too large to hold, or one produced as it is sent: ```go r.Get("/exports/{id}", func(ctx *muzak.Context, in ExportIn) (muzak.Stream, error) { object, err := bucket.Open(ctx.Context(), in.ID) if err != nil { return muzak.Stream{}, muzak.NotFound("").Wrap(err) } return muzak.Stream{ ContentType: "application/zip", Body: object, Length: object.Size(), }, nil }, muzak.Produces("application/zip")) ``` `Length` is sent as `Content-Length`, so a client can show progress and tell a truncated body from a complete one. Zero or less means it is not known, and the body is sent chunked. **Muzak owns `Body` from the moment the handler returns it.** When it is an `io.Closer` it is closed exactly once, on every path: | The request... | ...and the body is | |---|---| | is answered | copied, then closed | | is a `HEAD` | closed without being read | | answers `204` or `304` | closed without being read | | failed in the handler, which returned an error beside the stream | closed without being read, and the error rendered | | failed while the body was read | closed, and the connection aborted | | lost its client part way | closed, and the connection aborted | | panicked while the body was read | closed, and the panic recovered | Do not close it yourself. The copy goes through a pooled 32 KiB buffer, so a server streaming many bodies does not allocate one per response. A `Stream` with no `Body` fails with a `500`; return `Bytes` for a body that is empty. Once the header is sent the response cannot become an error, so a body that fails part way, ends before the `Length` it declared or runs past it, is logged and the connection aborted: the client sees a failed transfer rather than a body that ends cleanly and looks complete. The body is held to its `Length` by Muzak itself, so this holds under [Compression](/docs/techniques/compression) too, which takes the `Content-Length` off. The route's `Acquire` releases run before the header is sent, so that a commit that fails can still answer with an error, and therefore before `Body` is read at all. A `Body` must not read from anything an `Acquire` provider handed out, such as rows of the request's `Transaction`, which by then has been committed or rolled back. It opens what it reads itself, and closes it in its own `Close`. See [Dependencies](/docs/getting-started/dependencies). Muzak cannot interrupt a `Read` that blocks. A body whose `Read` can wait, such as an `io.Pipe` fed by a goroutine, should end when `ctx.Context()` is done; a body from an outbound request made with that context already does. ```go reader, writer := io.Pipe() // The goroutine keeps the request's context, never ctx itself: the Context is // pooled and handed to another request once the handler returns. requestCtx := ctx.Context() go func() { // CloseWithError ends the copy with the producer's failure, and a write // after the response is closed fails, which is what stops this goroutine // when the client goes away. writer.CloseWithError(export.WriteCSV(requestCtx, writer)) }() return muzak.Stream{ContentType: "text/csv", Body: reader}, nil ``` For a sequence of events rather than one body, use [Server-Sent Events](/docs/realtime/server-sent-events), which keeps the typed contract. ## Serving a file ```go var uploads, _ = os.OpenRoot("/var/lib/app/uploads") r.Get("/files/{name...}", func(ctx *muzak.Context, in FileIn) (muzak.FileResponse, error) { return muzak.FileResponse{FS: uploads.FS(), Name: in.Name, Download: true}, nil }) ``` The file is served with `http.ServeContent`, so a `Range` request is answered with the ranges asked for, and `If-Modified-Since`, `If-Unmodified-Since` and `If-Range` are answered from the file's modification time. A `HEAD` request gets the headers and no body, and the file is always closed. A `FileResponse` with no `FS` fails with a `500`. For a whole directory of assets rather than one file a handler chose, mount it with `Router.Static` instead; see [Static Files and Frontends](/docs/techniques/static-files). ### The name is checked before anything is opened `Name` is very often what a client asked for, so it is checked first, and a name that fails is answered `404`, exactly as a file that is not there would be, without the filesystem being asked to open it. | A name that... | ...is refused because | |---|---| | leaves the filesystem (`../secret`, `/etc/passwd`, `a//b`, a trailing `/`) | `fs.ValidPath` refuses it | | names a dotfile or anything inside a dot directory (`.env`, `.git/config`, `.well-known/x`) | a deployment directory is where those end up by accident | | holds a backslash, NUL or any other control character | a filesystem may read it as a separator or the end of the name | | on Windows, names a drive or a stream (`C:x`, `file.txt:$DATA`) | it opens something other than what it spells | | on Windows, names a device (`CON`, `nul.txt`, `COM1`, `LPT1`) | Windows opens the device whatever the extension | | on Windows, ends a segment with a dot or a space (`secret.txt.`) | Windows drops it, so the name opens another file | | on Windows, is shaped like an 8.3 short name (`GIT~1`) | it opens the long name it abbreviates, which no check has seen | A directory, a device, a pipe and anything else that is not a regular file is answered `404` as well: a link to `/dev/zero` would otherwise be read for as long as the client cared to wait. A file that does not exist logs nothing. Any other failure to open it, a permission or a link the root refused to follow, is a `404` to the client and logged for you. ### Symbolic links The check covers the name and nothing else. **`os.DirFS` follows a symbolic link wherever it points**, so a link planted inside the directory serves whatever file it names. Serve a directory on disk through the `FS` of an `os.Root`, opened with `os.OpenRoot`, which refuses to leave it. An `embed.FS` has no links. ### What a file is served as The type comes from the extension, with the same table `Router.Static` uses, and never from the content, so a file with no extension is `application/octet-stream`. Set `ContentType` to name one yourself; it is checked as `Bytes.ContentType` is. An HTML, SVG or other XML file is sent under `Content-Security-Policy: sandbox`, so a file a client uploaded cannot run script as your application when it is opened directly. Set `AllowActiveContent` for a file that is part of your site. A file from a filesystem whose files cannot seek, such as a zip archive, is buffered up to 8 MiB so ranges keep working, and streamed without them past that. The status follows `muzak.Status` and `ctx.SetStatus`. `200` is answered by `http.ServeContent`; `204` and `304` send no body; any other status, a custom `404` page say, sends the whole file without reading the range or conditional headers. When `http.ServeContent` answers a range it cannot serve with `416`, it clears `Cache-Control`; a response that carries a session cookie is kept private regardless. ## Offering a download `Bytes`, `Stream` and `FileResponse` share two fields: | Fields | Header | |---|---| | neither | none | | `Filename: "notes.txt"` | `Content-Disposition: inline; filename="notes.txt"` | | `Download: true` | `attachment`, with the file's own name for a `FileResponse` | | both | `attachment; filename="..."` | Never build the header by hand from a name a client chose. The filename is cleaned before it is sent: control characters (CR, LF and NUL among them), double quotes, the Unicode controls that reorder text (which would let `invoice` + U+202E + `fdp.exe` display as `invoiceexe.pdf`), and line and paragraph separators are removed; a slash or a backslash becomes `_`; and the name is cut to 255 bytes. A name with characters outside ASCII is sent twice, as RFC 6266 asks: an ASCII stand-in in `filename` and the name in full in `filename*`, encoded as RFC 8187 describes. ```http Content-Disposition: attachment; filename="r_sum_ _.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%20%E2%82%AC.pdf ``` ## Redirects ```go r.Post("/login", func(ctx *muzak.Context, in LoginIn) (muzak.Redirect, error) { if err := accounts.SignIn(ctx, in); err != nil { return muzak.Redirect{}, err } return muzak.Redirect{To: "/dashboard"}, nil }) ``` A redirect is sent with a `Location` and no body. Its status is `Redirect.Status` when set, otherwise the route's own when that is a redirect status, and otherwise `302 Found` for `GET` and `HEAD` and `303 See Other` for every other method. After a form is posted, `303` is the one status every client follows with a `GET`, which is what keeps a refresh from posting the form again; for a `GET`, `302` is the conventional "look over there". | Status | Use it for | The client repeats | |---|---|---| | `301` | a move that is permanent | as a `GET` | | `302` | a move that is not | as a `GET` | | `303` | "the answer is over there", after a `POST` | as a `GET` | | `307` | a temporary move that must keep the method and body | the same request | | `308` | a permanent move that must keep the method and body | the same request | Any other `Status` fails the request with a `500` and a logged cause, and declaring a status that is not a redirect on a route returning `Redirect` is a build error. ### Open redirects are refused A redirect to a place a client named is how a phishing link borrows your domain: `https://api.example.com/login?next=//evil.com` looks like yours until the browser lands on `evil.com`. So by default a `Redirect` only goes to a path on your own origin, and the check is made on every response: | A target that... | ...is | |---|---| | begins with exactly one `/` (`/dashboard`, `/items/42?tab=history#top`) | sent | | begins with `//` or `/\` (`//evil.com`, `/\evil.com`) | refused: a browser reads it as another host | | holds a backslash, a space, a tab or a line break anywhere | refused: a browser strips or rewrites them before it reads the URL | | holds any other control character, or a character outside ASCII | refused: percent-encode it, as `url.URL.String` does | | holds a `%` that does not begin an escape (`/50%off`) | refused: write it `%25` | | decodes, once or up to three times, to `//` or `/\` (`/%2F/evil.com`, `/%252F%252Fevil.com`) | refused: a proxy or a framework that decodes again reads another host | | has a path that decodes to a control character (`/%0d%0aSet-Cookie:x`) | refused | | reaches `//` once dot segments are resolved (`/.//evil.com`, `/%2e%2e//evil.com`) | refused | | is relative without a leading `/` (`evil.com`, `../x`) | refused | | uses a scheme other than `http` or `https` (`javascript:`, `data:`) | refused | | is an absolute URL to a host nothing lists | refused | | is empty, or longer than 8 KiB | refused | A query and a fragment may hold anything a URI may, so `/login?next=%2F%2Fevil.com` is fine: it is your own login page that has to check `next` before it follows it. A refusal fails the request with a `500`, and the log names the reason but not the target, which may carry a token in its query. ### Absolute URLs An absolute `http` or `https` URL is sent only when its host is listed with `RedirectHosts`, on the route, a router above it or the application: ```go app := muzak.New(opts, muzak.RedirectHosts("accounts.example.com", "localhost:5173")) ``` A listed host is compared without regard to case, and must otherwise be exactly the one listed: | Target | With `RedirectHosts("accounts.example.com")` | |---|---| | `https://accounts.example.com/cb` | sent | | `https://ACCOUNTS.example.com/cb` | sent | | `https://accounts.example.com:443/cb` | sent: the scheme's default port | | `https://accounts.example.com:8443/cb` | refused: another port is another service | | `https://accounts.example.com./cb` | refused: a name with a trailing dot is not the name listed | | `https://accounts.example.com.evil.com/` | refused | | `https://sub.accounts.example.com/` | refused: there are no wildcards | | `https://accounts.example.com@evil.com/` | refused: user information disguises the real host | | `https://accounts%2Eexample.com/` | refused: a host is never percent-encoded | | `https:accounts.example.com` | refused: an absolute URL names its host after `//` | An entry is a host name or an IP address with an optional port, an IPv6 address in brackets. An entry without a port admits the scheme's default port only, written or not; one with a port admits that port only. Anything else, a scheme, a path, user information, a wildcard or a trailing dot, is a build error, reported for each route that can answer with a `Redirect`. A list is read only by such routes, so a mistake in one declared on the application is not reported until a route returns `Redirect`. Lists add up: a route may send a client to any host it or a router above it lists. For a target your handler built from its own configuration rather than from the request, such as an identity provider's authorization endpoint, set `External`: ```go return muzak.Redirect{To: oauth.AuthCodeURL(state), External: true}, nil ``` `External` vouches for the host and nothing else: the target must still be a well-formed `http` or `https` URL with a host and no user information. **Never set it for a URL taken from the request.** An error renderer may answer with a `Redirect` too, which is how a missing session sends a browser to the login page. It is checked the same way, against the hosts of the route that failed, and only a route that itself returns `Redirect` reads its hosts. A path on your own origin is always fine; a renderer sending a client to another host sets `External` for the URL it built. ## Entity tags ```go app := muzak.New(opts, muzak.AutoETag()) ``` `AutoETag` tags a response with an entity tag computed from its body, and answers a request that already holds that body with `304 Not Modified`, so the client keeps its copy and the bytes are not sent again. It is a shared option: declare it on the application (`muzak.New` or `app.Options`), a router, an `Include` or a single route. Once on, it stays on for everything beneath: a tag only ever saves sending a body the client already has, so there is nothing a route below gains by turning it back off. It applies to a `GET` or `HEAD` answered `200` with a body Muzak holds in full: JSON, `HTML` and `Bytes`. The tag is strong, the first 128 bits of the body's SHA-256 in unpadded base64url, quoted, so it changes whenever a byte of the body does and only then. JSON is encoded with map keys sorted on a tagged route, so the same value always produces the same tag. A handler that sets its own `ETag` keeps it, and the request is answered against that one. ```http GET /items/42 HTTP/1.1 If-None-Match: "q1Hs3cWc9mJfV8x1Zr0b2A" HTTP/1.1 304 Not Modified ETag: "q1Hs3cWc9mJfV8x1Zr0b2A" ``` `If-None-Match` is compared weakly, as RFC 9110 requires, so `W/"..."` matches too, and `*` matches any body. It is read as a list, in a single pass, so a tag holding a comma is found where it is; a field that is not a well-formed list is ignored and the full response sent, because a full response is always correct and a 304 sent on a misreading is not. The `304` keeps what RFC 9110 asks for, `ETag`, `Cache-Control`, `Content-Location`, `Date`, `Expires` and `Vary`, and drops what describes the body it does not send: `Content-Type`, `Content-Length`, `Content-Encoding`, `Content-Language`, `Content-Disposition`, `Last-Modified` and the rest. Headers about the exchange rather than the body, such as `Set-Cookie`, the CORS answer and the request identifier, are kept. It composes with what surrounds it: - **Compression** weakens the tag of a body it compresses, `"v1"` to `W/"v1"`, and of the `304` it answers for one, so a cache can use the `304` to refresh what it stored. The weak tag the client sends back still matches. See [Compression](/docs/techniques/compression). - **A guarded route** keeps `Cache-Control: private, no-cache` on its `304`, so a shared cache never stores it. A guard still runs first, so a client it refuses is refused whatever tag it holds, and a different user with someone else's tag is sent their own body. - **A `Stream`** has no body to hash until it is sent, and **a `FileResponse`** is answered by `http.ServeContent`, which reads the conditional headers itself, so neither is tagged. - **Anything else** is left alone: a `POST`, a `201`, an error, a redirect. The body is hashed on every tagged request, which costs time linear in its size: the `304` saves the bytes and the client's work, not the handler's. A route that does not declare it pays nothing, not even a look at the request's headers. ## In the document `Bytes`, `Stream` and `FileResponse` are described as binary content, under `application/octet-stream` unless the route lists its types with `Produces`: ```go r.Get("/avatars/{id}", handlers.Avatar, muzak.Produces("image/png", "image/webp")) ``` ```json "200": { "description": "OK", "content": { "image/png": {"schema": {"type": "string", "format": "binary", "contentMediaType": "image/png"}}, "image/webp": {"schema": {"type": "string", "format": "binary", "contentMediaType": "image/webp"}} } } ``` `format: binary` is what client generators read as "a file, not text to parse", and `contentMediaType` says the same thing in JSON Schema's terms. A range such as `image/*` is accepted and carries no `contentMediaType`. `Produces` only describes: the type a response is sent with is the one the value names. It is a route option, declared on the route it describes. Using it on a route returning anything else, JSON included, giving it no types, or giving it something that is not a media type is a build error. A `Redirect` is described with its status, no content and a `Location` header: ```json "303": { "description": "See Other", "headers": { "Location": { "description": "Where the client is sent: a path on this origin, or an absolute URL to a host the route allows.", "required": true, "schema": {"type": "string", "format": "uri-reference"} } } } ``` `WithResponseModel[muzak.Bytes]` (or `Stream`, or `FileResponse`) describes another status as binary content under `application/octet-stream`, and `WithResponseModel[muzak.Redirect]` describes one as a redirect with its `Location`; see [Response Models](/docs/techniques/response-models). Returning a pointer to any of these types is a build error, since it would be encoded as JSON. [OpenAPI](/docs/fundamentals/openapi) covers the rest of the document. ## Limits - **`Bytes` and `Stream` are sent under the type you name, unsandboxed.** Only a `FileResponse` adds `Content-Security-Policy: sandbox` to HTML, SVG and XML. A `Bytes` or `Stream` sent as `text/html` is a page of your origin, so never send a body a client wrote that way; send it as `text/plain` or `application/octet-stream`, or as a download. - **A file a client uploaded is served under its real type.** An uploaded `.js` or `.css` file is sent as JavaScript or CSS, which a page of your origin may then load, and a `Content-Security-Policy` of `script-src 'self'` does not stop it, since the file is your origin's. Serve uploads from another origin, or set `ContentType` to `application/octet-stream`, which `nosniff` keeps a browser from running as a script or applying as a stylesheet. `Download` alone is not enough: it changes what a browser does with the file opened directly, and nothing about a page that loads it. [Safe Defaults](/docs/security/safe-defaults) lists the headers every response carries, `nosniff` among them. ## Where to go next [Responses](/docs/getting-started/responses) covers the return type, status codes and headers, [Static Files and Frontends](/docs/techniques/static-files) covers mounting a whole directory, [Compression](/docs/techniques/compression) covers how compressed bodies and entity tags interact, and [Typed Endpoints](/docs/techniques/typed-endpoints) covers how a Go caller receives `Bytes`, `Stream` and `Redirect` back as themselves. -------------------------------------------------------------------------------- title: "Problem Details" description: "Render every error as an RFC 9457 problem, application/problem+json, with the same codes, details, translations and leak guarantees as the default envelope, and a document that says so." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/problem-details" -------------------------------------------------------------------------------- # Problem Details RFC 9457, "Problem Details for HTTP APIs", is the standard shape for an error body, and the one an API gateway, a client generator or another team's service may already expect. Muzak's default envelope stays the default; one option switches the whole application to problems. ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: settings.AppName, // Every error is answered as application/problem+json, and the OpenAPI // document describes every error response that way. ProblemDetails: &muzak.ProblemOptions{TypeBase: "https://errors.example.com/"}, }) ``` ```json { "type": "https://errors.example.com/validation_error", "title": "Unprocessable Entity", "status": 422, "detail": "The request could not be validated.", "instance": "urn:uuid:0611f4b2-2f0a-7b57-9c1a-6e6a2e2f9b31", "code": "validation_error", "errors": [ { "field": "name", "location": "body", "issue": "is required" }, { "field": "limit", "location": "query", "issue": "must be a valid integer" } ], "request_id": "0611f4b2-2f0a-7b57-9c1a-6e6a2e2f9b31" } ``` ## The members | Member | Carries | |---|---| | `type` | `TypeBase` followed by the error code, path-escaped; `about:blank` when no base is set | | `title` | the status's reason phrase, such as `Not Found`, translated where the application translates it | | `status` | the HTTP status | | `detail` | the message the default envelope would carry, held to the same rule | | `instance` | `urn:uuid:` followed by the request identifier | | `code` | the stable classifier, such as `not_found` (extension) | | `errors` | every field that failed, as the envelope's `details` (extension, omitted when empty) | | `request_id` | the identifier that joins the response to the server's log (extension) | The Go type is `muzak.Problem`, and the media type `muzak.ProblemContentType`. `instance` names the occurrence by its request identifier rather than by the request's path. A path can hold personal data, an email address or a name, that a problem report forwarded to a ticket or a log aggregator should not repeat. Every identifier Muzak assigns is a UUID, so the member is a URN of the registered `uuid` namespace; an application that assigns identifiers of its own that are not UUIDs gets no `instance`. `code`, `errors` and `request_id` are what the default envelope carries, so a client moving from one format to the other loses nothing. Branch on `code`, or on `type` when a base is set; never on `title` or `detail`, which are text for people and are translated. ## TypeBase ```go muzak.ProblemOptions{TypeBase: "https://errors.example.com/"} // https://errors.example.com/not_found muzak.ProblemOptions{TypeBase: "urn:example:errors:"} // urn:example:errors:not_found muzak.ProblemOptions{} // about:blank ``` The code is appended as it is, so the base ends in whatever separates the two. A code with a character a path segment cannot carry, as one set with `WithCode("card declined")` might, is escaped: `card%20declined`. The RFC recommends serving documentation at each type URI; Muzak does not, so publish it there if you name a base that resolves. Left empty, every problem's type is `about:blank`, which the RFC defines as a problem described by its status alone, and the title is then the status's reason phrase, as the RFC asks. A base that is not an absolute URI, or that carries a query, a fragment, whitespace or a character outside ASCII, is a build error: a client would resolve a relative one against wherever it read the response, and a code appended after a `?` or `#` would land in the wrong part. ## The same promises as the envelope The problem renderer decides nothing on its own. It asks `DefaultErrorRenderer` and reshapes what that decided, so everything [Error Handling](/docs/getting-started/error-handling) says about the envelope holds for a problem too: - The same status and code for every error, and the same details for a validation failure. - A server-side fault, a plain `error`, a `StatusCoder` at `500` or above, or a panic, is answered with the fixed sentence `The server could not complete the request.` while the cause is logged and never sent. An `*HTTPError` keeps the message it was written with, at any status. - The headers set before the error was rendered are kept: `Allow` on a `405`, `Retry-After` on a `429`, `WWW-Authenticate` on a `401`, `Vary`, `Set-Cookie`. The body's own headers are reset and the response is sent with `Cache-Control: no-store`. Every error the application produces is a problem, not only those a route returns: a refused host, a rate limit, a guard on a mounted handler, an unknown path, a method the documentation does not answer, the `405` of a health endpoint, and a panic in middleware installed with `App.Use`, which used to be answered with the fixed envelope whatever the renderer was. So is the `500` an application that failed to build answers every request with, and the fixed `500` that stands in when an error renderer's body cannot be encoded; neither has a request to translate for, so both are in English. The same goes for the features that refuse a request before any route runs. A state-changing request a browser sends from another origin is refused by [Cross-Origin Requests](/docs/security/cross-origin-requests) with a `403` problem whose code is `cross_origin_request`, and a [session](/docs/security/sessions) that cannot be saved fails its request through the renderer, as a handler's error would. ## Translation `detail` is translated exactly as the envelope's `message` is (see [Internationalization](/docs/fundamentals/internationalization)). `title` is looked up at `muzak.status.`, falling back to the English phrase net/http knows the status by: ```yaml [locales/es.yml] es: muzak: status: 404: "No encontrado" 422: "Entidad no procesable" http: 404: "No se encontro el recurso solicitado." ``` The shipped locale carries the English titles of every status the framework classifies, and a test keeps them equal to `http.StatusText`, so turning a store on changes no English title. The machine members, `type`, `code` and `status`, are never translated. ## The document With `ProblemDetails` set, every error response the [OpenAPI](/docs/fundamentals/openapi) document describes, the `422` of a route that binds input, the `429` of a rate-limited one, the `503` of a route with a `Timeout`, each `WithResponseDoc` and the `default`, is `application/problem+json` with a `$ref` to the `Problem` component, and the `ErrorResponse` component is no longer emitted. A response declared with `WithResponseModel` keeps its own model. ## Composing with a renderer of your own `muzak.ProblemDetails(opts)` is the renderer itself, so an application with errors of its own to render can delegate the rest to it: ```go problems := muzak.ProblemDetails(muzak.ProblemOptions{TypeBase: "https://errors.example.com/"}) app := muzak.New(muzak.AppOptions{ ProblemDetails: &muzak.ProblemOptions{TypeBase: "https://errors.example.com/"}, ErrorRenderer: func(ctx *muzak.Context, err error) (int, any) { var declined *billing.DeclinedError if errors.As(err, &declined) { ctx.SetHeader("Content-Type", muzak.ProblemContentType) return http.StatusPaymentRequired, DeclinedProblem{ /* ... */ } } return problems(ctx, err) }, }) ``` When `ErrorRenderer` is set, it is used, and `ProblemDetails` still tells the document that errors are problems. Set both, as above: installing `muzak.ProblemDetails` through `ErrorRenderer` alone renders problems but leaves the document describing the envelope. ## Limits A few answers are written by net/http's file serving rather than by the renderer, and are plain text whatever the format: - The `416` that `http.ServeContent` sends for a range a file cannot satisfy, on a static file or frontend mount and for a `FileResponse`. - The `404` or `500` a static file or frontend mount sends when a file it found cannot be opened or read a moment later, for instance because it was deleted in between. The cause is logged. ## Where to go next [Error Handling](/docs/getting-started/error-handling) covers which errors describe themselves and which stay opaque, and [OpenAPI](/docs/fundamentals/openapi) what else the document says about a route. -------------------------------------------------------------------------------- title: "Mounting net/http Handlers" description: "Serve pprof, a metrics handler, a connect-go service or a legacy application beside your routes, behind the same guards, rate limit and middleware." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/mounting-handlers" -------------------------------------------------------------------------------- # Mounting net/http Handlers Not everything an application serves is a typed route. Profiling, metrics, an RPC service generated by another tool, or the application you are migrating away from are all plain `http.Handler`s. `Router.Mount` serves one at a prefix, for the prefix itself and everything beneath it, for every method. ::code-group ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{Title: settings.AppName}) // Operators only: the same guard every admin route uses. app.Mount("/metrics", promhttp.Handler(), muzak.Needs(auth.RequireOperator)) // net/http/pprof reads the full path, /debug/pprof/heap say, to choose a // profile, so it is mounted at the path it was written for, without // StripPrefix. pprofMux := http.NewServeMux() pprofMux.HandleFunc("/debug/pprof/", pprof.Index) pprofMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) pprofMux.HandleFunc("/debug/pprof/profile", pprof.Profile) pprofMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) pprofMux.HandleFunc("/debug/pprof/trace", pprof.Trace) app.Mount("/debug/pprof", pprofMux, muzak.Needs(auth.RequireOperator)) // Whatever no route answers yet. app.Mount("/", legacy.Handler()) ``` ```go [internal/auth/guards.go] // RequireOperator admits a request carrying the operator token, and nothing // else. It is a provider rather than a guard so that a route can read the // operator it resolved; a mount runs it for its verdict. func RequireOperator(ctx *muzak.Context) (Operator, error) { token, ok := muzak.BearerToken(ctx) if !ok || !muzak.SecureCompare(token, settings.OperatorToken) { return Operator{}, muzak.Unauthorized("") } return Operator{Name: "ops"}, nil } ``` :: The prefix is relative to the router the mount is registered on, under whatever prefixes that router is included with, exactly as a route's path is. A single trailing slash is ignored, so `"/debug/pprof/"` and `"/debug/pprof"` are one mount, and `"/"` mounts at the root of the router. ## What wins The most specific answer wins, as it does everywhere in the routing tree. | Request | Answered by | |---|---| | A path a route is registered at, with a method the route registers | the route | | `HEAD` on a path with a `GET` route | the route, as without the mount | | A path a route is registered at, with any other method, `OPTIONS` included | the mount | | A path beneath the prefix that no route has | the mount | | A path beneath a longer mount inside this one | the longer mount | | A path beneath a `Frontend` or `Static` mounted inside this one | the file mount | That is what a migration needs. With the legacy application mounted at `/` and `GET /users` moved to a route, `GET /users` is answered by the route and `POST /users` still reaches the legacy handler, until it moves too. Beneath a mount no `405` is ever answered, since the mount takes every method the routes there do not. ```go app.Get("/users", handlers.ListUsers) // migrated app.Mount("/", legacy.Handler()) // POST /users, /orders, ... still here ``` These are build errors, all reported by one `Build`: - two mounts at one prefix, including `"/x"` and `"/x/"`; - a mount at the prefix of a `Frontend` or `Static` mount; - a mount at the documentation's own path, since the documentation is answered ahead of routing; - a route whose wildcard sits directly beneath the prefix, such as `"/legacy/{rest...}"` beside a mount at `/legacy`, because the two would answer exactly the same paths; - a prefix that does not begin with `/`, or that holds a parameter or wildcard (including one inherited from `WithPrefix("/{tenant}")`), an empty segment, a `.` or `..` segment, or a character a request carries only percent-encoded; - a nil handler; - a `Timeout` given to `Mount`, described [below](#what-it-does-not-do). [Health Checks](/docs/deployment/health-checks) are answered ahead of routing, so a handler mounted at the root leaves `/livez` and `/readyz` to them. A health path beneath any other mount, or a health path of `/` beside a handler mounted at the root, would take a path the mount was given, and is a build error too. ## It runs inside the application A mounted handler is not a side door. Everything a route runs inside, it runs inside: - the [middleware](/docs/getting-started/middleware) installed with `App.Use`, the CORS policy, the cross-origin protection, the security headers, request identification, the access log and panic recovery; - before it runs, the **rate limit**, the **guards** and the **providers** of every router it is registered under and of the options given to `Mount`, in the order a route runs them. A refusal is rendered by the error renderer exactly as a route's is; - a security requirement it inherits that names a verifying scheme, such as one built by `JWTBearer` or `APIKeyVerifier`, ahead of the guards, exactly as for a route. `Public()` given to the mount exempts it. See [Authentication](/docs/security/authentication). The mount is counted by its routers' [rate limit](/docs/techniques/rate-limiting) as a route of theirs is: a quota shared with routes is spent by the mount too, and every spelling of the path that reaches it counts against the one quota. `SkipRateLimit()` given to `Mount` leaves it out. A provider's value is not handed on, since the handler has no `*muzak.Context` to read it from; a provider runs for its verdict. A mount behind any guard, request-scoped provider or verifying security scheme answers with `Cache-Control: private, no-cache` unless the handler sets a `Cache-Control` of its own, so a shared cache does not hand one client's metrics to another. `App.Override` and `testclient.Override` reach the providers given to `Mount` as they reach a route's, and a provider given to it with a nil function is a build error. A provider declared with `Acquire` is released once the handler returns, and is told the request succeeded, whatever status the handler wrote: the handler decided what to answer, so nothing failed on the framework's account. A panic releases it with the failure, and a release that fails is handled as a handler's failure would be, which aborts a response already started. A `Frontend` or `Static` mounted beneath the prefix releases what it acquired once the file is served. So a `Transaction` declared on a router commits behind a mounted handler that answered `500`; declare it on the routers whose routes need it rather than on one a mount sits under. See [Dependencies](/docs/getting-started/dependencies). The handler receives the **original** `*http.Request` (with [StripPrefix](#stripprefix), a shallow copy of it carrying the shorter path), never the pooled `muzak.Context`. Its context carries the framework's values: ```go func handler(w http.ResponseWriter, r *http.Request) { id, _ := muzak.RequestIDFromContext(r.Context()) // same as X-Request-Id route, _ := muzak.RouteFromContext(r.Context()) // the mount prefix, "/metrics" } ``` The access log records the prefix as the request's route, so a mount is one time series rather than one per path, and so do [tracing and the request observer](/docs/fundamentals/observability). Since a mount answers whatever method a client sends, a method no standard defines is recorded there as `_OTHER`, so a client cannot mint a span name or a metric series per request. A panic in the handler is recovered as a route's is: before anything was written it becomes the standard `500`, with the stack in the log and nothing of it in the response; after the response started, the connection is aborted so a client cannot mistake a truncated body for a complete one. `http.ErrAbortHandler` still aborts. The writer flushes and hijacks, so a streaming or WebSocket endpoint of the legacy application keeps working. ## Body limits A mounted handler reads its own body, and the body is still bounded by the `MaxBodySize` the mount inherits (one mebibyte by default). A body that declares a length over it is refused with `413` before the handler runs; one of unknown length is read through `http.MaxBytesReader`, so the read that goes over fails with `*http.MaxBytesError` and the handler answers as it would. ```go app.Mount("/upload", legacyUploads, muzak.MaxBodySize(64<<20)) // A client-streaming RPC is one long body. Remove the limit, and bound the // messages in the RPC framework instead. app.Mount("/rpc", connectHandler, muzak.MaxBodySize(-1)) ``` On a mount, unlike a route, a negative `MaxBodySize` given to `Mount` removes the limit. `MaxUploadSize` plays no part, since nothing tells a mount which bodies are uploads, so a mount that accepts files is given a `MaxBodySize` large enough for them, as above. A multipart form the handler parses has its temporary files removed when it returns or panics, as a route's are, with or without `StripPrefix`. ## StripPrefix `muzak.StripPrefix()` gives the handler paths relative to the mount, the way `http.StripPrefix` does, so a handler written to be served at the root can be served beneath a prefix unchanged. ```go // The old application was written to be served at the root. app.Mount("/legacy", legacy.Handler(), muzak.StripPrefix()) ``` | Request | Path the handler sees | |---|---| | `/legacy/users` | `/users` | | `/legacy` | `/` | | `/legacy/` | `/` | | `/legacy/a%2Fb` | `/a/b`, escaped `/a%2Fb` | | `/%6Cegacy/users` | `/users` | Two differences from `http.StripPrefix` are deliberate. The prefix itself becomes `/` rather than the empty path, which a `ServeMux` would answer with a redirect to the root of the **site**. And the prefix is removed segment by segment from the escaped path, so a prefix the client spelled with escapes is stripped too, and what remains keeps its own. The handler is given a shallow copy of the request carrying the new URL; `RequestURI`, the context and everything else are the original request's. A handler that redirects to an absolute path of its own, as `ServeMux` does to add a trailing slash, writes a `Location` relative to the root of the site rather than to the mount; serve such a handler without `StripPrefix`, at the path it expects. The same goes for a handler that reads the full path to decide what to serve, as `net/http/pprof` does, which is why the example at the top of this page mounts it without `StripPrefix`. `StripPrefix` given to `NewRouter`, `Include` or `App.Options` is a build error rather than an option that silently does nothing. ## What the guards cover The router never normalises a path: no case folding, no collapsing of `//`, no resolving of `.` or `..`. A request reaches the mount only through the tree's exact match of the prefix, segment by segment, with a percent-escape decoded only where it does not encode a `/`. For a mount at `/admin`: | Spelling | Reaches the mount | |---|---| | `/admin`, `/admin/`, `/admin/x`, `/admin//x` | yes, and runs its guards | | `/%61dmin/x`, `/adm%69n` | yes, and runs its guards | | `/admin/./x`, `/admin/../x` | yes, and runs its guards | | `//admin/x`, `/ADMIN/x`, `/Admin` | no | | `/./admin/x`, `/x/../admin/x`, `/%2e/admin/x` | no | | `/admin%2Fx`, `/admin;x`, `/admin%00`, `/admin\x` | no | So whatever the mounted handler serves has passed its guards: the framework's tests send every spelling above as a raw request line through a real server and check it. What the handler then does with the path it is given is its own affair. A handler that cleans, decodes or case-folds paths, a file server on a case-insensitive disk say, may serve for `/admin/../x` something that a guarded route beside the mount was meant to protect. Give such a handler guards that cover everything it can serve, and do not rely on the guards of a route the request did not reach. The same warning applies to a `{name...}` wildcard route; see [Routers](/docs/getting-started/routers). A spelling of a path beneath a `Frontend` or `Static` that a case-insensitive filesystem would open, such as `/APP/x` beneath a frontend at `/app`, is refused with `404` rather than handed to a less specific mount, exactly as it is refused rather than handed to a less specific frontend. ## What it does not do Mounts are not described in the OpenAPI document, which describes the routes Muzak can see into, and an [MCP endpoint](/docs/techniques/mcp-tools) never offers one as a tool. Options that describe or bind a route, such as `WithTags` or `CaptureBody`, have no effect on a mount. A mount answers every API version when versioning is on, because it has no version of its own to choose by. `Mount` panics once the application has been built, as registering a route does. A [Timeout](/docs/techniques/timeouts) is not applied to a mounted handler, which writes its own response and may be streaming one. One inherited from a router is left out, as it is for an event stream, and one given to `Mount` is a build error rather than a bound nothing enforces. Give the handler a deadline of its own, with `http.TimeoutHandler` for one. ## Where to go next [Static Files and Frontends](/docs/techniques/static-files) covers the file mounts a handler mount sits beside, and [Rate Limiting](/docs/techniques/rate-limiting) the budget it shares with routes. [Allowed Hosts and HTTPS](/docs/security/hosts-and-https) covers the checks that run ahead of every mount. -------------------------------------------------------------------------------- title: "Background Tasks" description: "Run work the client should not wait for after the response is written, on a bounded pool that a shutdown drains before it stops the components the work uses." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/background-tasks" -------------------------------------------------------------------------------- # Background Tasks Some work belongs to a request without belonging to its response: the welcome email after a sign-up, an audit record, a cache to warm. The client should not wait for it, and a bare `go func()` is the wrong tool, because nothing bounds how many there are, nothing waits for them at shutdown, and the one that captured the `*muzak.Context` reads another request's values a moment later. `Context.AfterResponse` registers the work instead. ::code-group ```go [handlers/users.go] func SignUp(ctx *muzak.Context, in schemas.SignUpIn) (schemas.UserOut, error) { user, err := users.Create(ctx.Context(), in) if err != nil { return schemas.UserOut{}, err } // Copied out before registering: the task never sees ctx. log := ctx.Logger() email := user.Email if err := ctx.AfterResponse(func(bg context.Context) { if err := mailer.SendWelcome(bg, email); err != nil { log.ErrorContext(bg, "welcome email failed", "error", err) } }); err != nil { // The queue is full or the server is shutting down. The user exists // either way; the email is what is lost, so say so. log.Warn("welcome email not queued", "error", err) } return schemas.UserOut{ID: user.ID, Email: user.Email}, nil } ``` ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Background: muzak.BackgroundOptions{ Workers: 8, // tasks running at once Queue: 256, // tasks waiting for a worker }, }) ``` :: ## When a task runs **Only when the handler succeeds.** A task runs when the handler returned a nil error without panicking. A request that fails drops every task it registered, whether its handler returned an error, a guard or the binder refused it, or something panicked, including a value that panicked while its response was being encoded. A task registered by a guard or a provider follows the same rule: it runs if the route's handler then succeeds. **After the response.** Tasks are handed to the workers once the response has been passed to `net/http`, so the response never waits for them. They are handed over before the middleware installed with `App.Use` has unwound, and a task can begin before the last byte has reached the client. **Whatever happens to the response afterwards.** Once the handler has returned nil, its own work is done: a client that leaves before reading the response does not cancel the email that was promised to it, and neither does a response value whose `MarshalJSON` returns an error. A value that panics while it is encoded is the exception, because something panicked. **Unless the route's releases were told it failed.** On a route with an [`Acquire`](/docs/getting-started/dependencies) provider, a value that fails to encode is reported to each `Release` as a failure, as an error of the handler's own would be. The work the tasks were to follow has been undone, a `Transaction` rolled back, so the tasks are dropped with it. The tasks follow what the releases were told. **In no particular order.** The tasks of one request run concurrently with each other and with everyone else's. A task that has to follow another should be one task. ## What a task receives | | Kept | Not kept | |---|---|---| | Request identifier (`RequestIDFromContext`) | yes | | | Locale (`LocaleFromContext`) | yes | | | Anything middleware stored in the context | yes | | | The request's cancellation | | the request ending does not end the task | | A route's `Timeout` deadline | | | | The `*muzak.Context` | | never | The context is cancelled when the shutdown deadline is reached, with a cause that says so, which is worth logging when a task gives up: ```go if err := export.Run(bg); err != nil { log.ErrorContext(bg, "export abandoned", "error", err, "cause", context.Cause(bg)) // cause="muzak: the shutdown deadline passed before this background task finished" } ``` A task that honours it is the difference between a shutdown that finishes on time and one that has to abandon the task. The `*muzak.Context` is pooled and belongs to another request by the time the task runs, so the task never receives it; copy out what it needs before registering it. `ctx.Logger()` returns a logger that carries the request's method, path and identifier (and its trace when tracing is on) and holds nothing of the pooled Context, so it is the one value always worth capturing. ## Bounds | Field | Default | Bounds | |---|---|---| | `Workers` | 4 | How many tasks run at once | | `Queue` | 64 | How many registered tasks may wait for a worker | A task counts against `Queue` from the moment it is registered, while its request is still being served, so a request that succeeds never finds the queue full at the moment its tasks are handed over. A registration past the bound returns `muzak.ErrBackgroundQueueFull` at once: the handler is never blocked waiting for room, and no goroutine is started to hold the task. What to do then is the handler's decision: do the work before returning, drop it with a log line, or fail the request. The same rule has a cost: a slow request holds the place its task took until the request ends. Many slow requests that each registered a task early can fill the queue while no worker is busy, and later registrations are refused until they finish. Register a task as late in the handler as you can, once the work it follows is done, and size `Queue` for the requests in flight as well as for the tasks waiting. There is no unbounded setting, on purpose: a queue without a cap is a backlog anyone who can send requests can grow. A negative value is a build error and zero takes the default. No goroutine exists while there is nothing to run. A worker is started when a task is handed over and fewer than `Workers` are running, and exits as soon as the queue is empty, so an application that never registers a task never runs one, and a request that registers none pays nothing for the feature. A panic in a task is recovered and logged with the request identifier and the stack, and the worker carries on with the next task. ``` 14:32:07.913 ERROR [Server] muzak: recovered from a panic in a background task panic="assignment to entry in nil map" request_id=0192a7c1 stack=... ``` ## Shutdown A shutdown runs the tasks it already accepted, in an order that keeps them working: 1. Readiness reports unavailable, and the server keeps serving through `DrainDelay` (see [Health Checks](/docs/deployment/health-checks)). Tasks registered meanwhile are accepted as usual. 2. The listeners close. From here on `AfterResponse` returns `muzak.ErrBackgroundShuttingDown`, while requests in flight are drained; the tasks they registered earlier are still handed over when they finish. 3. The queued and running tasks are waited for. 4. The [lifecycle components](/docs/getting-started/lifecycle) are stopped, so a task can still use the database pool it needs. All of it shares `ShutdownTimeout`. When the deadline passes during step 3, the tasks still waiting for a worker are dropped, the running ones have their context cancelled and are given 100 milliseconds to return, and the components are stopped anyway. Both are logged: ``` 14:32:56.903 WARN [Server] Shutdown deadline reached; dropped 12 queued background tasks that had not started 14:32:57.004 WARN [Server] 1 background task still running after the shutdown deadline and its grace period; stopping lifecycle components anyway ``` Size the pool with that in mind: `Workers` should get through a full `Queue` within `ShutdownTimeout`. A negative `ShutdownTimeout` waits for the tasks without limit. ## Where it is refused | Called from | Result | |---|---| | A handler, guard or provider of an ordinary route | Registered | | An event stream or WebSocket handler | An error: the response ends only when the handler returns, so do the work then | | A guard or provider of the documentation, a static file or frontend mount, or a handler given to `Mount` | An error: no route is being served | | A goroutine after the handler returned | An error, if the Context has been released; and a data race either way | | `AfterResponse(nil)` | An error | An application served by hand through `App.ServeHTTP` runs its tasks the same way, but nothing drains them, since no `Shutdown` is involved. ## Testing The [test client](/docs/fundamentals/testing) serves the application through the same server and shuts it down the same way when the test ends, so the test's cleanup waits for the tasks its requests registered. A test that wants to see a task's effect before then waits for it on a channel the task closes. ## Where to go next [Lifecycle](/docs/getting-started/lifecycle) covers the components a task uses, [Health Checks](/docs/deployment/health-checks) the readiness drop and drain delay that begin a shutdown, and [Timeouts](/docs/techniques/timeouts) the deadline a task does not inherit. -------------------------------------------------------------------------------- title: "Timeouts" description: "Give a route, or every route of a router, a deadline its guards, providers and handler share, answered with a 503 when it passes and never enforced by a second goroutine." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/timeouts" -------------------------------------------------------------------------------- # Timeouts `muzak.Timeout` gives a route a deadline for answering a request. ```go [routers/reports.go] r := muzak.NewRouter(muzak.WithPrefix("/reports"), muzak.Timeout(5*time.Second)) r.Get("/{id}", handlers.GetReport) // 5s, from the router r.Post("/export", handlers.Export, muzak.Timeout(30*time.Second)) // 30s, its own r.Get("/live", handlers.LiveTotals, muzak.Timeout(-1)) // none ``` It is a `SharedOption`, so it goes on a route, on a router, at the point a router is included, or on `New` for the whole application. The narrower declaration wins, whether it is shorter or longer, a negative value removes an inherited deadline, and zero leaves the inherited one as it is. ## A cooperative deadline The deadline is set on the request's context, `ctx.Context()`, after a rate limit counted before the dependencies and before anything the route itself runs: the security schemes it enforces, the body `CaptureBody` keeps, its dependencies and the binding of its input. Guards, providers, the handler and everything they pass the context to see it: ```go func GetReport(ctx *muzak.Context, in schemas.ReportParams) (schemas.ReportOut, error) { // The query gives up when the route's deadline passes, and its error // comes back here. rows, err := db.QueryContext(ctx.Context(), reportQuery, in.ID) if err != nil { return schemas.ReportOut{}, err } defer rows.Close() // ... } ``` Nothing that does not watch the context is interrupted. There is no second goroutine running the handler while the first one writes a 503, which is how timeout middleware usually works and why it usually races: the handler keeps running, keeps the request's resources, and can still write into a response that was already answered. A Muzak handler always finishes on its own goroutine, with its `*muzak.Context` to itself. ## What the client receives | The route | The client gets | |---|---| | Fails with an error caused by the deadline, before the response started | `503` with `Retry-After: 1`, through the application's error renderer | | Fails with an error that carries its own status, `muzak.GatewayTimeout(...)` say | That status | | Fails because of the deadline after the response started | An aborted connection, as for any failure once a response has started | | Ignores the deadline and returns successfully | Its success, however late | | Fails because the client left, or a shorter deadline of the handler's own passed | What that error renders as, not a 503 | An error is caused by the deadline when `errors.Is` reports it as `context.DeadlineExceeded` and the deadline that passed is the route's own. That second condition is what keeps a client that disconnected, or a `context.WithTimeout` the handler set for one call, from being reported as the route running out of time. The cause is logged; the client is told only that the request took longer than the operation allows: ```json { "error": { "code": "service_unavailable", "message": "the request took longer than this operation allows; try again shortly", "status": 503 }, "request_id": "0192a7c1-..." } ``` **A success is sent, however late.** A handler that ignores its context and returns a value has done its work, and throwing the result away would only make the client repeat it. The deadline is a budget the handler is asked to respect, not one the framework can enforce without the second goroutine it refuses to start. The exception is work that was undone underneath the handler. A request's `Transaction` is rolled back by `database/sql` the moment the deadline passes, so a handler that carries on and succeeds has nothing left to commit, and is answered with the 503 rather than a success whose writes were thrown away. See [Dependencies](/docs/getting-started/dependencies). **503, not 504.** A `504 Gateway Timeout` says a gateway's upstream did not answer, which sends whoever reads it to look at a proxy. A `503` with a `Retry-After` says this server could not answer in time, and when to try again. A route with a deadline documents the 503 in the OpenAPI document, unless it declares a 503 of its own with `WithResponseDoc`. ## Event streams and WebSockets A stream lasts as long as its connection, so a deadline on it would be a deadline on the conversation. `Timeout` declared directly on an `SSE` or `WS` route is a build error that names the option that bounds a stream instead: ``` muzak: SSE GET /events: Timeout cannot be declared on a route whose response lasts as long as its connection; bound it with SSEOptions.MaxLifetime instead ``` A deadline inherited from a router is not applied to the streams beneath it, so a router can carry both, and `Timeout(-1)` is always allowed. See [Server-Sent Events](/docs/realtime/server-sent-events#ending-a-stream-on-purpose) and [WebSockets](/docs/realtime/websockets#ending-a-connection-on-purpose). ## Mounted handlers A handler served with `Router.Mount` writes its own response and may be streaming one, so it is treated the same way. A `Timeout` given to `Mount` is a build error: ``` muzak: mount at "/legacy": Timeout cannot be declared on a mount, whose handler writes its own response; give the handler a deadline of its own, with http.TimeoutHandler for one ``` and one inherited from a router is not applied to the mounted handler. See [Mounting Handlers](/docs/techniques/mounting-handlers). ## Next to the server's timeouts `Timeout` is not `WriteTimeout`. The server's `WriteTimeout` still bounds how long a response may take to write and closes the connection when it passes, so a route deadline longer than `WriteTimeout` is cut short by it, with no 503. Keep route deadlines below it, or raise it for the routes' sake. See [Server Configuration](/docs/deployment/server-configuration#timeouts). A background task registered with `AfterResponse` does not inherit the deadline: it keeps the request's values but not its cancellation. See [Background Tasks](/docs/techniques/background-tasks). ## Cost A route without a deadline pays nothing for the option existing: no context, no timer, no copy of the request. A route with one pays for one context with a timer and one shallow copy of the `*http.Request`, whatever the size of its body. ## Where to go next [Dependencies](/docs/getting-started/dependencies) covers the providers that run under the deadline, and [Error Handling](/docs/getting-started/error-handling) the renderer the 503 goes through. -------------------------------------------------------------------------------- title: "HTTP Client" description: "An outbound client that refuses private and metadata addresses at the moment of connecting, bounds every wait and every body, retries only what is safe to send twice, and carries the request id along." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/http-client" -------------------------------------------------------------------------------- # HTTP Client An application that fetches a URL someone else chose, a webhook target, an avatar, an import link, is one request away from fetching `http://169.254.169.254/` on its own behalf. `muzak.NewClient` builds the client to make those calls with: a `net/http` client with the decisions that make it safe already made. ```go client := muzak.NewClient(muzak.ClientOptions{}) defer client.Close() ``` The zero options are the strict ones. Loopback, private, link-local and metadata addresses are refused, at most five redirects are followed and never from https down to http, at most ten mebibytes of a response are read, a call gives up after thirty seconds, and a request is retried only when sending it twice is harmless. ## Making a request `Client.Do` takes an ordinary `*http.Request` and behaves as `http.Client.Do` does, except where safety calls for something else. The helpers decode JSON for the common case. ::code-group ```go [core/clients.go] // Upstream is the client every handler calls other services with. One client // for the whole application, because it holds the pool of open connections. var Upstream = muzak.NewClient(muzak.ClientOptions{}) // UpstreamLifecycle releases the client's idle connections on shutdown. var UpstreamLifecycle = muzak.NewLifecycle("upstream", nil, func(context.Context) error { return Upstream.Close() }) ``` ```go [routers/previews.go] type PreviewIn struct { URL string `query:"url"` } func (in *PreviewIn) Validate(v *muzak.Validation) { // A syntax check, not a safety one: whether the address may be fetched // is decided by the client, at the moment it connects. v.String(&in.URL).Required().HTTPS() } type PageMeta struct { Title string `json:"title"` } func Preview(ctx *muzak.Context, in PreviewIn) (PageMeta, error) { meta, err := core.Upstream.GetJSON[PageMeta](ctx.Context(), in.URL) var refused *muzak.AddressRefusedError if errors.As(err, &refused) { return PageMeta{}, muzak.BadRequest("that address cannot be fetched") } return meta, err } ``` ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{Title: settings.AppName}, muzak.WithLifecycle(core.UpstreamLifecycle), ) ``` :: Passing `ctx.Context()` matters twice over: the call ends when the request it serves ends, and the request id the `RequestID` middleware assigned goes along in `X-Request-Id`, with the trace context when the application traces requests (see [Propagation](#propagation)). Any other error from `Do` is safe to return from a handler as it is, and renders as an opaque 500. Its message names the method and the origin, `GET https://api.example.com`, and never the path or the query string, which is where URLs carry their secrets. ## What is refused Every address the client is about to connect to is checked, on the socket, at the moment of connecting. It is not checked when the URL is read, because that is not the address that matters: a name can resolve somewhere else by the time it is fetched. | Refused | Ranges | Opened by | |---|---|---| | Loopback and unspecified | `127.0.0.0/8`, `0.0.0.0/8`, `::1`, `::` | `AllowPrivateNetworks` | | Private | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`, local-use NAT64 `64:ff9b:1::/48` | `AllowPrivateNetworks` | | Link-local IPv6 | `fe80::/10` | `AllowPrivateNetworks` | | Shared and reserved | CGNAT `100.64.0.0/10`, benchmarking `198.18.0.0/15` and `2001:2::/48`, `240.0.0.0/4`, broadcast, multicast, IETF assignments `192.0.0.0/24`, the old 6to4 relays `192.88.99.0/24` | `AllowPrivateNetworks` | | Documentation | `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`, `2001:db8::/32`, `3fff::/20` | `AllowPrivateNetworks` | | Everything outside IPv6 global unicast | anything not in `2000::/3`, plus Teredo, ORCHID, discard-only | `AllowPrivateNetworks` | | Cloud metadata | all of IPv4 link-local `169.254.0.0/16`, `168.63.129.16` (Azure), `100.100.100.200` (Alibaba), `192.0.0.192` (Oracle), `fd00:ec2::254` and `fd00:ec2::23` (AWS), `fd20:ce::254` (Google) | only an `AllowedNetworks` entry | An IPv6 address that delivers to an IPv4 one is judged by both, so `[::ffff:127.0.0.1]`, `[64:ff9b::7f00:1]` (NAT64), `[2002:7f00:1::]` (6to4), `[::ffff:0:7f00:1]` (the IPv4-translated form of SIIT) and `[::127.0.0.1]` are refused with the address they reach. The local-use NAT64 block `64:ff9b:1::/48` belongs to a network's own translator, which places the IPv4 address in one of several layouts the client cannot see. It is refused as a private range is, and an address in it is read in every layout a translator may use, so a metadata service or a network in `DeniedNetworks` stays refused through it even with `AllowPrivateNetworks` set. A host that browsers and the C resolver read as an IPv4 address and Go reads as a name is refused whatever it points at: `127.1`, `0177.0.0.1`, `0x7f.0.0.1`, `2130706433`. A check and a fetch that disagree about what a host is are how request forgery filters are usually got around, so the client does not guess. `localhost` and every name under it are refused with loopback. A refusal is an `*muzak.AddressRefusedError` naming the host, the address and what it is. Nothing was sent, and it is never retried. ### Names, rebinding and redirects The check runs in the dialer, which is the one place every connection is opened, so: - a name that resolves to a private address is refused when it is dialled; - a name that resolves to a public address and then, the second time it is asked, to the metadata service, which is what DNS rebinding is, is refused on the second connection; - a name that resolves to several addresses is connected only to the ones that pass, in the resolver's order; - a redirect, however many hops in, meets the same check, and so does every retry. ### Calling your own services Service-to-service calls inside one network are what `AllowPrivateNetworks` is for. It never opens a metadata service, which holds the credentials of the machine the client runs on. ```go // Calls to the inventory service, inside the cluster. Only that subnet. inventory := muzak.NewClient(muzak.ClientOptions{ AllowedNetworks: []netip.Prefix{netip.MustParsePrefix("10.20.0.0/16")}, }) // Calls to any internal service, but never to the database subnet. internal := muzak.NewClient(muzak.ClientOptions{ AllowPrivateNetworks: true, DeniedNetworks: []netip.Prefix{netip.MustParsePrefix("10.30.0.0/16")}, }) ``` `DeniedNetworks` wins over everything, `AllowedNetworks` over everything else. Keep a strict client for URLs that came from outside and a relaxed one for your own configuration, rather than one relaxed client for both. A prefix that is not valid panics in `NewClient`, because a list with a hole in it does not mean what it says. ### Behind an egress proxy The client never reads `HTTP_PROXY`. A proxy decides where requests go, and an environment variable is a setting anything that can set one controls. Name it instead: ```go egress, _ := url.Parse("http://egress.internal:3128") client := muzak.NewClient(muzak.ClientOptions{Proxy: egress}) ``` The proxy is connected to without the address checks, and is trusted to apply its own: it is the proxy that resolves the names it is asked for. The client still refuses what it can judge without resolving anything, a refused address written literally, a localhost name, a name ending in a number, and a name outside ASCII, which `net/http` would map before the proxy saw it. A proxy that enforces no egress policy of its own reopens every request forgery the client otherwise closes. ## Bounds | Bound | Default | Field | |---|---|---| | The whole call: every attempt, redirect and wait, and reading the body | 30 seconds | `Timeout` | | Resolving and connecting | 10 seconds, shared between a name's addresses | `DialTimeout` | | TLS handshake | 10 seconds | `TLSHandshakeTimeout` | | Waiting for the response header | 20 seconds | `ResponseHeaderTimeout` | | Response body | 10 MiB, counted after decompression | `MaxResponseBytes` | | Response header | 1 MiB | not configurable | | Redirects | 5 | `MaxRedirects` | | Attempts | 3, the first included | `MaxAttempts` | | Addresses tried for one name | 16 | not configurable | | TLS | 1.2 or later; a lower `MinVersion` is raised | `TLSConfig` | A negative timeout removes that bound and leaves the request's context in charge. The body limit cannot be removed, only raised. A response that declares a `Content-Length` over the limit is refused by `Do` with `muzak.ErrResponseTooLarge` before any of it is read; one that declares none, or is compressed, fails with the same error on the read that passes it, so a gzip body that expands a thousandfold stops at the limit. `Timeout` keeps running after `Do` returns, until the body is closed or read to its end. Close every body, as with `net/http`. ## Redirects A redirect is followed only to an address the client would have connected to directly, never from https to plain http, and at most `MaxRedirects` times. `MaxRedirects: -1` follows none and hands back the redirect itself, `Location` and all. When a redirect leaves the origin the request was sent to, `Authorization`, `Cookie`, `Proxy-Authorization` and every header in `SensitiveHeaders` are removed, and so is the `Referer` net/http would otherwise add. An origin is the scheme, host and port together, so a subdomain, another port and an upgrade from http to https all count as leaving it, which is stricter than `net/http`. Once one hop has left, the headers stay off for the rest of the chain, so a host passed through cannot send the request back to the origin, with the credentials, at a path of its choosing. ```go client := muzak.NewClient(muzak.ClientOptions{ SensitiveHeaders: []string{"X-Api-Key"}, }) ``` The headers are what is removed, not the body. A `307` or `308` asks for the same request again, body included, and the client follows it to another origin as `net/http` does, so a request body goes wherever such a redirect points, as long as the address passes. For a call whose body must reach one origin and no other, set `MaxRedirects: -1` and judge the redirect yourself. ## Retries A request is retried only when sending it twice is harmless: `GET`, `HEAD`, `OPTIONS`, `PUT` and `DELETE`, and any request carrying an `Idempotency-Key` header (or `X-Idempotency-Key`, which `net/http`'s own transport also honours). It is retried after a connection that failed or broke, and after `429`, `502`, `503` and `504`. When the attempts run out, the last response is returned as it is, for the caller to read. What is never retried: a `POST` or `PATCH` without an idempotency key, a request whose body cannot be read twice (it has no `GetBody`, which `http.NewRequest` sets for a `bytes.Reader`, `bytes.Buffer` or `strings.Reader`), a refused address, a refused redirect, an open circuit, a certificate that does not verify, a TLS alert, a name that does not exist, and a call whose context has ended. ```go req, _ := http.NewRequestWithContext(ctx.Context(), http.MethodPost, chargesURL, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") // The payment provider deduplicates on this key, so a retry cannot charge twice. req.Header.Set("Idempotency-Key", order.ID) charge, err := client.DoJSON[Charge](req) ``` The wait between attempts is random, between zero and a ceiling that starts at `RetryBaseDelay` (100 ms) and doubles up to `RetryMaxDelay` (2 s). Drawing the whole wait at random is what keeps a thousand clients that failed together from retrying together. ### Retry-After A `Retry-After` header, in seconds or as an HTTP date, is a floor on the wait. One that asks for longer than `MaxRetryAfter` (10 s) is not waited out: the response is returned, since retrying sooner than the server asked is what it asked not to have. A wait that would outlast the call's deadline is not slept either; the response is returned at once rather than after the deadline has been spent sleeping. A date in the past means now, and a number too large to represent is treated as a very long wait rather than wrapping into a negative one. ### The retry budget Retries are what turn an upstream's bad minute into its bad hour: every failing request becomes three, and the load that made it fail triples. The client keeps a bucket of retry tokens. Every response that is not a server error earns a tenth of one, every retry spends one, and a retry with no token to spend is not made. It starts with ten and holds at most ten. ```go client := muzak.NewClient(muzak.ClientOptions{ RetryBudget: muzak.RetryBudget{Ratio: 0.2, Burst: 20}, }) ``` `MaxAttempts: 1` turns retrying off altogether. ## Circuit breaker A host that is down answers slowly or not at all, and every caller waiting on it holds a goroutine and a connection for as long as the timeout allows. The breaker is off until it is given a threshold: ```go client := muzak.NewClient(muzak.ClientOptions{ CircuitBreaker: muzak.CircuitBreakerOptions{Threshold: 5, Cooldown: 30 * time.Second}, }) ``` After `Threshold` failures in a row to one host, a connection error, a timeout or a `5xx`, requests to that host fail at once with `muzak.ErrCircuitOpen` for `Cooldown`. Then one request is let through: success closes the circuit, failure opens it for another cooldown, and every other request is refused while that one is out. A caller cancelling its own request is not a failure; a deadline passing is, since a host that hangs is the one a breaker is most for. A host is a scheme, a name and a port, and each step of a redirect counts against the host it reached. Only failing hosts are remembered, at most `MaxHosts` (1024) of them, the least recently used forgotten first, so calling ten thousand hosts cannot grow the table. ## Propagation Every request is passed through `ClientOptions.Propagate` once before it is sent. The default, `muzak.DefaultPropagate`, copies the request id from the context into `X-Request-Id`, so that the logs of two services can be joined on it, and leaves a header the caller set alone. An id that holds a control character or is longer than 256 bytes is left out rather than failing the call. When the application traces requests, it also writes the W3C `traceparent` and `tracestate` of the current span with `muzak.InjectTraceContext`, so the service called records its work as part of the same trace; a `traceparent` the caller set is left alone, and a request that is not traced sends none. See [Observability](/docs/fundamentals/observability). ```go client := muzak.NewClient(muzak.ClientOptions{ Propagate: func(ctx context.Context, h http.Header) { muzak.DefaultPropagate(ctx, h) h.Set("X-Tenant", tenantFrom(ctx)) }, }) ``` A function that does nothing turns propagation off. Whatever a propagator adds is checked like any other header. On a call made with a typed endpoint's `Call`, a propagator may fill a header the input binds and left empty, but one that changes a header the input set refuses the call; see [Typed Endpoints](/docs/techniques/typed-endpoints). `DefaultPropagate` adds its headers to every request, whoever the URL names. Neither header is a secret, but both let the receiver join its records to yours, and a `traceparent` says whether you are sampling. A client that fetches URLs from outside, a webhook target or an avatar, should not hand them to whoever chose the URL: give that client a `Propagate` that adds nothing, and keep the default for the clients that call your own services. ```go // Fetches URLs users supply. Nothing about our own requests goes with them. fetcher := muzak.NewClient(muzak.ClientOptions{ Propagate: func(context.Context, http.Header) {}, }) ``` ## Headers A header value holding a carriage return, a line feed or a NUL is refused before anything is sent, and so is a header name that is not a token. That is how a value that came from outside, a forwarded token or an identifier, would otherwise write a header of its own. The error names the header and never repeats its value. ## JSON | Method | Sends | Returns | |---|---|---| | `client.GetJSON[T](ctx, url)` | `GET` with `Accept: application/json` | the decoded `T` | | `client.PostJSON[T](ctx, url, body)` | `POST` of `body` as JSON | the decoded `T`; never retried | | `client.DoJSON[T](req)` | your request, `Accept` added if missing | the decoded `T` | A status that is not `2xx` is an `*muzak.RemoteError` carrying the status, the header and the first 64 KiB of the body. When the body is the error envelope a Muzak application writes, the default one or a problem details document, its `Code`, `Message`, `Details` and `RequestID` are filled in, and `Code` is what to branch on. The body is read as an envelope only under a JSON media type, whole or not at all, keeping at most a hundred details, so a truncated or malformed body leaves those fields empty rather than half filled. Its message is the method, origin and status, `muzak: GET https://api.example.com answered with status 404 (not_found)`, naming the code only when it is a plain identifier of at most 64 bytes, and never the body or the message, which are the other server's text. Handle it with `errors.As` and the `StatusCode` or `Code`, not by matching the message. ```go item, err := client.GetJSON[Item](ctx.Context(), itemURL) var remote *muzak.RemoteError if errors.As(err, &remote) && remote.Code == "not_found" { return Item{}, muzak.NotFound("no such item upstream") } ``` A success not labelled as JSON is refused rather than guessed at, which is how an HTML error page from a proxy announces itself. `204` and `HEAD` decode to the zero `T`. Members `T` does not have are ignored, as a client reading another service's answers should; duplicate members and invalid UTF-8 are refused. ## Errors | Error | Test with | Meaning | |---|---|---| | `*muzak.AddressRefusedError` | `errors.As` | The address is one the policy refuses. Nothing was sent | | `muzak.ErrResponseTooLarge` | `errors.Is` | The body passed `MaxResponseBytes` | | `muzak.ErrCircuitOpen` | `errors.Is` | The host's circuit is open. Nothing was sent | | `*muzak.RemoteError` | `errors.As` | A JSON helper, or a typed endpoint's `Call`, got a status that is not `2xx` | | `context.DeadlineExceeded` | `errors.Is` | `Timeout`, or the context's own deadline, passed | | `context.Canceled` | `errors.Is` | The context was cancelled | ## Shutting down `Close` releases the idle connections and does not stop the client: a request made afterwards opens a connection again, which is what lets a `Lifecycle`'s `Stop` call it while a late request is still finishing. ## Testing code that calls out A test server from `httptest` listens on `127.0.0.1`, which a strict client refuses. Build the client the code under test uses from options the test can set: ```go func TestPreview(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"title":"Plumbus"}`)) })) defer upstream.Close() client := muzak.NewClient(muzak.ClientOptions{AllowPrivateNetworks: true}) defer client.Close() meta, err := client.GetJSON[PageMeta](t.Context(), upstream.URL) // ... } ``` Keep `AllowPrivateNetworks` out of the production configuration of a client that fetches URLs from outside, and test the refusal too: a URL of `http://127.0.0.1/` must come back as an `*muzak.AddressRefusedError`. [Testing](/docs/fundamentals/testing) covers testing the application's own routes. The same applies to a JWT bearer scheme's key set, which is fetched through a client built here with the strict defaults. An identity provider on a private network, or a test server, needs `JWKSOptions.Client` set to a client with `AllowPrivateNetworks` or an `AllowedNetworks` entry; see [Authentication](/docs/security/authentication). ## Limits - A NAT64 translator configured with a prefix of the network's own choosing, outside `64:ff9b::/96` and `64:ff9b:1::/48`, is invisible to the client: its addresses look like any public IPv6 address. If your network has one, list that prefix in `DeniedNetworks`. - A `307` or `308` that leaves the origin is followed with the request body, as described under [Redirects](#redirects). - `DefaultPropagate` sends the request id and the trace context to any URL, as described under [Propagation](#propagation). ## Options reference | Field | Default | Effect | |---|---|---| | `AllowPrivateNetworks` | off | Connect to loopback, private and the other special-purpose ranges. Never opens metadata | | `AllowedNetworks` | none | Prefixes reachable whatever else says, metadata included | | `DeniedNetworks` | none | Prefixes never reachable, whatever else says | | `Proxy` | none, and never from the environment | Send every request through this http, https or socks5 proxy, trusted to enforce egress | | `TLSConfig` | system roots, TLS 1.2 minimum | Cloned; a lower `MinVersion` is raised to 1.2 | | `Timeout` | `DefaultClientTimeout`, 30 seconds | The whole call, body included. Negative removes it | | `DialTimeout` | `DefaultClientDialTimeout`, 10 seconds | Resolving and connecting. Negative removes it | | `TLSHandshakeTimeout` | `DefaultClientTLSHandshakeTimeout`, 10 seconds | Negative removes it | | `ResponseHeaderTimeout` | `DefaultClientResponseHeaderTimeout`, 20 seconds | Negative removes it | | `MaxResponseBytes` | `DefaultClientMaxResponseBytes`, 10 MiB | Decompressed body size | | `MaxRedirects` | `DefaultClientMaxRedirects`, 5 | Negative returns redirects unfollowed | | `SensitiveHeaders` | none beyond the three built in | Dropped when a redirect leaves the origin | | `MaxAttempts` | `DefaultClientMaxAttempts`, 3 | 1 turns retrying off | | `RetryBaseDelay`, `RetryMaxDelay` | 100 ms, 2 s | The doubling ceiling of the random wait | | `MaxRetryAfter` | `DefaultClientMaxRetryAfter`, 10 seconds | Longest `Retry-After` waited out | | `RetryBudget` | `Ratio` 0.1, `Burst` 10 | Retries earned per success and banked | | `CircuitBreaker` | off | `Threshold`, `Cooldown` (30 s), `MaxHosts` (1024) | | `Propagate` | `DefaultPropagate` | Adds headers from the context to every request: the request id, and the trace context when tracing is on | | `BaseURL` | none | The root a typed endpoint's `Call` sends to. An absolute http or https URL with no query, fragment or user information, or `NewClient` panics. `Do` and the JSON helpers never read it | `NewClient` panics on options that cannot mean what they say: an invalid prefix in `AllowedNetworks` or `DeniedNetworks`, a `Proxy` with no host or a scheme no proxy speaks, and a `BaseURL` that is not a base. ## Where to go next [Typed Endpoints](/docs/techniques/typed-endpoints) covers calling another Muzak service with the server's own types through this client, [Safe Defaults](/docs/security/safe-defaults) lists every other default and how to relax it, [Lifecycle](/docs/getting-started/lifecycle) covers closing the client on shutdown, [Logging](/docs/fundamentals/logging) covers the request id the client carries along, and [Observability](/docs/fundamentals/observability) covers the trace it continues. -------------------------------------------------------------------------------- title: "Typed Endpoints" description: "Declare an operation once, in a package the service and its Go callers share, and let the compiler hold both sides to it. A call writes its input as the exact request the binder reads back." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/typed-endpoints" -------------------------------------------------------------------------------- # Typed Endpoints When one Go service calls another, the request and the response are described twice: once by the handler that serves them, and once by the code that builds the URL, sets the headers and decodes the answer. The two drift. A query parameter is renamed on one side, a field moves from the body to a header, and the first thing that notices is production. `muzak.NewEndpoint` declares an operation once, as a value: its method, its path template, the route options that describe it, and its `In` and `Out` types. The service implements it with `Router.Implement`, its callers call it with `Endpoint.Call`, and both sides are typed by the same declaration. ::code-group ```go [userapi/userapi.go] // Package userapi is imported by the users service and by everything that // calls it. It holds the shapes and the endpoints, and nothing else. package userapi type GetUserIn struct { ID string `path:"id"` Expand []string `query:"expand"` Tenant string `header:"X-Tenant"` } type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } var GetUser = muzak.NewEndpoint[GetUserIn, User](http.MethodGet, "/users/{id}", muzak.Summary("Fetch a user"), muzak.WithTags("users")) ``` ```go [users/routers/users.go] func NewRouter() *muzak.Router { r := muzak.NewRouter() r.Implement(userapi.GetUser, handlers.GetUser) return r } ``` ```go [orders/core/clients.go] var Users = muzak.NewClient(muzak.ClientOptions{ AllowPrivateNetworks: true, BaseURL: settings.UsersURL, // "http://users.internal:8080" }) ``` ```go [orders/handlers/orders.go] func GetOrder(ctx *muzak.Context, in OrderIn) (OrderOut, error) { user, err := userapi.GetUser.Call(ctx.Context(), core.Users, userapi.GetUserIn{ID: in.UserID, Tenant: in.Tenant}) if err != nil { return OrderOut{}, err } return OrderOut{Customer: user.Name}, nil } ``` :: ## Implementing an endpoint `Router.Implement` registers the handler exactly as `Router.Handle` with the endpoint's method and path would: the same route, the same binding, the same document. The endpoint's own route options apply first and the ones given to `Implement` after them, so a guard, a rate limit or a timeout that belongs to this deployment is added where the endpoint is implemented, and the summary and tags that belong to the operation travel with it. ```go r.Implement(userapi.GetUser, handlers.GetUser, muzak.WithDependencies(core.RequireServiceToken), muzak.Timeout(2*time.Second)) ``` The path is relative to wherever the endpoint is implemented. A router included under `WithPrefix("/v1")` serves it at `/v1/users/{id}`, and its callers put `/v1` at the end of their `BaseURL`. ### What the compiler catches The handler's types are the endpoint's. Every one of these is a compile error rather than a route that disagrees with its callers: ```go // The handler takes another input type. r.Implement(userapi.GetUser, func(ctx *muzak.Context, in OtherIn) (userapi.User, error) { ... }) // in call to r.Implement, type func(ctx *muzak.Context, in OtherIn) (userapi.User, error) // does not match inferred type muzak.Handler[userapi.GetUserIn, userapi.User] // The handler returns another output type. r.Implement(userapi.GetUser, func(ctx *muzak.Context, in userapi.GetUserIn) (Profile, error) { ... }) // in call to r.Implement, type func(...) (Profile, error) does not match inferred type // muzak.Handler[userapi.GetUserIn, userapi.User] // A caller passes the wrong input. userapi.GetUser.Call(ctx, users, OtherIn{ID: "42"}) // cannot use OtherIn{...} as userapi.GetUserIn value in argument to userapi.GetUser.Call // A caller expects the wrong output. var p Profile p, err = userapi.GetUser.Call(ctx, users, userapi.GetUserIn{ID: "42"}) // cannot use 1st function result (value of struct type userapi.User) as Profile value // An endpoint converted to another pair of types. other := muzak.Endpoint[OtherIn, userapi.User](userapi.GetUser) // cannot convert userapi.GetUser (variable of struct type muzak.Endpoint[userapi.GetUserIn, // userapi.User]) to type muzak.Endpoint[OtherIn, userapi.User] ``` The last one is closed on purpose. Every `Endpoint[In, Out]` would otherwise share one underlying struct type, and Go allows a conversion between two such types, which would hand an endpoint compiled for one input type to a call passing another. ### Mistakes in the declaration An endpoint is usually a package-level variable, and a panic while packages initialise stops the program before it can say which declaration was wrong. So `NewEndpoint` checks the method and the path when it is called, by the rules the router holds a route to, and records what it finds. A mistake is a build error of the application that implements the endpoint, joined with every other, and the error every call returns. The input type is checked when the endpoint is implemented, as any route's is, and again on the first call, for what writing it as a request needs. A type the server binds but a call cannot write, such as one with `UnmarshalText` and no `MarshalText`, fails every call with an error saying what to change. A panic while that plan is compiled, such as a `Validate` method that panics on the zero input, is the panic of every call alike, never of the first call only. ## Calling an endpoint `Endpoint.Call` sends the input to the endpoint at the client's `BaseURL` and returns what the handler answered, decoded into `Out`. ```go user, err := userapi.GetUser.Call(ctx.Context(), core.Users, userapi.GetUserIn{ID: "42"}) ``` The client is an ordinary `muzak.NewClient`, with everything that comes with one: address checks at the moment of connecting, bounded waits and bodies, retries only for what is safe to send twice, a circuit breaker if you turn one on, and the request id carried along. A service calling its own network sets `AllowPrivateNetworks`, or names its subnets in `AllowedNetworks`. `ClientOptions.BaseURL` is the root of the service, such as `http://users.internal:8080` or `https://api.example.com/v1`. It lives on the client because a client is built per service: it holds that service's connection pool and circuit breaker. It must be an absolute http or https URL with a host and nothing after its path; a query, a fragment or user information panics in `NewClient`, because each would be sent with every call. `Client.Do` and the JSON helpers take absolute URLs and never read it. ### How the input is written A call writes its input as the exact inverse of binding. The plan it follows is compiled, on the first call, from the binder's own plan for the same type, so the two cannot disagree about which field comes from where. | Location | How a value is written | |---|---| | `path` | Escaped segment by segment; a trailing `{name...}` parameter may hold `/` | | `query` | Once, or once per entry for a list, with every byte escaped | | `header` | As it is; a list is one line of entries separated by `, ` | | `cookie` | As it is, quoted when it holds a space or a comma | | JSON body | The body members alone, never a located field or a `Dep` | | `form`, `file` | `multipart/form-data`, a value or a `[]byte` file per part | Numbers are written in the decimal grammar the binder reads, the shortest that reads back as the same value. A `time.Duration` is written as `1500ms`, and a `time.Time`, a `uuid.UUID` or any type that reads itself with `UnmarshalText` writes itself with `MarshalText`. An input that decodes its body itself, with an `UnmarshalJSON` of its own, is still written from its body members alone. Its located fields are left out of the body, so an `Authorization` or a session cookie it binds goes in its header or cookie and never into a body that may be logged and kept. An input that binds a header net/http writes itself, or that frames the request, cannot be called: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, `Proxy-Connection`, `TE`, `Trailer`, `Upgrade`, `Accept-Encoding`, and `Cookie`, which is what the `cookie` tag is for. `Content-Type` may be bound by an input that has no body. A `User-Agent` or an `Accept` the input binds and leaves out arrives absent, rather than as the one net/http or the call would otherwise write. Headers `ClientOptions.Propagate` adds, the request id and the trace context with the default, are added after the input is written. A field bound to one of them that the input leaves out, a nil pointer or an empty list, receives the propagated value. A header the input did set and `Propagate` changes refuses the call with `ErrCallRefused`, since the server would not read the input's value: a `Propagate` of your own should leave a header that is already set alone, as `muzak.DefaultPropagate` does. A non-pointer field is always set, even when it is empty. A form is always sent as multipart, which every method can carry, and a `[]byte` file is a part named after its field. A `muzak.File` field is what a handler receives and holds no content a caller could set, so an input with one cannot be called: declare the field `[]byte`, or `[][]byte` for several files. ### Values a request cannot carry Some values have no request that carries them unchanged. They are refused before anything is sent, with an error that wraps `muzak.ErrCallRefused` and names the field and the reason, never the value, which may be a credential. | Value | Why it is refused | |---|---| | An empty path parameter | The router never matches an empty segment | | `.` or `..` as a path segment | Clients and proxies resolve them as steps through the path | | `/` in a path parameter that is not a trailing `{name...}` | Muzak keeps `%2F` inside its segment, but some proxies decode it and route elsewhere, and others refuse it | | An empty segment in a `{name...}` value | Proxies merge `//` and drop a trailing `/` | | A header with a control character | A line break would end the header and write another | | A header with a space or tab at either end | Every HTTP parser drops them | | A header list entry that is empty, or holds a comma or an unbalanced quote | The server would read it as the edge of another entry | | An empty `User-Agent` | net/http leaves an empty one out, so it would arrive absent | | A cookie with a control character, `"`, `;`, `\` or a byte outside ASCII | The cookie parser drops it, and net/http would drop it with only a log line | | A list of more than one value in a path parameter or a cookie | Each carries exactly one | | A number that is not finite | The binder reads only decimal numbers | | A body string that is not UTF-8 | The JSON decoder refuses it | | A body member with a `default` that its json tag's `omitzero` or `omitempty` leaves out | The server would read the default in its place | | A header given to `CallHeader` that the input binds or a call writes itself, or a value with a line break or a space at either end | Two values would compete, or the value would arrive altered | ```go _, err := api.GetFile.Call(ctx, client, api.FileIn{Name: in.Name}) if errors.Is(err, muzak.ErrCallRefused) { // The caller's input was at fault, not the upstream. return Out{}, muzak.BadRequest("that is not a file name") } ``` One value is not refused, because the client cannot see it: a path parameter that spells a static route beside it. With `/users/me` and `/users/{id}` registered, a call with `ID: "me"` reaches `/users/me`, as a browser would. ### Absent values A nil pointer and an empty list are not sent, because a request has no way to spell them, so the server reads them as absent: nil, or the default the field's tag declares. A zero value that is not a pointer is sent, so a default never replaces a zero that was written on purpose. ```go type ListIn struct { Limit int `query:"limit" default:"50"` // Limit: 0 arrives as 0 Cursor *string `query:"cursor"` // Cursor: nil arrives as nil } ``` A body member is the one place a zero can be left out: a json tag with `omitzero` or `omitempty` drops it from the body, and the server would then read the member's default. So a body member that has a `default` and that its tag would leave out is refused with `ErrCallRefused` rather than replaced by the default. Send a value the tag keeps, or a nil pointer, or an empty list, when the default is what you mean. ```go type CreateIn struct { Name string `json:"name"` Limit int `json:"limit,omitzero" default:"10"` // Limit: 0 is refused } ``` A pointer is read for what it points at, so a non-nil pointer to a nil pointer, or to an empty list, arrives as nil too. JSON has no nil slice or map either: a body member holding one arrives empty rather than nil, and so does an output's. ### What comes back | `Out` | What `Call` returns | |---|---| | A JSON type | The body, decoded with encoding/json/v2; members `Out` lacks are ignored, and a `204` is the zero value | | `muzak.Bytes` | The body, its `Content-Type`, and the file name and `Download` of its `Content-Disposition` | | `muzak.Stream` | The body unread, as an `io.ReadCloser` you must close, with the same header fields and `Length` when the server declared one | | `muzak.Redirect` | The redirect itself, `To` and `Status`, which is not followed | | `muzak.HTML` | The document | | `muzak.Empty` | Nothing | | `muzak.FileResponse` | Cannot be called: declare the endpoint with `muzak.Stream` | A redirect is returned rather than followed because following it would answer with another endpoint's output. Every body is bounded by `ClientOptions.MaxResponseBytes`. A success that is not labelled as JSON, where `Out` is a JSON type, is an error rather than a guess, which is how an HTML page from a proxy in the way announces itself. The file name a server offers in `Content-Disposition` is that server's, and is cleaned as a name Muzak sends is: a `/` or a `\` becomes `_`, and control characters, line breaks, double quotes and the Unicode controls that make a name display other than it is are removed. A name of `../../etc/passwd` arrives as `.._.._etc_passwd`, one name and never a path. A `Stream` a handler receives from a call can be returned as its own, which passes the body on without holding it; Muzak closes it once it is sent. ```go func Download(ctx *muzak.Context, in DownloadIn) (muzak.Stream, error) { return storageapi.Export.Call(ctx.Context(), core.Storage, storageapi.ExportIn{ID: in.ID}) } ``` ### Errors A status outside 2xx is a `*muzak.RemoteError`. Besides the status, the header and the start of the body, it carries what the service's error envelope says, read from the default shape or from problem details: | Field | From the default envelope | From problem details | |---|---|---| | `Code` | `error.code` | `code` | | `Message` | `error.message` | `detail`, or `title` | | `Details` | `error.details` | `errors` | | `RequestID` | `request_id` | `request_id` | ```go user, err := userapi.GetUser.Call(ctx.Context(), core.Users, in) var remote *muzak.RemoteError if errors.As(err, &remote) && remote.Code == muzak.CodeNotFound { return Out{}, muzak.NotFound("no such customer") } ``` The body is another server's, so nothing about it is trusted. It is read only under a JSON media type, kept to 64 KiB, decoded whole or not at all, and at most a hundred details are kept. `RemoteError.Error()` names the method, the origin and the status, and the code when it is a plain identifier of at most 64 bytes (letters, digits and `_ - . :`), so a hostile body cannot write a line of its own into a log: ``` muzak: GET https://users.internal:8080 answered with status 404 (not_found) ``` Branch on `StatusCode` or `Code`, never on that message. `Message` and the issues in `Details` are the remote server's text: do not hand them to your own clients as they are. `Client.DoJSON`, `GetJSON` and `PostJSON` fill the same fields. A failure to reach the service is returned as `Client.Do` returns it: an `*muzak.AddressRefusedError`, a timeout, an open circuit, or the context's error. ## Call options `muzak.CallHeader` adds a header the input does not bind, such as the `Authorization` a guard reads, or the `Idempotency-Key` that makes a POST safe to retry. ```go item, err := api.CreateItem.Call(ctx.Context(), client, in, muzak.CallHeader("Authorization", "Bearer "+token), muzak.CallHeader("Idempotency-Key", key)) ``` A header the input binds is set through its field, and naming it here is refused, as are `Host`, `Content-Length`, `Content-Type` and the other headers a call writes itself. A `Cookie` given here is sent after the input's own cookies, so a cookie of the same name cannot replace one the input binds. Headers every call needs, such as a tenant taken from the context, belong in `ClientOptions.Propagate`. `muzak.ValidateFirst` checks the input before it is sent, as the server checks it once it is bound: required parameters, form fields and files, and the rules of an input that implements `Validatable`. A failure is the `*muzak.ValidationError` the server would have answered `422` with, and nothing is sent. ```go _, err := api.CreateItem.Call(ctx.Context(), client, in, muzak.ValidateFirst()) ``` It is off unless asked for, because the server is the authority on what it accepts. A client built from an older version of the shared package holds that version's rules and would refuse what the server now accepts, and a rule that reads a `Dep` reads its zero value on the client. Turn it on where a round trip is worth saving and both sides ship together. ## What a call guarantees The promise is tested as a property. For inputs covering every location, every scalar kind, pointers, lists, embedded structs, types with their own text form, dependencies and wildcards, with values full of `%`, `/`, `?`, `#`, spaces, Unicode and attempts at line breaks, a call against a real server either delivers to the handler an input equal to the one sent, or is refused with `ErrCallRefused` and never reaches the handler. Nothing in a value can add a header, a cookie or a query parameter of its own. The property is fuzzed with arbitrary bytes in every location. The same encoder writes the arguments of an MCP tool call as the request its route reads, so a route that can be served as a tool is held to the same rules; see [MCP Tools](/docs/techniques/mcp-tools). ## Testing a call A call is an ordinary HTTP request, so a test serves the application with `httptest` and points a client at it. The test server listens on loopback, which a client refuses unless it is told otherwise, so the client sets `AllowPrivateNetworks`: ```go func TestGetUser(t *testing.T) { server := httptest.NewServer(buildApp()) defer server.Close() users := muzak.NewClient(muzak.ClientOptions{ AllowPrivateNetworks: true, BaseURL: server.URL, }) defer users.Close() user, err := userapi.GetUser.Call(t.Context(), users, userapi.GetUserIn{ID: "42"}) if err != nil { t.Fatal(err) } if user.ID != "42" { t.Fatalf("got %q", user.ID) } } ``` For the server side alone, `testclient` is quicker; see [Testing](/docs/fundamentals/testing). ## Limits - A path parameter that spells a static route beside it reaches that route, as described above, since the client cannot see the server's other routes. - A non-pointer header or cookie field is sent even when it is empty, so the server reads an empty value rather than an absent one. Use a pointer for a value that may be absent. - A `time.Time` arrives with the same instant and offset, in a fixed zone, not in the named `Location` it was sent from. - `ClientOptions.BaseURL` is not checked for dot or empty segments in its path. It is operator configuration, and is sent as it is written. - A `307` or `308` is followed with the body, to another origin too, as `Client.Do` follows one for any request; credentials are dropped once a redirect leaves the origin, the body is not. With `AllowPrivateNetworks` that origin may be another internal service, so name the networks a client may reach in `AllowedNetworks` where that matters. ## Where to go next [HTTP Client](/docs/techniques/http-client) covers the client a call is made with, [Request Data](/docs/getting-started/request-data) the tags an input is bound by, [Files, Streams and Redirects](/docs/techniques/files-streams-redirects) the outputs that are not JSON, and [Error Handling](/docs/getting-started/error-handling) the envelope a `RemoteError` reads. [TypeScript](/docs/fundamentals/typescript) generates the same API's types for a frontend from the OpenAPI document. -------------------------------------------------------------------------------- title: "MCP Tools" description: "Serve a Model Context Protocol endpoint and let an MCP client call chosen routes as tools, each call served by the route itself with its security, guards, rate limits and validation intact." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/techniques/mcp-tools" -------------------------------------------------------------------------------- # MCP Tools `App.MCP` serves a [Model Context Protocol](https://modelcontextprotocol.io) endpoint. An MCP client, such as a desktop assistant, an IDE agent or a model behind your own orchestration, connects to it, lists the tools it offers and calls them. Each tool is a route you already have, described from the OpenAPI document and called through the whole application, so a model gets exactly the operations you chose, under exactly the rules an HTTP client gets them under. ::code-group ```go [main.go] app := muzak.New(muzak.AppOptions{ Title: "Shop API", Version: "2.1.0", SecuritySchemes: map[string]muzak.SecurityScheme{ "oidc": muzak.JWTBearer(muzak.JWTOptions{ Issuers: []string{"https://login.example.com/"}, Audience: "https://api.example.com/mcp", Algorithms: []string{"RS256"}, JWKS: muzak.JWKSOptions{URL: "https://login.example.com/.well-known/jwks.json"}, // Clients that know only the endpoint's URL discover the // authorization server through this document. ResourceMetadata: &muzak.ResourceMetadata{Resource: "https://api.example.com/mcp"}, }), }, }) // The endpoint is a route like any other: its security, guards and rate limit // apply to every message. Nothing is a tool until it is chosen. app.MCP("/mcp", muzak.MCPOptions{ Tags: []string{"agent"}, Instructions: "Use get_order to look an order up before cancelling it.", }, muzak.WithSecurity(muzak.Require("oidc"))) app.Include(routers.Orders(), muzak.WithPrefix("/v1")) ``` ```go [routers/orders.go] func Orders() *muzak.Router { r := muzak.NewRouter(muzak.WithSecurity(muzak.Require("oidc"))) // Chosen by its tag. r.Get("/orders/{id}", handlers.GetOrder, muzak.WithTags("agent"), muzak.OperationID("get_order"), muzak.Title("Get Order"), muzak.Summary("Fetch one order")) // Chosen explicitly, whatever its tags. r.Post("/orders/{id}/cancel", handlers.CancelOrder, muzak.MCPTool(), muzak.OperationID("cancel_order"), muzak.Summary("Cancel an order that has not shipped")) // Not a tool: an HTTP client can call it, a model cannot. r.Delete("/orders/{id}", handlers.DeleteOrder) return r } ``` :: ## Choosing tools Nothing is exposed by default. A route becomes a tool in any of three ways, and they add up: | Chosen by | How | |---|---| | The route | `muzak.MCPTool()` among its options | | A tag | `MCPOptions.Tags` names one of the route's tags, its own or a router's | | A rule | `MCPOptions.Include` returns true for the `*muzak.Route` | `Include` is called once per route when the application is built, and only for routes that can be tools; it cannot take back a route a tag or `MCPTool` chose, so a policy with exceptions is written in `Include` alone. Some things are never tools, however eagerly they are chosen: WebSocket and event stream routes, `Hidden` routes, the endpoint itself, a method OpenAPI has no place for, and everything that is not a route at all, `Mount`, `Static`, `Frontend`, the documentation and the health endpoints. `MCPTool` is a route option, so it cannot be given to `Mount`, `Static` or `Frontend` at all: it does not compile there. On a route that cannot be a tool, a WebSocket or event stream route, a `Hidden` one, the endpoint itself or one whose method OpenAPI has no place for, it is a build error naming the route, rather than an option quietly ignored. An application without an MCP endpoint ignores `MCPTool` on every route, so a router shared by applications with and without one can declare its tools once. A chosen route that a tool call cannot write a request for is a build error too, naming the route and the reason. A tool call writes its request as [`Endpoint.Call`](/docs/techniques/typed-endpoints) does, so whatever `Call` refuses is refused here: a `muzak.File` field (declare it `[]byte` or `[][]byte`), a header net/http writes itself, and a route that answers with `muzak.FileResponse`. So is an input that binds a header or cookie the endpoint forwards, such as `Authorization`, or a header a trusted proxy names the client in, since its value comes from the client's own request and never from a model, and a versioned route whose version is chosen by a custom extractor, which a call cannot satisfy. Nor may an input bind any other place a credential is read from: the header, query parameter or cookie of an API key scheme, whether the scheme verifies the key or only describes it, or the cookie `AppOptions.Sessions` keeps the session in. Binding one is a build error naming the route even when the endpoint does not forward it, so a model can never choose the credential a call is made with, or present a key it read somewhere as its own. ## What a tool looks like ```json { "name": "get_order", "title": "Get Order", "description": "Fetch one order", "inputSchema": { "type": "object", "properties": { "path": { "type": "object", "properties": {"id": {"type": "string", "description": "The order's identifier"}}, "required": ["id"], "additionalProperties": false }, "query": { "type": "object", "properties": {"expand": {"type": "array", "items": {"type": "string"}}}, "additionalProperties": false } }, "required": ["path"], "additionalProperties": false }, "outputSchema": {"type": "object", "properties": {"id": {"type": "string"}, "total": {"type": "integer"}}, "required": ["id", "total"]}, "annotations": {"readOnlyHint": true} } ``` | Member | Comes from | |---|---| | `name` | The operation id, with every character other than an ASCII letter, a digit, `_` or `-` replaced by `_`, cut to 64; two routes that end up with one name are a build error, so set `muzak.OperationID` to choose | | `title` | `muzak.Title` | | `description` | `muzak.Summary` and `muzak.Description` | | `inputSchema` | The operation's parameters and request body in the OpenAPI document | | `outputSchema` | The success response's JSON schema | | `annotations` | The method: `GET`, `HEAD` and `OPTIONS` are read-only, `DELETE` destructive and idempotent, `PUT` idempotent | The input is grouped by where the route reads it: `path`, `query`, `header` and `cookie` hold the parameters by the names a request uses, `body` holds the JSON body, and `form` holds the fields of a form, files as standard base64. Grouping is what keeps a path parameter and a body member of the same name apart, and it is the shape the request takes. Every schema is the document's own, with the same required members and the same rules, and every reference is resolved within the tool: inlined, or written once under `$defs` for a type that contains itself. ## Calling a tool A call is the route's own request. The arguments are decoded into the route's input type by the binder's own rules, defaults included, written as a request by the encoder [`Endpoint.Call`](/docs/techniques/typed-endpoints) uses, and served in-process by the whole application: middleware, the host allowlist, the security gate, guards, providers, the rate limit, binding, validation and releases all run, and nothing is skipped because the caller is a model. Because the request is written by the rules `Call` writes one by, an argument `Call` would refuse is refused here, as an error result. The one a model is likely to meet is a body member with a `default` tag whose `json` tag carries `omitzero` or `omitempty`: set to its zero value, it would be left out of the body and arrive as the default, so it is refused rather than quietly replaced. Leaving the member out is how a call asks for the default. The request a call writes is held to the smaller of two limits: the route's own body limit (`MaxBodySize`, or `MaxUploadSize` for a route that takes files) and the endpoint's `MaxBodySize`, which the arguments arrived under. Written out as a body, a few bytes of arguments, such as a list of empty strings or empty objects, can be many times their size, so arguments that would exceed the limit get an error result with status 413, and the route does not run. The request carries over from the MCP request only what has to: | Carried | Why | |---|---| | `Authorization`, and `ForwardHeaders` and `ForwardCookies` | The client's credentials, unchanged | | The client's address, TLS state and the headers a trusted proxy names it in | Rate limits, client IP and the HTTPS redirect see the real client | | The request identifier | One `request_id` in both access log lines and in the tool's error envelope | | The trace | The call's span is a child of the MCP request's | | Cancellation and deadline | A client that disconnects stops the call; no other value of the MCP request's context is | | `User-Agent`, `Accept-Language` | Unless the input binds them itself | Nothing else is: no `Origin`, no session, no cookie the options do not name. A call reaches its own route and no other. An argument that spells the path of another route, a static sibling of a parameter or anything under a wildcard, is refused before the other route runs. Only routes are refused beforehand: `App.Use` middleware and the documentation answer ahead of routing, so when a wildcard argument spells a path one of them answers, that answer runs, and only then is the result refused. A health endpoint's path cannot be spelled at all, since a route that answers it is a build error. The route's answer becomes the result: | The route answered | The result | |---|---| | JSON | The JSON as text, and as `structuredContent` (an object before 2026-07-28, any value from it) | | Text or HTML | Text | | An image or audio | An image or audio item, in base64 | | A redirect | The `Location` as text; it is not followed | | No body | An empty `content` | | Any status from 400 | `isError: true` with the error envelope as text | | Anything else, or more than `MaxResultSize` | `isError: true` saying why | Only the status, the body, its `Content-Type` and a redirect's `Location` make the result. The route's other response headers, `Set-Cookie` included, are not returned to the MCP client: a session in the cookie store that a tool changes is not kept, and `Retry-After` and `WWW-Authenticate` do not reach the client either. A failure of the call, arguments that do not fit, a 401 from the route, a validation error, a handler's panic, is a result with `isError` set, carrying the error envelope your error renderer wrote for clients, so a model reads it and can try again. Only a malformed message, an unknown tool or arguments that are not an object are JSON-RPC errors. ## Authorization Protect the endpoint with the same scheme as the routes. An unauthenticated client then gets the 401 whose `WWW-Authenticate` names the `resource_metadata` document, which is how an MCP client finds the authorization server and starts the OAuth flow: ```http HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp" ``` Set `Audience` and `ResourceMetadata.Resource` to the endpoint's URL, so a token issued for it is accepted here and nowhere else. The token reaches the routes of this application, unchanged, and no other service: a route that calls another service presents that service its own credential, never the client's. Only the endpoint can challenge a client. A route's 401 or 403 becomes an error result, not an HTTP challenge, so a tool call never sends a client to sign in or to get a token with more scopes. Protect the endpoint itself with the scopes its tools need, so that a client is challenged for them when it connects: ```go app.MCP("/mcp", muzak.MCPOptions{Tags: []string{"agent"}}, muzak.WithSecurity(muzak.Require("oidc", "orders:read", "orders:write"))) ``` An API key a call does not carry cannot authenticate it. A call's query string is written from its arguments alone, and an input may not bind the key's parameter, so a key read from the query string never reaches the route; nor does one read from a header or cookie that `ForwardHeaders` or `ForwardCookies` does not name. A route that only such a key admits fails closed: every call of it is an error result with status 401. A session is bound to the principal that opened it: the issuer, subject and scheme of a bearer token, or the identifier and scheme of an API key. A client presenting another principal's session identifier is told it does not exist. A session opened without a credential the endpoint verified is bound to nobody, so any principal may use it, and JWTs without a `sub` from one issuer and scheme share an owner. Sessions grant nothing in any case: every message is authenticated by the endpoint's security and every call by its route's. ## Protocol support | Revision | How it is served | |---|---| | 2026-07-28 | Stateless: every request carries its revision, no session, `server/discover` | | 2025-11-25 | Session opened with `initialize` | | 2025-06-18 | Session opened with `initialize` | | 2025-03-26 | Session opened with `initialize`; no titles, structured content or output schemas, and no batches | `initialize` answers the revision a client asks for when it is one of the session revisions, and 2025-11-25 otherwise. A 2026-07-28 client may send any request first and is told the revisions with `-32022` if its own is not one of them. | Request | Answer | |---|---| | `POST` with a request | `200` and `application/json` | | `POST` with a notification or a response | `202`, no body | | `POST` without a session a session revision needs | `400` | | `POST` or `DELETE` with an unknown or expired session | `404`; the client opens another | | `DELETE` | `204`, the session ends | | `GET` | `405`; no stream from the server is offered | | An `Origin` that is not allowed | `403` | | A body that is not `application/json`, or an `Accept` without it | `415`, `406` | | An `MCP-Protocol-Version` other than the session's | `400` | | An `Mcp-Method`, or on `tools/call` an `Mcp-Name`, that does not match the body or is sent twice | `400` with `-32020`; 2026-07-28 requires both, and a session revision, which may leave them out, is held to them when it sends them | | A body larger than the endpoint's `MaxBodySize` | `413` | | A batch | `400` with `-32600`, for every revision | Messages are read strictly: the JSON-RPC 2.0 envelope and nothing else, `jsonrpc` exactly `"2.0"`, an id that is a string or a number and never null, params that are an object, no member twice, nested at most 128 deep, and no larger than the endpoint's `MaxBodySize`. ## Browser clients and the Origin check Only a browser sends `Origin`, and the check exists for one attack: DNS rebinding, where a page served from the attacker's name is made to resolve to your server and sends that name as `Origin` and `Host` alike. The endpoint's own origin therefore counts only when its `Host` is one the application vouches for: listed in `AppOptions.AllowedHosts`, or `localhost` or an address. Any other origin needs `MCPOptions.AllowedOrigins` or `AllowOriginFunc`, and `"*"` is refused. A browser client also needs [CORS](/docs/security/cors), with `Mcp-Session-Id` among the exposed headers: ```go muzak.AppOptions{ AllowedHosts: []string{"api.example.com"}, CORS: muzak.CORSOptions{ AllowedOrigins: []string{"https://inspector.example.com"}, AllowedHeaders: []string{"Content-Type", "Authorization", muzak.HeaderMCPSessionID, muzak.HeaderMCPProtocolVersion, muzak.HeaderMCPMethod, muzak.HeaderMCPName}, ExposedHeaders: []string{muzak.HeaderMCPSessionID}, }, } ``` A server run on a developer's machine binds to `127.0.0.1`, not to every interface. ## Bounds | Option | Default | Bound | |---|---|---| | `MaxResultSize` | 1 MiB | At most 64 MiB; a larger answer is an error result and stops the route | | The endpoint's `MaxBodySize` | The application's, 1 MiB unless set | Bounds each message, and the request a call writes, which is also held to the route's own `MaxBodySize`, or `MaxUploadSize` for a route that takes files; arguments that would exceed the smaller are an error result with status 413 | | `PageSize` | 100 tools | At most 1000 | | `MaxSessions` | 10,000 | At most 1,000,000; the session idle longest is evicted | | `SessionIdleTimeout` | 24 hours | | | `ListTTL` | 5 minutes | How long a 2026-07-28 client may cache the list | Anyone the endpoint admits can open sessions, and there is no cap for each principal: a flood of `initialize` evicts the sessions idle longest, whose clients are told `404` and initialize again. Guard the endpoint or give it a rate limit, so that one client cannot push everyone else's sessions out. In the same way, `tools/list` shows every tool to whoever the endpoint admits, whether or not the tool's own route would admit them, and does so even with `DisableDocs`. `tools/list` cursors are opaque and authenticated, so a client can neither forge nor alter one, and one from before a restart is refused. A 2026-07-28 listing is marked private to caches when the endpoint is guarded. A tool call counts once against the endpoint's rate limit and once against its route's, both for the same client, so a model calling a route through the endpoint spends the same budget an HTTP client does; see [Rate Limiting](/docs/techniques/rate-limiting). ## Limits - **Batches are refused for every revision**, 2025-03-26 included, which allowed them. A client sends each message in a request of its own. - **Sessions live in memory.** A deployment of several instances serving the session revisions (2025-03-26 to 2025-11-25) needs sticky routing, so that each client keeps reaching the instance that opened its session; a client of another instance is told `404` and opens a new one. 2026-07-28 clients are stateless and need nothing. - **A route that binds `User-Agent` to a non-pointer string** cannot be called unless the model supplies a value: a call refuses to send an empty `User-Agent`. Bind it to a `*string` when it is optional. - **An optional header or cookie bound to a non-pointer string** is sent empty when the model leaves it out, as `Endpoint.Call` sends it. Bind it to a pointer when an empty value and an absent one mean different things to the route. ## Testing ```go func TestAgentCanReadAnOrder(t *testing.T) { client := testclient.New(t, testApp(t), testclient.WithHeader("Authorization", "Bearer "+testToken(t))) accept := testclient.Header("Accept", "application/json, text/event-stream") res := client.Post("/mcp", accept, testclient.RawJSON(`{"jsonrpc":"2.0","id":1,"method":"initialize", "params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}`)) res.AssertStatus(http.StatusOK) session := testclient.Header(muzak.HeaderMCPSessionID, res.Header.Get(muzak.HeaderMCPSessionID)) res = client.Post("/mcp", accept, session, testclient.RawJSON(`{"jsonrpc":"2.0","id":2,"method":"tools/call", "params":{"name":"get_order","arguments":{"path":{"id":"42"}}}}`)) res.AssertStatus(http.StatusOK) } ``` ## Where to go next [Authentication](/docs/security/authentication) covers the schemes the endpoint and its tools are protected by and the protected resource metadata a client signs in through, [OpenAPI](/docs/fundamentals/openapi) covers the operation ids and schemas a tool is described with, and [Typed Endpoints](/docs/techniques/typed-endpoints) covers the encoder a call is written by. -------------------------------------------------------------------------------- title: "WebSockets" description: "A WebSocket route registered like any other, with the input bound and the guards run before a single byte is upgraded, and every direction a peer controls bounded." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/realtime/websockets" -------------------------------------------------------------------------------- # WebSockets `Router.WS` registers a WebSocket route. The protocol is implemented in the framework rather than delegated to a library: RFC 6455 framing, masking, UTF-8 validation and the close handshake, with every rule the specification lays down enforced and every violation answered with the status it calls for. ::code-group ```go [routers/items.go] // A WebSocket route is declared like any other: the input is bound from the // handshake, the dependency resolves before the upgrade, and the handler owns // the connection until it returns. r.WS("/items/{item_id}/ws", handlers.ItemSocket, muzak.Summary("Talk to an item over a WebSocket"), muzak.Needs(core.GetCaller), muzak.WithWebSocket(muzak.WSOptions{ ReadLimit: 64 << 10, MaxLifetime: 8 * time.Hour, MessageLimits: []muzak.Quota{ {Name: "ws-messages", Window: time.Second, Limit: 10}, }, })) ``` ```go [schemas/items.go] // WSItemIn is the input of the item's WebSocket route. // // A handshake carries no body, so every field comes from the path, the query // string, a header or a cookie. The pointer makes the query parameter optional: // it stays nil when the client did not send one, which is how an absent value is // told from a zero one. type WSItemIn struct { ItemID string `path:"item_id" doc:"The item being talked about"` Q *int `query:"q" doc:"An optional number echoed back with each reply"` // Caller is filled by the route's provider before the handshake // completes; it is never read from the request. Caller muzak.Dep[core.Caller] } ``` ```go [handlers/items.go] // ItemSocket answers a WebSocket conversation about one item. // // The handshake has already succeeded by the time this runs: the input is // bound, the guards have passed and the dependency is resolved, so what is left // is the conversation itself. Returning ends it, and Muzak closes the // connection; returning nil closes it normally. func ItemSocket(ctx *muzak.Context, in schemas.WSItemIn, conn *muzak.WSConn) error { caller := in.Caller.Get() for { message, err := conn.ReadText(ctx.Context()) if err != nil { // The peer closed, or the connection was lost. Either way there is // nothing left to say. return nil } if err := conn.WriteText(ctx.Context(), "hello, "+caller.Username); err != nil { return err } if in.Q != nil { if err := conn.WriteText(ctx.Context(), fmt.Sprintf("q is %d", *in.Q)); err != nil { return err } } if err := conn.WriteText(ctx.Context(), fmt.Sprintf("you said %q, about item %s", message, in.ItemID)); err != nil { return err } } } ``` :: ## The handshake is an ordinary GET Everything that applies to a route applies here. Middleware runs, guards run, dependencies resolve, and the input struct is bound and validated **before a single byte is upgraded**. A request that fails any of that is answered with the usual JSON error and never becomes a connection at all, which is the difference between a rejection a client can read and a socket that closes a moment after it opened. One thing is judged **before** the guards: a request that asks to upgrade has its origin and the connection limits checked first. A cross-origin handshake, or one arriving at a full server, used to be answered `403` or `503` only after the session dependency had run with the visitor's cookie, so a refused handshake still cost a store lookup and whatever side effect it has. A plain `GET` that does not ask to upgrade still reaches the guards first, so a client without credentials still gets `401`. So does an HTTP/2 `GET`, which is answered `426` after the guards: a WebSocket handshake is not carried over HTTP/2. A room is reserved for the connection under the register's lock **before** the `101` is written. It used to be recorded after the response had told the peer it was connected, so a client that dialled again as soon as it read the response could be admitted past the connection limits. ```bash curl -i 'http://localhost:8080/items/plumbus/ws' ``` ```json { "error": { "code": "unauthorized", "message": "sign in, or pass a token query parameter", "status": 401 }, "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31" } ``` Because a handshake carries no body, an input type with a body field is a registration error rather than a request that mysteriously never arrives. ### Where a browser can put a credential 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. A provider covering both is what makes the route usable from either. ```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. 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") } ``` `ctx.Session()` needs `AppOptions.Sessions`, and reads the visitor's session during the handshake, as it would on any route. A WebSocket route only reads it: the `101` has gone out before the handler runs, so no cookie can follow, and a change made to the session on such a route is not saved and is logged as a warning. See [Sessions](/docs/security/sessions) and [Dependencies](/docs/getting-started/dependencies). A cookie is ambient authority a browser attaches without being asked, which is exactly why the origin check below cannot be turned off casually. ## Reading and writing Reading and writing are message oriented. A message split across several frames is reassembled and delivered once, whole. Control frames that arrive in between are handled without interrupting it: a ping is answered automatically, and a close is answered and then reported as a `*WSCloseError`. | Call | What it does | |---|---| | `Read(ctx)` | The next message, as a type and a payload | | `ReadText(ctx)` | The next message as text. A binary message is refused with `1003` | | `ReadBinary(ctx)` | The next message as bytes. A text message is refused with `1003` | | `ReadJSON(ctx, &v)` | Decodes a text message with the same strictness a request body gets | | `Write(ctx, typ, payload)` | One message, as a single frame | | `WriteText(ctx, s)` | A text message. Invalid UTF-8 is refused before anything reaches the wire | | `WriteBinary(ctx, b)` | A binary message | | `WriteJSON(ctx, v)` | Encodes as JSON and sends it as text | | `Ping(ctx)` | Sends a ping and returns once it is on the wire | | `Close(status, reason)` | Closes with a status and a reason | | `Subprotocol()` | The subprotocol negotiated during the handshake, or `""` | ```go type Command struct { Action string `json:"action"` Target string `json:"target"` } func Control(ctx *muzak.Context, in ControlIn, conn *muzak.WSConn) error { for { var command Command if err := conn.ReadJSON(ctx.Context(), &command); err != nil { return nil } if err := conn.WriteJSON(ctx.Context(), handle(command)); err != nil { return err } } } ``` `ReadJSON` decodes with the same rules a request body is decoded with: unknown members, duplicate members and invalid UTF-8 are all rejected. A message that does not decode closes the connection with `1007`, because a peer sending malformed JSON on a JSON connection is not going to be understood by carrying on. The slice `Read` returns belongs to the caller and is not reused, so it may be kept. The payload passed to `Write` is not retained, so the caller may reuse it as soon as `Write` returns. ### Concurrency Writes are serialized, so any number of goroutines may write to one connection and each message goes out intact. A broadcast from many goroutines cannot interleave two messages. Reads are serialized too, but a second reader is rarely what is wanted: the messages of one connection arrive in order and one loop should consume them. ```go func Room(ctx *muzak.Context, in RoomIn, conn *muzak.WSConn) error { hub := muzak.From[*core.Hub](ctx) updates := hub.Subscribe(ctx.Context(), in.Room) go func() { for update := range updates { // Safe from another goroutine: writes are serialized. _ = conn.WriteJSON(ctx.Context(), update) } }() for { message, err := conn.ReadText(ctx.Context()) if err != nil { return nil } hub.Publish(in.Room, message) } } ``` ### A deadline on a read `ReadTimeout` bounds one message once it has begun, and deliberately not the wait between messages. A handler that wants its peer to say something within a given time gives each read a deadline of its own: ```go func Conversation(ctx *muzak.Context, in ConversationIn, conn *muzak.WSConn) error { for { // A peer that says nothing for five minutes is done with. readCtx, cancel := context.WithTimeout(ctx.Context(), 5*time.Minute) message, err := conn.ReadText(readCtx) cancel() if err != nil { return err } if err := conn.WriteText(ctx.Context(), "you said "+message); err != nil { return err } } } ``` A read that its context ends ends the connection, and the peer is told why. A deadline that passed closes with `1008` and the reason "no message arrived within the time allowed", the status the read timeout and keepalive use, and a cancellation closes with `1001`. The read returns the context's error once the close frame is out and the peer has answered it, or after `CloseGracePeriod` (250 ms by default) at the most; set it negative if a read must return the moment its deadline passes. Returning that error, as above, is not treated as a failure. A write whose context had already ended when it got the connection sends nothing of the message and closes with `1001`. One that its context interrupts part way through a frame ends the connection with no close frame, so the peer sees `1006`, because a close frame would be read as more of the frame. On a `WSDial` connection a cancelled read closes the transport, and no close frame can follow. ## Ending a connection The connection is closed when the handler returns, so a handler owns its connection for as long as it runs and never has to arrange the teardown itself. | The handler returns | The peer gets | |---|---| | `nil` | `1000 Normal Closure` | | a `*WSCloseError` | the status and reason it carries | | an error a read or write returned because its context ended, or `ctx.Context().Err()` once it has ended | already told (`1008` for a read deadline, `1001` otherwise); a connection still open is closed with `1001`. Logged at debug, not as a failure | | anything else, a deadline from elsewhere included | `1011 Internal Error`, with the reason logged and nothing disclosed | ```go if !allowed(caller, in.Room) { return &muzak.WSCloseError{Status: muzak.WSStatusPolicyViolation, Reason: "not a member of this room"} } ``` Calling `Close` yourself is only necessary to choose a status other than normal closure or to end the connection from another goroutine. It is safe to call more than once and from more than one goroutine; only the first call sends anything. The reason is truncated to the 123 bytes a close frame can carry, on a rune boundary. A value the route's providers acquired with `Acquire` lives as long as the connection. Its release runs as soon as the handler returns, before the connection is closed, and is handed what the handler returned, so a `Transaction` held for the conversation commits only when the handler returned nil. A release that fails a connection which had not failed closes it with `1011`, as a handler's error would. See [Dependencies](/docs/getting-started/dependencies). With tracing on, the request's span lasts as long as the connection and ends when it does, and a handler that fails (the `1011` row above) marks it an error, while a connection its context ended does not. `AppOptions.Observer` is told about the connection once, after it has ended. See [Observability](/docs/fundamentals/observability). ### Telling a goodbye from a violation Every read and every write returns a `*WSCloseError` once the connection is finished, which is why the loops above end on any error. A peer that is still sending when the server refuses the connection reads the close status instead of a reset. A message over the read limit is answered with a close frame `1009`, but a client that kept sending, a message still streaming in, often saw the connection reset (`1006`) and never read it: the server closed the socket with unread data in its receive buffer, which makes the kernel send a reset and drop the close frame in flight. The same happened after every close the server started on finding a fault, `1002`, `1003`, `1007` and `1008` included. Once the close frame is out, the server now stops sending (a half-close, or the `close_notify` alert on TLS), keeps reading and dropping what arrives until the peer's close frame, the end of its stream, `CloseGracePeriod` or 1 MiB, whichever is first, and only then closes the transport. Nothing read is kept, a peer that answers the close frame promptly is not made to wait, and one that never stops is cut off at the grace period as before. A close the handler starts with `Close` gets the same half-close. In a test that sends a message over the limit and keeps sending, the `1009` reached the client in 0 of 50 runs before and 50 of 50 after. The status reaches the peer even when two closers race. A second `Close` (a shutdown, a keepalive timeout, a returning handler) used to tear the transport down under the first one's close frame, so the peer saw `1006`. It now waits for that frame, and a peer's own close is echoed even when another goroutine is writing at that moment. A close reason that is not valid UTF-8, one an application forwarded from elsewhere, is repaired before it is sent, with invalid bytes written as U+FFFD, so the peer reads the status instead of failing the connection. ```go message, err := conn.ReadText(ctx.Context()) if err != nil { if status, ok := muzak.WSCloseStatus(err); ok && status == muzak.WSStatusNormalClosure { return nil } return err } ``` `WSCloseError.Unwrap` exposes the transport failure behind an abnormal closure, so `errors.Is` can test for `io.EOF` or a network error. The codes RFC 6455 defines are exported as `muzak.WSStatus` constants, from `WSStatusNormalClosure` (1000) to `WSStatusTLSHandshake` (1015). Three of them describe a local observation rather than something a peer said: `WSStatusNoStatusReceived`, `WSStatusAbnormalClosure` and `WSStatusTLSHandshake` are reported by Muzak but never written to the wire, and passing one to `Close` closes without a status code. Codes from 4000 to 4999 are free for an application to define. ## What a hostile peer cannot do A WebSocket is the longest-lived thing an unauthenticated stranger can ask a server for, so every direction a peer controls is bounded, and each bound stops something specific. | A peer that... | ...is stopped by | |---|---| | sends a message larger than the limit | refused with `1009` before any of the payload is buffered. The check cannot overflow: one byte followed by a continuation frame declaring `0x7FFFFFFFFFFFFFFF` used to wrap negative and pass, and the peer could stream as much as it liked into one message. Such a frame is refused from its header alone | | declares a huge payload and sends none of it | a frame is taken a chunk at a time, so six bytes of header cannot buy an allocation the size of the limit | | dribbles a message out a byte at a time | closed once `ReadTimeout` passes with the message unfinished. Waiting *between* messages stays unbounded | | fragments a message endlessly, or floods pings | closed once too many frames arrive without one completing, which no size limit would ever catch. More than 65536 pings, pongs and empty fragments in a minute closes the connection with `1008` | | connects and goes silent, or is dropped by a network that told nobody | keepalive, on by default: a ping every 30 seconds, closed when nothing at all arrives within `PongTimeout` of a ping. Bytes of a message still arriving count as an answer | | answers every ping and says nothing, for as long as it likes | `MaxLifetime`, when the route sets one | | stops reading what it asked for | writes give up after `WriteTimeout` rather than pinning a goroutine | | opens connections without end | `MaxConnections` per application, then `503` with `Retry-After` | | opens many from one address | `MaxConnectionsPerIP`, so one client cannot take every slot the process has. 16 addresses (one IPv6 /52) fill the defaults | | opens one from another origin | refused outright. The origin is read strictly, and `AllowedHosts` closes DNS rebinding | | withholds its close frame after the handler returned | the slot is held until the socket is actually closed, so it cannot hold sockets the limits say nobody may | | sends a body with the handshake | refused, because what went unread would be taken for frames the moment it was upgraded | | asks for an extension | none is negotiated, so no peer can make the server hold decompression state | | never pauses, within every limit above | `MessageLimits`, closed with `1008` | Nothing a peer sends is echoed into a response header: only a subprotocol the route itself offered can be answered with, and a route offering one that is not a token is refused when the application is built. None of the defaults bounds how long a connection lasts. A peer that answers every ping and sends nothing is alive as far as a keepalive can tell, and with `MaxLifetime` unset nothing ends it. `MaxConnections` divided by `MaxConnectionsPerIP` is how many addresses can hold every slot: 1024 / 64 = 16 with the defaults, which is sixteen IPv4 addresses, or a single IPv6 /52 at the default /56 grouping. Every other handshake is then refused with `503` until they let go. A route that clients you do not control can open is worth more than the defaults: - **Put it behind a guard that authenticates the client** where it can be, which ties a connection to someone rather than to an address. - **Set `MaxLifetime`, to minutes rather than hours**, so that every slot turns over. A client that reconnects on going away, as a browser application usually does, carries on. - **Count IPv6 clients by their /48** with `ClientIPOptions.ConnectionIPv6Prefix: 48`, which makes each of those addresses cost a whole site's allocation. [Server-Sent Events](/docs/realtime/server-sent-events#what-a-stream-bounds) works through the same arithmetic for event streams, which share the per-address grouping. ## Options ```go app.Include(chat, muzak.WithWebSocket(muzak.WSOptions{ ReadLimit: 64 << 10, PingInterval: 30 * time.Second, AllowedOrigins: []string{"https://app.example.com"}, })) ``` | Field | Default | Bounds | |---|---|---| | `ReadLimit` | 1 MiB | The largest message accepted. There is deliberately no way to remove it | | `ReadTimeout` | 30s | How long one message may take to arrive once it has begun | | `WriteTimeout` | 10s | How long one message may take to reach the peer | | `MaxConnections` | 1024 | Connections the application holds at once. Application-level only | | `MaxConnectionsPerIP` | 64 | Connections one address holds at once. Application-level only | | `CloseGracePeriod` | 250ms | How long a closing connection waits for the peer's close frame, and how long a `WSDial` client waits for the server to hang up | | `PingInterval` | 30s | Keepalive: ping this often and close when the peer stops answering. Negative turns it off | | `PongTimeout` | 10s | How long a keepalive ping waits. Negative means the default, and it means nothing once keepalive is off | | `MaxLifetime` | none | The longest a connection stays open. Negative removes a bound a wider scope set | | `MessageLimits` | none | Quotas bounding how fast a peer may send | | `Subprotocols` | none | The subprotocols the route can speak | | `AllowedOrigins` | none | Browser origins allowed in addition to the server's own | | `AllowedHosts` | none | The `Host` values that make an origin the server's own | | `AllowOriginFunc` | none | A dynamic origin decision, consulted only for origins not already allowed | | `InsecureSkipOriginCheck` | off | Accept a handshake from any origin | | `EnforceOriginCheck` | off | Turn the origin check back on beneath a scope that set `InsecureSkipOriginCheck` | Options can be set application-wide through `AppOptions.WebSocket` and narrowed for a router or a route with `WithWebSocket`. Layering works field by field, so a route that raises only the read limit keeps the application's origin policy. `MaxConnections` and `MaxConnectionsPerIP` may only be set on the application: the resource they protect is the process, not a route, so a router or a route that sets either is refused when the application is built. ### Keepalive Keepalive is **on by default**: a ping every 30 seconds (`DefaultWSPingInterval`), and the connection is closed when nothing has arrived from the peer within 10 (`DefaultWSPongTimeout`). A peer that connects and goes silent, or whose network dropped without a word, used to hold a connection slot for as long as it liked; only the per-address and global caps bounded it. A browser answers pings without being asked, so nothing needs to change for one. A negative `PingInterval` turns keepalive off, and a negative `PongTimeout` means the default, since it used to be read as zero and the first ping closed every connection whose peer could not answer instantly. ```go muzak.WSOptions{PingInterval: -1} // no keepalive on this route ``` A pong is consumed by a read like any other frame, so a connection is closed for an unanswered ping **only when the handler was reading during it**. The rule is what makes the default safe: a handler that only ever writes, or one busy with a message, is left alone, which also stops keepalive closing a healthy peer of a handler that does not read. It also means a handler that only writes is never closed by keepalive, and should ping by hand with `Ping` if it wants that. The pong check uses the monotonic clock, so a wall-clock step cannot close a healthy connection. Anything that arrives from the peer after a ping answers it as well as a pong would, since what is being asked is whether the peer is still there. A peer part way through one large frame cannot send its pong until the frame is finished, and used to be closed with `1008` while its bytes were still coming in: with the defaults, any 1 MiB message sent at under about 100 KB/s was closed ten seconds in, although `ReadTimeout` allowed thirty. Every frame header and every read that brings payload now counts as hearing from the peer, so only a peer from which nothing at all has arrived for a pong timeout is closed. One that stalls part way through a frame is still closed by the keepalive, and by `ReadTimeout` in any case. ### Ending a connection on purpose Keepalive closes a peer that stops answering. It cannot close one that answers every ping and never says anything, which costs that peer a TCP connection and holds a slot, a goroutine and a file descriptor here until the server stops. `MaxLifetime` is the bound for that. It is unset by default, which leaves connections unbounded, and worth setting on any route a client you do not control can open: ```go muzak.WSOptions{MaxLifetime: 2 * time.Hour} ``` When the lifetime has passed the connection is closed with `1001 Going Away`, the status of a server that is done with a connection rather than one that failed. The handler sees it as the close it reads, and the connection's slot is released once the transport is closed. A client that reconnects on going away, as a browser application usually does, carries on. A negative value removes a bound a wider scope set. ## The origin check A cross-origin handshake is refused by default, and no CORS policy changes that. A WebSocket handshake is not subject to the same-origin policy and is never preflighted, which is what makes cross-site hijacking possible in the first place: without the check, any page on the internet could open an authenticated connection to your server from a visitor's browser, cookies and all. `AppOptions.CORS` has no bearing on it, for exactly that reason. Nor does `AppOptions.CrossOriginProtection`: a handshake is a `GET`, which it never refuses, so this check is what keeps another site's page from opening a connection with your visitor's cookies. See [Cross-Origin Requests](/docs/security/cross-origin-requests). ```go muzak.WSOptions{AllowedOrigins: []string{"https://app.example.com"}} ``` The server's own origin is always allowed. The single entry `"*"` allows any origin. An origin is read strictly: a scheme and a host with nothing after it, compared as a browser serializes it. `http://attacker@host`, `http://host/path`, `http://host?x` and `//host` used to pass as the server's own origin, because the host was taken out of any value a lenient URL parse could find one in. A value carrying credentials, a path, a query or a fragment is not the server's own origin however its host reads, and a handshake with more than one `Origin` header is refused with `400`. Values listed in `AllowedOrigins`, and the ones `AllowOriginFunc` sees, are compared as they arrived, without regard to case. A browser sends neither shape; a non-browser client that sent a decorated `Origin` must send a bare `scheme://host[:port]` or none at all. The lists themselves are checked when the application is built, the same way the CORS list is. An `AllowedOrigins` entry that can never equal what a browser sends, such as `https://app.example.com/`, `https://app.example.com/ws`, `https://app.example.com:443` or `https://*.example.com`, is a build error naming the route and the spelling to use, and so is `"null"`: it is the origin a sandboxed iframe sends, so any page can present it, and listing it would let every page open a connection with the visitor's cookies. The single entry `"*"` still allows any origin, and wildcard subdomains belong in `AllowOriginFunc`. An `AllowedHosts` entry with a scheme, a path, credentials or a pattern can never equal a `Host` header and is refused too. ### DNS rebinding The server's own origin is any whose host is the request's `Host`, which is whatever the client wrote there. That is enough for a browser that reached the server by its name, and it is not for one that was pointed at it by somebody else's: in a DNS-rebinding attack a page served from the attacker's name, made to resolve to your server, sends that name as both `Origin` and `Host`, and reads as same-origin. `AllowedHosts` lists the names the route answers to: ```go muzak.WSOptions{AllowedHosts: []string{"app.example.com", "app.example.com:8443"}} ``` When it is set, a handshake counts as same-origin only if its `Host` is listed, compared without regard to case and with any port written as the client wrote it. Any other `Host` is refused with `403` unless `AllowedOrigins` or `AllowOriginFunc` names the origin. A handshake with no `Origin` is not affected, since only a browser sends one. Unset, the rule is what it was, so nothing changes until it is set; it is worth setting for a server on a loopback or private address, where such a page is a real threat. The option bounds the WebSocket handshake only. A plain HTTP route is not protected against rebinding by it. `AppOptions.AllowedHosts` holds every request to the application's own names, handshakes included: a request for any other host is answered `421` before a route, a guard or the origin check runs. See [Hosts and HTTPS](/docs/security/hosts-and-https). ```go muzak.WSOptions{ AllowOriginFunc: func(r *http.Request, origin string) bool { return strings.HasSuffix(origin, ".example.com") }, } ``` It runs on every handshake an earlier rule did not already allow, so it must be cheap and free of side effects. Options layer field by field, so an application's `AllowOriginFunc` stays in force for a route that only lists origins of its own, and a scope that says nothing keeps a wider scope's `InsecureSkipOriginCheck`. A router or route that does need the check, such as one authenticated by a cookie in an application that otherwise authenticates by token, sets `EnforceOriginCheck: true`. A scope beneath it can set `InsecureSkipOriginCheck` again, and the narrowest scope that set either wins. A scope that sets both keeps the check. ```go // Every route takes a token the client presents explicitly... app := muzak.New(muzak.AppOptions{ WebSocket: muzak.WSOptions{InsecureSkipOriginCheck: true}, }) // ...except this one, which a browser authenticates with its session cookie. r.WS("/account/ws", handlers.AccountSocket, muzak.WithWebSocket(muzak.WSOptions{EnforceOriginCheck: true})) ``` `InsecureSkipOriginCheck` accepts a handshake from anywhere. It is safe only for a connection that carries no ambient authority: one authenticated by a token the client has to present explicitly, never by a cookie, since a browser attaches cookies to a cross-origin handshake without being asked. ## Subprotocols ```go muzak.WSOptions{Subprotocols: []string{"graphql-transport-ws", "graphql-ws"}} ``` The client's own list is in preference order, so the first of its choices that appears here is the one negotiated. A client asking for something else is answered without the header, which tells it to give up. Read the result with `conn.Subprotocol()`. ## Shutdown Open connections are tracked, so a graceful shutdown tells every peer it is going away with `1001` and waits for the handlers, and for connections that are still closing. `net/http` cannot do that on its own: a hijacked connection is no longer one it knows about. The handler's context ends with its connection, so a handler waiting on it does not outlive its peer or the server's close at shutdown. The goodbye comes after readiness has turned unavailable and `ServerOptions.DrainDelay` has passed, during which connections stay open and new handshakes are still served. It is sent as the listeners close, alongside the drain of ordinary requests, and the background tasks and lifecycle components are dealt with only once the handlers have returned. Shutdown shares one deadline with everything else; see [Lifecycle](/docs/getting-started/lifecycle#shutdown). ## Documentation A WebSocket route appears in the OpenAPI document with its parameters and a `101 Switching Protocols` response, because that is what OpenAPI can say about a handshake. The conversation itself continues off the document. What a handshake is refused with is listed too: `426` and `422`, `400` for a malformed handshake, `403` for a foreign origin (left out for a route that skips the check) and `503` for a draining or full server. A status a route declared with `WithResponseDoc` keeps its own description. ## Testing `muzak.WSDial` is the client half of the same engine, which is what lets a route be tested over a real connection rather than against a second implementation. The test client wraps it: ```go func TestItemSocket(t *testing.T) { client := testclient.New(t, buildApp()) // A token tokens.Verify accepts for the user jessica. conn := client.WS("/items/plumbus/ws", testclient.Query("token", jessicasToken)) if err := conn.WriteText(t.Context(), "hello"); err != nil { t.Fatalf("WriteText = %v", err) } reply, err := conn.ReadText(t.Context()) if err != nil { t.Fatalf("ReadText = %v", err) } if reply != "hello, jessica" { t.Errorf("reply = %q", reply) } } func TestItemSocketNeedsCredential(t *testing.T) { client := testclient.New(t, buildApp()) _, response := client.TryWS("/items/plumbus/ws") response.AssertStatus(http.StatusUnauthorized) } ``` Outside a test, dial directly: ```go conn, _, err := muzak.WSDial(ctx, "ws://"+app.Addr()+"/items/plumbus/ws", muzak.WSDialOptions{}) if err != nil { return err } defer conn.Close(muzak.WSStatusNormalClosure, "") ``` The URL may use `ws`, `wss`, `http` or `https`. The response is returned alongside the connection so a caller can read the handshake headers, and on failure the status and body the server refused with. `WSDial` never follows a redirect, because following one would send the handshake's headers to whatever host the answer named. A `101` that names an extension is refused, because the client never offers one and RFC 6455 section 4.1 says a response that negotiates one it did not ask for fails the connection. It used to read on with frames whose meaning the server had changed. `WSDial` connections apply `ReadTimeout` and `WriteTimeout`, which they did not before: the transport `net/http` hands back is not a `net.Conn`, so no deadline was ever set and a server that stopped reading held the client forever. The frame-size check that let a message slip past the read limit is fixed on the client too. A `WSDial` connection waits, up to `CloseGracePeriod`, for the server to close TCP first, as RFC 6455 section 7.1.1 asks, so that the server rather than every client holds the `TIME_WAIT` state a closed connection leaves behind. It reads on to the end of the stream, so `Close` returns once the server has hung up, or after the grace period at the most. It used to hang up the moment its close frame was out, and `WSDialOptions.CloseGracePeriod` did nothing. ## Where to go next [Server-Sent Events](/docs/realtime/server-sent-events) covers the simpler half of what a WebSocket is usually reached for, and [Rate Limiting](/docs/techniques/rate-limiting) covers the quotas `MessageLimits` reuses. -------------------------------------------------------------------------------- title: "Server-Sent Events" description: "A typed event stream registered like any other route, read natively by a browser's EventSource, with the schema of one event in the generated document." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/realtime/server-sent-events" -------------------------------------------------------------------------------- # Server-Sent Events `Router.SSE` registers a route whose response is a stream rather than a body. It is the other half of what a WebSocket is usually reached for, and the simpler half: the server sends, the client listens, and a browser reads it natively with `EventSource`, reconnecting on its own when a stream drops. ::code-group ```go [routers/items.go] // An event stream route is declared like any other and answers 200 rather than // upgrading: the guards run and the input binds before a byte of the stream is // written, and the handler owns the stream until it returns. r.SSE("/items/stream", handlers.StreamItems, muzak.Summary("Follow every change to the items"), muzak.WithSSE(muzak.SSEOptions{ // A stream that says nothing for long enough is closed by proxies that // believe it to be idle, so a comment goes out instead. KeepAlive: 15 * time.Second, // A browser reconnects on its own when a stream ends, and this is how // soon. Retry: 2 * time.Second, })) ``` ```go [handlers/items.go] func StreamItems(ctx *muzak.Context, _ muzak.Empty, stream *muzak.SSEStream[schemas.ItemOut]) error { store := muzak.From[*core.ItemStore](ctx) updates := store.Watch(stream.Context()) for { select { case <-stream.Context().Done(): // The client went away or the server is shutting down. Either way // the conversation is over, and it is not a failure. return nil case change := <-updates: item := schemas.ItemOut{ID: change.Item.ID, Name: change.Item.Name} if err := stream.Send(item); err != nil { return err } } } } ``` :: The type parameter is the contract: nothing but an `ItemOut` can be sent, and the generated document describes the stream with that type, in the same way a handler's return type describes an ordinary response. ```bash curl -N 'http://localhost:8080/items/stream' ``` ``` retry: 2000 event: item_update id: 1 data: {"id":"plumbus","name":"Plumbus"} event: item_update id: 2 data: {"id":"fleeb","name":"Fleeb"} ``` The `retry` field is sent once, before anything else, because `SSEOptions.Retry` was set on the route above: a client that loses the stream on the very first event already knows how long to wait. The response carries `Content-Type: text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform` and `X-Accel-Buffering: no`. The type is set whatever a middleware set before it, because a stream served under any other type, such as the `application/json` a middleware may give every response in advance, is one `EventSource` refuses to read. The other two are left alone when something already set them. ## An ordinary route Middleware runs, guards run, dependencies resolve, and the input is bound and validated **before a byte of the stream is written**. A request that fails any of that is answered with the usual JSON error and never becomes a stream at all. The response header is written before the handler is called, which is what lets a client see the stream open immediately. Anything that decides whether to serve a stream at all therefore belongs in a guard or a dependency, where there is still a response to say it in. ## The stream Nothing on `SSEStream` takes a context, unlike `WSConn`, because a stream belongs to one request. | Call | What it does | |---|---| | `Send(data)` | One event carrying `data`, encoded as JSON into its data field | | `SendEvent(event)` | One event described in full | | `Comment(text)` | A comment line, which no client acts on and every client accepts | | `Context()` | The context governing every send | | `LastEventID()` | The identifier the client last saw | | `Err()` | The error that ended the stream, or nil while it is usable | `stream.Context()` is derived from the request's own and is cancelled when the client disconnects, when the request ends, or when the server begins shutting down. That makes it the one thing a handler has to watch, and every send after it reports `muzak.ErrSSEStreamEnded`, so a handler that only sends ends on its own. ```go for update := range updates { if err := stream.Send(update); err != nil { return err } } ``` A handler that returns `ErrSSEStreamEnded` is treated as a stream that finished rather than one that failed, so a client going away is not logged as an error. Test for it with `errors.Is`; the reason the stream ended is wrapped inside. Tracing draws the same line. With tracing on, the request's span ends when the stream does, and it is marked an error only when the handler fails, not when the client goes away. `AppOptions.Observer` is told about the stream once, after it has ended. See [Observability](/docs/fundamentals/observability). A value the route's providers acquired with `Acquire` lives as long as the stream. Its release runs as soon as the handler returns and is handed what the handler returned, so a `Transaction` held for the stream commits only when the handler returns nil: a stream the client left ends with `ErrSSEStreamEnded`, and is rolled back. A release that fails is logged as the stream's failure. See [Dependencies](/docs/getting-started/dependencies). Writes are serialized, so any number of goroutines may write to one stream and each event goes out whole. A stream is single use. The first failure ends it, and every later send reports that same error rather than writing into a response nobody is reading. Sends that report a mistake instead, such as an event name carrying a line break, leave the stream usable. A stream never writes into a response it has handed back. A send still encoding when the handler returned held the write lock, and the stream used to give up after `WriteTimeout` and return the response anyway, which crashed the process or could write one client's event into the next request on the connection. ## Events with more than data ```go type SSEEvent[Out any] struct { Name string // the type a browser dispatches it under ID string // what a reconnect resumes from Retry time.Duration // how long to wait before reconnecting Comment string // text no client acts on Data *Out // the value, encoded as JSON Text string // a payload written as it stands EmptyData bool // a data field even with no payload } ``` ```go [handlers/items.go] // sendChange writes one change as an event a browser can listen for by name // and resume from by identifier. func sendChange(stream *muzak.SSEStream[schemas.ItemOut], change core.Change) error { item := schemas.ItemOut{ID: change.Item.ID, Name: change.Item.Name} return stream.SendEvent(muzak.SSEEvent[schemas.ItemOut]{ Name: "item_update", ID: strconv.Itoa(change.Seq), Data: &item, }) } ``` ```js const source = new EventSource("/items/stream") source.addEventListener("item_update", (event) => { const item = JSON.parse(event.data) // ... }) ``` `Data` is a pointer so that an event carrying nothing can be told from one carrying a zero value, and it cannot be combined with `Text`. An event setting both is refused. A client dispatches an event only when it has a data field, which it has when it carries `Data`, a `Text` that is not empty, or `EmptyData`. An event without one is still written, and a browser and `SSEReader` alike apply its `ID` and `Retry`, but neither hands it to the application, `Name` included. The zero value is the extreme case: a blank line that no client acts on. `EmptyData` writes a data field with nothing in it, which is dispatched as an event whose data is the empty string. That is how a stream of text sends an empty message, as in `SSEEvent{Text: line, EmptyData: true}` for a log line that may be blank, and how an event that is only a name, such as a tick, is dispatched at all. `Retry` is whole milliseconds, and a positive delay shorter than a millisecond is written as 1, where it used to be truncated to 0 and told a client to reconnect at once. `Name` and `ID` may not contain a line break. An event stream is a sequence of lines, so a break in one of those fields would end it and let whatever followed be read as fields of its own, which on a stream carrying one client's input to another is event forgery. Such a send is refused rather than repaired, and the stream stays usable. ### Payloads that are not JSON `Text` writes a payload as it stands, for a log line or for the sentinel some completion APIs end with. A stream whose events are all text declares `muzak.Empty` as its model, and the generated document then says there is no schema rather than describing one that does not exist. ```go [handlers/chat.go] // StreamChat answers a prompt one token at a time, which is the shape every // chat completion API streams in. func StreamChat(ctx *muzak.Context, in schemas.ChatIn, stream *muzak.SSEStream[muzak.Empty]) error { for word := range strings.SplitSeq(in.Text, " ") { select { case <-stream.Context().Done(): // The client closed the tab, or the server is shutting down. There // is no one left to answer. return nil case <-time.After(tokenDelay): } if err := stream.SendEvent(muzak.SSEEvent[muzak.Empty]{Name: "token", Text: word}); err != nil { return err } } // The sentinel some clients expect at the end of a completion. It is text // rather than a value, which is what Text is for. return stream.SendEvent(muzak.SSEEvent[muzak.Empty]{Name: "done", Text: "[DONE]"}) } ``` A payload spanning several lines is written as several data lines and arrives whole, which is both what the format asks for and what stops a value from ending its own field. ## A stream reached by POST A stream is not tied to `GET`. `Router.SSEHandle` registers one for any method, which is what a protocol that streams its answer to a posted document needs, and there the input binds a request body like any other route. ::code-group ```go [routers/chat.go] r.SSEHandle(http.MethodPost, "/chat/stream", handlers.StreamChat, muzak.Summary("Answer a prompt one token at a time"), muzak.WithSSE(muzak.SSEOptions{ // A completion is never quiet for long, so the keepalive is only there // for the pause before the first token. KeepAlive: 10 * time.Second, })) ``` ```go [schemas/chat.go] // ChatIn is the prompt a chat stream answers. // // It is an ordinary JSON body: an event stream reached by POST binds and // validates one exactly as any other route does. type ChatIn struct { Text string `json:"text" doc:"The prompt to answer, one token at a time"` } // Validate bounds the prompt, and is applied before a byte of the stream is // written. func (in *ChatIn) Validate(v *muzak.Validation) { v.String(&in.Text).Trim().Required().MinLen(1).MaxLen(280) } ``` :: ```bash curl -N -X POST 'http://localhost:8080/chat/stream' \ -H 'Content-Type: application/json' -d '{"text":"what is a plumbus"}' ``` An input that binds nothing from the body leaves it to the handler, which reads it from `ctx.Request().Body` even though the stream's header has already gone out, over HTTP/1.1 as over HTTP/2. It is read under `ServerOptions.ReadTimeout`, as any route's body is, so a client that declares a body and sends half of it cannot hold the handler for longer than that. Read it before sending the first event, because a client may send the whole body before it reads any of the response. Until the body has been read to its end, net/http cannot watch the connection for the client going away, so a disconnect is noticed when a write fails rather than at once, which with the keepalive on takes a couple of its intervals. ## Resuming a dropped stream A browser remembers the last identifier it saw and sends it back in `Last-Event-ID` when it reconnects. `stream.LastEventID()` reads it, and that is what turns a dropped connection into a stream that picks up where it left off rather than one that starts again. ```go [handlers/items.go] func StreamItems(ctx *muzak.Context, _ muzak.Empty, stream *muzak.SSEStream[schemas.ItemOut]) error { store := muzak.From[*core.ItemStore](ctx) // A browser sends back the identifier of the last event it saw when its // EventSource reconnects. The value is the client's, so one that is not a // number is treated as no value at all rather than as an error. seen := 0 if last := stream.LastEventID(); last != "" { if parsed, err := strconv.Atoi(last); err == nil { seen = parsed } } // Subscribing before the backlog is read is what stops a change made in // between from falling through the gap between the two. updates := store.Watch(stream.Context()) for _, change := range store.Since(seen) { if err := sendChange(stream, change); err != nil { return err } seen = change.Seq } for { select { case <-stream.Context().Done(): return nil case change := <-updates: if change.Seq <= seen { // Already sent from the backlog above. continue } if err := sendChange(stream, change); err != nil { return err } seen = change.Seq } } } ``` `LastEventID` is a value the client controls, so treat it as input rather than as a cursor to be trusted. ## What a stream bounds A stream costs a connection and a goroutine for as long as a client cares to hold it. | A client that... | ...is stopped by | |---|---| | stops reading a stream that keeps writing | `WriteTimeout`, once the socket buffers between them are full: a write gives up rather than pinning a goroutine and a full buffer. A stream sending only keepalives never fills them | | opens streams without end | `MaxStreams` per application, then `503` with `Retry-After` | | opens many from one address | `MaxStreamsPerIP`, so one client cannot take every slot the process has. 16 addresses (one IPv6 /52) fill the defaults | | holds a stream open for hours | the listener's own timeouts are cleared for it and replaced with a deadline that exists only while a write is in progress, so a healthy stream is never cut off and an unhealthy one still is | | holds a stream open and reads nothing at all | `MaxLifetime`, when the route sets one. Nothing else can tell it from a listener, because the write timeout bounds one write and a keepalive is a few bytes that never fill a socket buffer | | sends a `Last-Event-ID` a handler echoes | a name or identifier with a line break in it is refused | | reads through a buffering proxy | `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, and a keepalive comment every `KeepAlive` | None of the defaults bounds how long a stream lasts, and on a route anyone can open that leaves the whole budget to whoever takes it first. A client that reads nothing is never ended by `WriteTimeout`, and with `MaxLifetime` unset nothing else ends it. `MaxStreams` divided by `MaxStreamsPerIP` is how many addresses fill the process: 1024 / 64 = 16 with the defaults, which is sixteen IPv4 addresses, or sixteen IPv6 /56 prefixes, a single /52. Each opens 64 streams and reads none of them, and every other stream is refused with `503` until they let go. A route that clients you do not control can open is worth more than the defaults: - **Put it behind a guard that authenticates the client** where it can be. That is the only thing that ties a stream to someone rather than to an address. - **Raise `MaxStreams` and lower `MaxStreamsPerIP`**, so that the one divided by the other is larger than the number of addresses an abuser can be expected to have. `MaxStreams` can go as far as the file descriptor limit allows. How low `MaxStreamsPerIP` can go depends on who shares an address: one browser needs a stream per open tab, while an office or a carrier's NAT puts many users behind one. - **Count IPv6 clients by their /48** with `ClientIPOptions.ConnectionIPv6Prefix: 48`, which makes each of those addresses cost a whole site's allocation. - **Set `MaxLifetime`, to minutes rather than hours**, so that a stream whose client has stopped listening is reclaimed and every slot turns over. A browser reconnects and resumes on its own, so a legitimate client loses nothing. `MaxLifetime` alone does not stop a client that reconnects as soon as its stream ends, which is what the settings above are for. ```go app := muzak.New(muzak.AppOptions{ ClientIP: muzak.ClientIPOptions{ConnectionIPv6Prefix: 48}, SSE: muzak.SSEOptions{ MaxStreams: 8192, MaxStreamsPerIP: 8, MaxLifetime: 15 * time.Minute, }, }) ``` Clearing the listener's read and write deadlines matters more than it sounds. A stream is a response that does not end, so it would otherwise die at `ServerOptions.WriteTimeout` however healthy it was, and the read deadline would cancel the request, and with it the stream, at `ServerOptions.ReadTimeout` and blame the client. The read deadline is cleared once nothing is left of the request body to read. That is as the stream opens, unless the body was left to the handler: then it is cleared when the handler has read the body to its end, so that body is read under `ReadTimeout` as any route's is. The stream's own deadline is armed **around each write** and taken off afterwards. It used to be armed before every event and never removed. On HTTP/2 that deadline is a timer that resets the stream when it fires, so with the default 10 second `WriteTimeout` and 15 second keepalive every idle stream died before its first keepalive, and browsers reconnected in a loop. Now a graceful shutdown or a handler's return ends an idle stream cleanly rather than aborting it, since an interrupt is applied only to a write that is in flight. ### Ending a stream on purpose `SSEOptions.MaxLifetime` is unset by default, which leaves a stream open for as long as its handler runs and its client keeps the connection. It is worth setting on any route a client you do not control can open. When it has passed, the stream writes a closing comment and ends the way a finished response does. A browser's `EventSource` reconnects on its own, sending the identifier of the last event it saw in `Last-Event-ID`, so a handler that resumes from `stream.LastEventID()` loses nothing. ```go muzak.WithSSE(muzak.SSEOptions{MaxLifetime: 30 * time.Minute}) ``` The handler is told the way it is told of a disconnect, through `stream.Context()` and the `ErrSSEStreamEnded` every later send reports, and the stream's slot is released when the handler returns. A stream is not cut mid-event: the ceiling is applied between events. Nothing is written after the closing comment, where a keepalive already waiting for the write lock could go out behind it. A negative value removes a bound a wider scope set. A client that does not reconnect on its own, a script using `SSEDial` for instance, must be written to. ## Options | Field | Default | Effect | |---|---|---| | `KeepAlive` | 15s | How often a comment is written to a stream that has sent nothing. A negative value turns it off | | `WriteTimeout` | 10s | How long one event may take to reach the client. It bounds a stalled write, not a stream's age. Negative removes it | | `MaxLifetime` | none | The longest a stream stays open. Negative removes a bound a wider scope set | | `Retry` | unset | The reconnection delay advertised at the start of every stream | | `MaxStreams` | 1024 | Streams the application serves at once. Application-level only | | `MaxStreamsPerIP` | 64 | Streams one address holds at once, counting an IPv6 client by its /56. Application-level only | Set them application-wide through `AppOptions.SSE` and narrow them for a router or a route with `WithSSE`. Layering works field by field, so a route that only lengthens the keepalive keeps the application's write timeout. `MaxStreams` and `MaxStreamsPerIP` may only be set on the application: the resource they protect is the process, not a route, so a router or a route that sets either is refused when the application is built. The keepalive exists because a proxy that sees an idle connection for long enough closes it, and because a silent stream is indistinguishable from a dead one. A keepalive arrives every interval, measured from the last write. It used to wait on fixed ticks and skip a tick whose last write was younger than the interval; its own write lands a hair after its tick, so the tick after it always skipped, and a keepalive asked for every 15 seconds arrived at 15, 45 and 60, twice the silence a proxy idle timeout was meant to be kept off with. This was seen behind Cloud Run. The keepalive clock is monotonic, so a wall-clock step cannot starve it. A stream whose response cannot carry write deadlines is still served, but its `WriteTimeout` is not enforced. Middleware that wraps the response writer, forwards `Flush` and has no `Unwrap() http.ResponseWriter` method hides the connection's deadlines, and the first stream on each route logs a warning naming which writer stopped the chain. Give the wrapper an `Unwrap`. ## What does not apply **Compression leaves an event stream alone.** Holding events in a compressor's window until something forces them out is the one thing a stream cannot survive. **The origin check a WebSocket needs has no counterpart here.** An `EventSource` is subject to the same-origin policy and to CORS like any other request, so `AppOptions.CORS` already governs it. See [CORS](/docs/security/cors). **Nothing a handler fails with is disclosed.** The response header went out before the handler ran, so a failure ends the stream and the reason goes to the log. ## Shutdown Open streams are tracked, so a graceful shutdown ends every one of them and waits for the handlers, instead of waiting out its whole deadline once per stream. An idle stream ends cleanly. Streams are ended once readiness has turned unavailable and `ServerOptions.DrainDelay` has passed, as the listeners close and ordinary requests drain, and the background tasks and lifecycle components are dealt with only after the handlers have returned. See [Lifecycle](/docs/getting-started/lifecycle#shutdown). A shutdown can also arrive while a stream is still opening: it is decided under the lock, and the header is written as an armed write a shutdown can stop, so an interrupt landing in that window is not lost. ## Documentation An SSE route is documented with a `200` response carrying `text/event-stream`, whose schema describes the data field of a single event. OpenAPI 3.1 has no way to say "many of these, one after another", and one event's data is the only part a client has to be able to decode. A stream whose model is `muzak.Empty` is documented with no schema at all. The `503` a draining or full server answers with is documented too, and a status a route declared with `WithResponseDoc` keeps its own description. ## Testing ```go func TestStreamItems(t *testing.T) { client := testclient.New(t, buildApp()) stream := client.SSE("/items/stream") client.Post("/items/", testclient.JSON(map[string]string{"id": "plumbus", "name": "Plumbus"})). AssertStatus(http.StatusCreated) item := stream.Decode[schemas.ItemOut]() if item.ID != "plumbus" { t.Errorf("id = %q, want plumbus", item.ID) } } ``` `muzak.SSEDial` is the reading half of the same engine, so a route is tested over a real connection rather than against a second implementation: ```go reader, _, err := muzak.SSEDial(ctx, "http://"+app.Addr()+"/items/stream", muzak.SSEDialOptions{}) if err != nil { return err } defer reader.Close() for { message, err := reader.Next(ctx) if errors.Is(err, muzak.ErrSSEStreamEnded) { return nil } if err != nil { return err } item, err := message.Decode[schemas.ItemOut]() if err != nil { return err } _ = item } ``` A response that is not `200` with a `text/event-stream` body is refused rather than parsed, because a stream reader that quietly accepts an HTML error page reports "no events" for what is actually a failure. `SSEDialOptions` carries the method, body, headers, a `LastEventID` to resume from, a read limit, a read timeout, a handshake timeout and `KeepComments` for seeing the keepalives. `reader.Close` may be called from another goroutine, and releases a `Next` that is waiting. Once a reader has been closed, or a read cancelled or timed out, `Next` reports `ErrSSEStreamEnded` even for an event that had already arrived and was waiting in the buffer. A few things `SSEDial` does not do, on purpose: - **It does not follow redirects.** A `3xx` is refused and the response returned with the error. Dial the `Location` yourself if it is trusted. It used to send custom headers such as `X-Api-Key` to another host, and `Authorization` over an https-to-http downgrade. - **Opening a stream is bounded.** It removes the supplied client's `Timeout` so that it does not cut the stream short, and used to put nothing in its place, so a server that accepted the request and never answered held the call for as long as a context without a deadline lasts. `SSEDialOptions.HandshakeTimeout` now bounds it, defaulting to the `Timeout` of the supplied `HTTPClient` when it has one and otherwise to `muzak.DefaultSSEHandshakeTimeout` (30 seconds); a negative value removes the bound. It ends when the stream is open and says nothing about how long the stream lasts or idles. A server that takes longer than 30 seconds to send response headers needs it set higher, or negative. - **`SSEReader.Retry` is capped at an hour and accepts digits only.** A `retry:` field was parsed with `ParseInt` and multiplied into a duration, so a server sending `9223372036855` made `Retry()` negative, and a client that sleeps for it reconnected in a tight loop. A value with a sign, a space or an exponent is now ignored as the format requires, and a value beyond an hour is reported as an hour. - **`SSEReader.LastEventID` moves only when an event completes.** An `id` is held in a buffer, as the HTML specification keeps it, and becomes the last event ID when the blank line that ends its event arrives, whether or not the event carried data. An event the stream cut off part way, by dropping, by running past the read timeout or by exceeding the read limit, leaves it where it was, so a reconnect that passes it to `SSEDialOptions.LastEventID` asks for that event again rather than for the one after it. ## Where to go next [WebSockets](/docs/realtime/websockets) covers the two-way half, and [Testing](/docs/fundamentals/testing) covers asserting on a stream. -------------------------------------------------------------------------------- title: "Safe Defaults" description: "Every default in Muzak is the conservative one. This is the full list, what each one stops, and how to relax it deliberately." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/security/safe-defaults" -------------------------------------------------------------------------------- # Safe Defaults Muzak starts from settings that are safe rather than permissive. Each of them can be relaxed; none of them is relaxed by omission. ## The list | Area | Default | Relax it with | |---|---|---| | Listener timeouts | All four are non-zero. An `http.Server` left at its zero values holds a connection open forever, which is all a slow-loris client needs. Over HTTP/2, `WriteTimeout` also closes a connection whose socket accepts no bytes for that long | `ServerOptions`, a negative value to disable one | | Request bodies | Capped at 1 MiB. A body that declares a `Content-Length` over the limit is refused before any of it is read | `AppOptions.MaxBodySize`, `MaxBodySize(n)` | | Form bodies | A route that declares a `file` field is capped at 32 MiB. A route that binds only `form` values is capped at 1 MiB, urlencoded or multipart | `AppOptions.MaxUploadSize`, `MaxUploadSize(n)`, `MaxBodySize(n)` | | Request headers | The header block is capped at 64 KiB | `ServerOptions.MaxHeaderBytes` | | JSON bodies | A body with no `Content-Type` is refused with `415`, so a page cannot drive a JSON route cross-site without a CORS preflight | not relaxable | | Unknown JSON members | Rejected. A client's typo becomes an immediate `422` instead of a silently dropped value. A member naming a path, query, header or cookie field is unknown too | `AllowUnknownFields()` | | Duplicate members, invalid UTF-8 | Rejected by `encoding/json/v2` | not relaxable | | Located fields | A `path`, `query`, `header`, `cookie`, `form` or `file` tag the binder cannot reach is a build error, so a request cannot set it from the JSON body | not relaxable | | Numbers | `NaN` and infinity are refused in a parameter and by `validate.Number` | not relaxable | | Number parameters | JSON's decimal grammar only: hexadecimal, `1_000`, `.5` and a leading `+` are refused, so a proxy reading the number by JSON's rules sees the value the handler does | not relaxable | | URL rules | `URL()`, `HTTPS()` and `URLWithSchemes()` require a hostname, so `http://:8080/`, which an HTTP client dials as the local machine, is refused | not relaxable | | Scalar parameters sent twice | A query, header or form parameter that holds one value is refused with `422` when sent more than once, rather than one of its values being chosen | declare a slice to accept a list | | Cross-origin requests | Denied. No CORS header is emitted until a policy is configured, and a wildcard origin with credentials is refused outright. Every response under a named-origin policy carries `Vary: Origin` | `AppOptions.CORS` | | Cross-site request forgery | Refused once `AppOptions.CrossOriginProtection` is set: a request a browser sends from another origin with any method but GET, HEAD and OPTIONS is answered `403` before any middleware, guard or handler runs, with no token to manage. See [Cross-Origin Requests](/docs/security/cross-origin-requests) | `TrustedOrigins`, `InsecureBypassPatterns` | | Sessions | Off until configured. Once on, the cookie is HttpOnly, Secure, SameSite=Lax and named `__Host-session`, its contents are encrypted and authenticated, and `CrossOriginProtection` is required beside it, so a cookie a browser attaches on its own cannot be ridden from another site. See [Sessions](/docs/security/sessions) | `SessionOptions.SameSite`, `AllowInsecure`, `Name` | | Cross-origin WebSocket handshakes | Refused. A handshake is not subject to the same-origin policy and is never preflighted, so CORS cannot cover it. The origin is read strictly, and a handshake with more than one `Origin` is refused | `WSOptions.AllowedOrigins` | | WebSocket and DNS rebinding | The server's own origin is any whose host is the request's `Host`, until a route lists the names it answers to in `WSOptions.AllowedHosts`. `AppOptions.AllowedHosts` goes further: a request for any other host, a handshake included, is refused with `421` before it reaches a route | `WSOptions.AllowedHosts`, `AppOptions.AllowedHosts` | | WebSocket messages | Capped at 1 MiB, refused with `1009` before any of the payload is buffered, read a chunk at a time. The length check cannot overflow | `WSOptions.ReadLimit` | | WebSocket message time | One message has 30 seconds to arrive once it has begun, and a bounded number of frames to arrive in. Idle connections are left alone | `WSOptions.ReadTimeout` | | WebSocket keepalive | On: a ping every 30 seconds, closed when nothing at all arrived for 10 seconds after a ping, whenever the handler was reading during it | `WSOptions.PingInterval`, `PongTimeout`, a negative `PingInterval` to disable | | WebSocket connections | 1024 per application and 64 per address, then `503` with a `Retry-After`. A slot is held until the socket is actually closed. 16 addresses (one IPv6 /52) whose peers answer every ping and send nothing hold every slot unless `MaxLifetime` is set | `WSOptions.MaxConnections`, `MaxConnectionsPerIP` | | WebSocket and event stream age | Unbounded, until a route sets one | `WSOptions.MaxLifetime`, `SSEOptions.MaxLifetime` | | WebSocket extensions | None negotiated, so no peer can ask the server to hold compression state on its behalf | not relaxable | | Event stream writes | Given up on after 10 seconds, so a client that stops reading a stream that keeps writing cannot pin a goroutine and a full socket buffer once the buffers between them fill. A stream that sends only keepalives never fills them, and is never ended by this | `SSEOptions.WriteTimeout` | | Event streams | 1024 per application and 64 per address, with a keepalive comment every 15 seconds. 16 addresses (one IPv6 /52) that read nothing hold every slot unless `MaxLifetime` is set | `SSEOptions.MaxStreams`, `MaxStreamsPerIP`, `KeepAlive` | | Per-address counts | An IPv4 client is counted exactly and an IPv6 client by its /56, for the connection caps and the rate limit alike | `ClientIPOptions.ConnectionIPv6Prefix`, `ConnectionIPv4Prefix` | | Event fields | A name or identifier carrying a line break is refused, because it would end its own field and let what follows be read as events of its own | not relaxable | | Panics | Logged with a full stack trace, its value cut to 4 KiB, and answered with a generic `500`. Nothing derived from the panic reaches the client | not relaxable | | A failure after the response started | Logged once and the connection aborted, so a truncated export cannot look complete | not relaxable | | Errors | An error that does not describe itself becomes an opaque `500` with the real cause logged and never transmitted. A `StatusCoder` renders only its own message, below `500` | `AppOptions.ErrorRenderer` | | Error responses | Drop the headers set for the response they replace, and are sent `Cache-Control: no-store`, so an error is never served as HTML or cached publicly | `AppOptions.ErrorRenderer` | | Guarded responses | A route or file mount behind a guard, a request-scoped provider or a verifying security scheme, and any response whose request read its session, is sent `Cache-Control: private, no-cache`, so a CDN cannot hand one user's response to another (an event stream and a WebSocket keep their own). A verifying scheme adds `Vary` on the header its credential arrives in, and a session `Vary: Cookie`. A response carrying the session cookie stays private whatever sets `Cache-Control` after it | set `Cache-Control` in the handler, except on a response that carries the session cookie | | Request identifiers | Not trusted from the client, because an attacker-controlled identifier is an attacker-controlled log field | `AppOptions.TrustRequestIDHeader` | | Trace context | Off until a `Tracer` is named. A span is named after the route template, never the path, which carries identifiers, and `traceparent` and `tracestate` are read strictly. A valid `traceparent` is continued by default, so a service the internet reaches should believe only its proxy's. See [Observability](/docs/fundamentals/observability) | `TracingOptions.Parent` | | Forwarding headers | Not believed. `X-Forwarded-For` is read only for a request that arrived from a proxy named in `TrustedProxies`, and from the right | `AppOptions.ClientIP` | | Host header | Not checked until the application lists the hosts it serves; then a request for any other is answered `421` before anything else runs, and an HTTPS redirect only ever names a listed host. See [Allowed Hosts and HTTPS](/docs/security/hosts-and-https) | `AppOptions.AllowedHosts`, `AppOptions.RedirectHTTPS` | | Rate limiting | Off until a policy names a quota. Once one exists, counters are bounded in number and in key length, a full table makes room within the quota being counted (only a quota holding no counter takes one from the largest), a storage that stops answering is cut off after 2 seconds, and a storage that fails refuses traffic | `AppOptions.RateLimit`, `FailOpen`, `StorageTimeout` | | Secrets in logs | Any attribute key that contains a term such as `authorization`, `token`, `api-key`, `password`, `session`, `jwt` or `dsn` is redacted, and so is every value inside a matching group and every matching entry of a logged `http.Header`, `url.Values` or string map | `LoggerOptions.RedactKeys` | | Log records | One line each. Control characters, C1 controls, bidirectional controls and invalid UTF-8 are escaped, so a request path cannot forge a line or drive a terminal | not relaxable | | Token comparison | Constant-time, over hashed inputs, so neither the contents nor the length of a secret leaks through timing | `muzak.SecureCompare` | | Security headers | `X-Content-Type-Options`, `X-Frame-Options` and a referrer policy on every response, and `Strict-Transport-Security` on a response over TLS (not for localhost or an address). No `Content-Security-Policy`: a page sets its own | `AppOptions.DisableSecurityHeaders` | | Documentation UI | Not served at all until `AppOptions.DocsUI` names one. A page that is configured is embedded in the binary, fetches nothing from a third party, is served under a content security policy that hashes its own inline script and permits no network access beyond this origin, runs the application's guards and providers, and counts against its rate limit | `AppOptions.DocsUI`, `AppOptions.DisableDocs` | | Static files | A dotfile answers `404`, a file's type comes from its extension and never its content, HTML and SVG are sent under `Content-Security-Policy: sandbox`, a symbolic link cannot leave the directory, and a directory with a mount of its own is reachable only through it | `AllowDotfiles`, `AllowActiveContent` | | File mounts | Run the rate limit, the guards and every provider of the router they were registered on | `SkipRateLimit()`, a router without them | | Files and byte bodies | `Bytes`, `Stream` and `FileResponse` are sent with `X-Content-Type-Options: nosniff`. A `FileResponse` takes its type from the file's extension, never its content, refuses a dotfile, and sends HTML, SVG and XML sandboxed. See [Files, Streams and Redirects](/docs/techniques/files-streams-redirects) | `FileResponse.AllowActiveContent` | | Redirects | `muzak.Redirect` accepts only a path on this origin. `//`, `/\`, control characters and a path that decodes or resolves to another host are refused, and an absolute URL is sent only to a host the route lists | `RedirectHosts(...)`, `Redirect.External` | | Outbound requests | `muzak.NewClient` refuses loopback, private, link-local and cloud metadata addresses at the moment it connects, so a name that resolves to one, DNS rebinding and a redirect all meet the same check. Every call is bounded (30 seconds, a 10 MiB body, five redirects, never from https to http), and only a request that is safe to send twice is retried. See [HTTP Client](/docs/techniques/http-client) | `ClientOptions.AllowPrivateNetworks`, `AllowedNetworks`, `DeniedNetworks` | | Configuration | A `required` setting refuses an empty value, and a value read by a `secret` field is hidden from every other field that reads the same variable | drop `required`, or give it a `default` | | Validation | Automatic for any model that declares rules. There is no option to remember, so a model cannot be left unvalidated by forgetting one. A report holds at most 100 failures and follows nested models 32 levels deep, and `Unique()` is linear in the size of the collection | `SkipValidation()` | | Versioning | Off until a type is named. Once on, a route that declares no version answers nothing rather than being served unversioned, so a resource is opted into being version-independent deliberately. A header sent on several lines names no version | `AppOptions.Versioning`, `WithVersion`, `VersionNeutral` | | Late configuration | `Use`, `Include`, route and mount registration after the application was built panic, so a guard added late cannot be dropped silently | not relaxable | ## The one deliberate exception Rate limiting is off until a quota is declared. There is no limit that is right for every application, and a default one would be a number nobody chose refusing traffic nobody expected. What is safe by default is what happens once a policy exists: the counters are bounded, the address is not taken from a header anyone can write, an IPv6 client cannot multiply its budget by rotating addresses inside its own range, and a storage that stops answering stops traffic rather than stopping the limit. See [Rate Limiting](/docs/techniques/rate-limiting). One more thing is unbounded until a route says otherwise, and it is worth saying: how long a WebSocket or an event stream may stay open. Keepalive closes a peer that stops answering, and the write timeout bounds one write, but a peer that answers every ping and says nothing, or a client that reads nothing while comments trickle in, costs a connection, a goroutine and a file descriptor until the server stops, and nothing but a ceiling on age can tell it from one that is listening. `MaxLifetime` puts that decision in your hands, and it is left unset because there is no age that is right for every stream. ## The defaults as constants Every one of them is exported, so a deployment can read them rather than guess. ```go muzak.DefaultMaxBodySize // 1 << 20 muzak.DefaultMaxUploadSize // 32 << 20 muzak.DefaultMaxHeaderBytes // 64 << 10 muzak.DefaultReadHeaderTimeout // 5s muzak.DefaultReadTimeout // 30s muzak.DefaultWriteTimeout // 30s muzak.DefaultIdleTimeout // 120s muzak.DefaultShutdownTimeout // 15s muzak.DefaultWSReadLimit // 1 << 20 muzak.DefaultWSReadTimeout // 30s muzak.DefaultWSWriteTimeout // 10s muzak.DefaultWSMaxConnections // 1024 muzak.DefaultWSMaxConnectionsPerIP // 64 muzak.DefaultWSCloseGracePeriod // 250ms muzak.DefaultWSPingInterval // 30s muzak.DefaultWSPongTimeout // 10s muzak.DefaultSSEKeepAlive // 15s muzak.DefaultSSEWriteTimeout // 10s muzak.DefaultSSEMaxStreams // 1024 muzak.DefaultSSEMaxStreamsPerIP // 64 muzak.DefaultSSEHandshakeTimeout // 30s, for SSEDial muzak.DefaultConnectionIPv6Prefix // 56 muzak.DefaultConnectionIPv4Prefix // 32 muzak.DefaultRateLimitMaxEntries // 100000 muzak.DefaultRateLimitSweepInterval // 1m muzak.DefaultRateLimitStorageTimeout // 2s muzak.MaxValidationDetails // 100 muzak.MaxNestedDepth // 32 muzak.DefaultCompressionMinSize // 1400 muzak.DefaultForwardedHeader // "X-Forwarded-For" ``` ## Things the type system does rather than a default Some of what would be a runtime setting elsewhere is not a setting here at all. **A response cannot leak a field it does not declare.** The handler's return type is the response model. There is no filtering pass to configure and no `response_model` to forget, because a field that is not on `Out` has no way to be written. ```go // Adding a column to this changes nothing a client can see. type Item struct { ID string Name string InternalNote string } // Only these three members exist on the wire. type ItemOut struct { ID string `json:"id"` Name string `json:"name"` } ``` **A crafted body cannot reach a located field.** When an input mixes a path parameter with body members, the body is decoded into a struct of the body members alone, so a member naming a located field is an unknown member, refused with `422`, rather than a value that reaches it or is silently dropped. **Two concurrent requests cannot see each other's dependencies.** Resolved values live on the request's `Context` and are cleared when it returns to the pool. There is a test for exactly that, run under the race detector. **A misconfiguration is a build error, not a surprise.** Duplicate routes, unbindable input types, path parameters no field binds or declared twice, a static route that could never match, duplicate operation identifiers, malformed prefixes, a wildcard CORS origin combined with credentials, a CORS or WebSocket origin that could never match or is `"null"`, a WebSocket `AllowedHosts` entry no `Host` header can equal, a `MaxConnections` set on a route, a missing static directory, two mounts at one path, a location tag the binder cannot reach, a body type the JSON decoder refuses, a `Nested` that would validate nothing, a certificate without its key, a security scheme, category or title that cannot be described, alternatives that mix a verifying security scheme with a descriptive one, a `Dep` field no provider fills, sessions without cross-origin protection or with a secret under 32 bytes, an HTTPS redirect with no host it may name, an `AllowedHosts` entry that can never match, a `Timeout` on an event stream or WebSocket route, a health path a route already answers, and an OpenAPI document that cannot be encoded as JSON: all reported together when the application is built, before a socket is opened. ## A deployment checklist Things worth deciding explicitly, because the safe default may not be the correct one for your deployment: - **`ClientIP.TrustedProxies`** if anything sits in front of the service, or every request is attributed to the proxy. See [Behind a Proxy](/docs/deployment/behind-a-proxy). - **`CORS`** if a browser on another origin calls the API. See [CORS](/docs/security/cors). - **`CrossOriginProtection`** if a browser sends the service cookies, which sessions require anyway. See [Cross-Origin Requests](/docs/security/cross-origin-requests) and [Sessions](/docs/security/sessions). - **`AppOptions.AllowedHosts`**, so a request with a forged `Host` header, a WebSocket handshake included, is refused with `421`, and **`RedirectHTTPS`** if plain HTTP reaches the service. See [Allowed Hosts and HTTPS](/docs/security/hosts-and-https). - **`WSOptions.AllowedOrigins`** if a browser opens WebSocket connections, and a host list if the server is on a loopback or private address: `AppOptions.AllowedHosts` for every request, or `WSOptions.AllowedHosts` for one route's handshakes alone. - **`MaxLifetime`**, in minutes rather than hours, on every WebSocket and event stream route a client you do not control can open. Without it, 16 addresses can hold every slot of either. - **`SSEOptions.MaxStreams` against `MaxStreamsPerIP`**, since the one divided by the other is how many addresses fill every stream slot, and **`ClientIPOptions.ConnectionIPv6Prefix: 48`** so that an IPv6 client counts by its site's allocation, for streams and WebSocket connections alike. See [Server-Sent Events](/docs/realtime/server-sent-events). - **A rate limit policy**, and a shared storage if the service runs more than once. - **`Secure: true`** on every cookie once the service is served over TLS. See [Cookies](/docs/techniques/cookies). - **`DisableDocs`** if the deployment must not describe itself, or a guard declared where the routers are included, rather than on `New`, if only the API is private. - **`ServerOptions.UnencryptedHTTP2`** on a platform that speaks `h2c` to the container, and a `TrustedProxies` range for it. See [Running on Cloud Run](/docs/deployment/cloud-run). - **`Cache-Control`** in a handler that serves public data from behind a guard. - **`Required()`** on every JSON body member a client must send, since the decoder accepts a body that leaves any member out and the generated document follows the rules you declare. - **A `Servers` list** in the OpenAPI options, so a generated client points at the right host. ## Where to go next [Authentication](/docs/security/authentication) covers proving who a caller is, and [Behind a Proxy](/docs/deployment/behind-a-proxy) covers the one default that is wrong in the other direction behind a load balancer. -------------------------------------------------------------------------------- title: "Authentication" description: "Security schemes that enforce what they document, JWT bearer tokens verified against a key set, API keys compared in constant time, and the guards, providers and sessions for everything else." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/security/authentication" -------------------------------------------------------------------------------- # Authentication Authentication in Muzak is declared once. A **verifying security scheme** is written into the OpenAPI document like any other scheme, and a route that names it in `WithSecurity` refuses a request without a valid credential before any guard, provider or handler runs. The document and the behaviour are one declaration, so they cannot drift apart. Two verifying schemes ship with the framework: `muzak.JWTBearer` for bearer tokens issued by an identity provider, and `muzak.APIKeyVerifier` for API keys. Anything they do not cover, a browser session, a signed webhook, a credential checked against your own database, is a **guard** or a **provider**, exactly as before. | You need | Use | |---|---| | Tokens from an OAuth or OpenID Connect provider | `muzak.JWTBearer` with `JWKS` | | Tokens you sign yourself | `muzak.JWTBearer` with `Keys` | | API keys for partners or services | `muzak.APIKeyVerifier` | | One shared secret for an internal route | `muzak.RequireBearerToken` or `muzak.RequireHeaderToken` | | A browser session | `AppOptions.Sessions` and a provider that reads `ctx.Session()` | | A signature over the request body | `muzak.CaptureBody` and a guard | ## JWT bearer tokens ```go app := muzak.New(muzak.AppOptions{ Title: "Orders API", SecuritySchemes: map[string]muzak.SecurityScheme{ "oidc": muzak.JWTBearer(muzak.JWTOptions{ Issuers: []string{"https://login.example.com/"}, Audience: "https://api.example.com", Algorithms: []string{"RS256"}, JWKS: muzak.JWKSOptions{ URL: "https://login.example.com/.well-known/jwks.json", }, }), }, }) orders := muzak.NewRouter(muzak.WithSecurity(muzak.Require("oidc", "orders:read"))) orders.Get("/orders", handlers.ListOrders) orders.Get("/orders/health", handlers.Health, muzak.Public()) app.Include(orders, muzak.WithPrefix("/v1")) ``` A request to `/v1/orders` without `Authorization: Bearer ` is answered with `401` before `ListOrders` runs. A valid token that does not grant `orders:read` is answered with `403`. `/v1/orders/health` is open, because `Public()` exempts it. The issuers, the audience and the algorithms are required. There is no default that accepts whatever a token says: a scheme that leaves one out is a build error. Every key of a scheme is trusted for every issuer it lists, so a token signed with one issuer's key may name another listed issuer as its own. List several issuers in one scheme only when they share their keys, as the spellings of one identity provider's issuer do. Two identity providers are two schemes, offered as alternatives: `muzak.WithSecurity(muzak.Require("workforce"), muzak.Require("customers"))`. | Option | What it decides | |---|---| | `Issuers` | The `iss` values accepted, compared exactly | | `Audience` | The value `aud` must contain, as a string or one entry of an array | | `Algorithms` | The allowlist of JWS algorithms; `none` is never accepted | | `Keys` | Keys configured in the application, each optionally with a `kid` | | `JWKS` | A key set fetched from the issuer and kept fresh | | `Leeway` | Clock skew allowed on `exp`, `nbf` and `iat`; 60 seconds by default, at most 5 minutes | | `AllowNoExpiry` | Accept a token without `exp`; off by default | | `Types` | Accepted `typ` values; `JWT` and `at+jwt` by default | | `MaxTokenBytes` | Longest token read; 8 KiB by default, at most 64 KiB | | `Realm` | The realm the `WWW-Authenticate` challenge names; `api` by default | | `ResourceMetadata` | Publish RFC 9728 metadata; off by default | ### Reading the claims The verified claims are a `*muzak.Claims`, available as a `Dep`, through `muzak.From` and through `muzak.TryFrom`. ::code-group ```go [schemas/orders.go] type ListOrdersIn struct { Limit int `query:"limit" default:"20"` Claims muzak.Dep[*muzak.Claims] } ``` ```go [handlers/orders.go] func ListOrders(ctx *muzak.Context, in schemas.ListOrdersIn) ([]schemas.Order, error) { claims := in.Claims.Get() return store.OrdersOf(ctx.Context(), claims.Subject, in.Limit) } ``` :: `Claims` holds the registered claims Muzak checked: `Issuer`, `Subject`, `Audience`, `ExpiresAt`, `NotBefore`, `IssuedAt`, `ID`, the `Scopes` granted by `scope` or `scp`, and the `KeyID` and `Algorithm` of the header. `Raw` is the payload as it was signed. A `Dep[*muzak.Claims]` is checked when the application is built: every way into the route must go through a JWT scheme. A route that also admits an API key, or anonymous requests, reads the claims with `muzak.TryFrom[*muzak.Claims](ctx)` instead, and the build error says so. See [Dependencies](/docs/getting-started/dependencies) for `Dep`, `From` and `TryFrom`. ### Custom claims `muzak.ClaimsAs` decodes the payload into a type of your own, once per request and type. ```go type TenantClaims struct { Tenant string `json:"tid"` Roles []string `json:"roles"` } tenant, err := muzak.ClaimsAs[TenantClaims](ctx) if err != nil { return Out{}, err // a 401, with the reason logged } ``` To hand them to a handler as a dependency, wrap the call in a provider: ```go muzak.Needs(func(ctx *muzak.Context) (TenantClaims, error) { return muzak.ClaimsAs[TenantClaims](ctx) }) ``` ### What a token has to be A token is accepted only when every one of these holds. A token that fails any of them gets the same `401`, and nothing says which check failed. - **Its shape.** A JWS compact serialization of three segments, no longer than `MaxTokenBytes`, each segment in strict base64url: no padding, no `+` or `/`, no whitespace, and no non-zero trailing bits, so that each token has exactly one spelling. - **Its header.** A JSON object no deeper than four levels and no larger than 2 KiB, without a repeated member, naming an algorithm on the allowlist. A `typ`, when present, is one of `Types`. A token carrying `crit`, `jku`, `jwk`, `x5u`, `x5c`, `enc` or `zip`, or a `cty` of `JWT`, is refused. - **Its signature.** Checked with a key of the algorithm's own kind. A token naming a `kid` is checked only against keys with that `kid`, and one naming none only against keys that have none. ECDSA signatures are the raw `r||s` form of exactly the curve's width. - **Its claims.** A JSON object no deeper than sixteen levels, without a repeated member. `iss` is one of `Issuers`, `aud` contains `Audience`, `exp` is in the future, and `nbf` and `iat` are not, each within the leeway. Dates are JSON numbers, fractional or not, between 0 and the end of the year 9999. The payload is read only after the signature has verified. ### Keys and algorithms A key is used only by algorithms of its own kind. This is what defeats the classic algorithm-confusion attack, where a token signed with HS256 uses the server's RSA public key as its HMAC secret: an RSA key is never an HMAC secret, whatever the token says. | Algorithms | Key | |---|---| | `HS256`, `HS384`, `HS512` | `Secret`, at least as long as the hash: 32, 48 or 64 bytes | | `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512` | `*rsa.PublicKey` of at least 2048 bits | | `ES256`, `ES384`, `ES512` | `*ecdsa.PublicKey` on P-256, P-384 or P-521 respectively | | `EdDSA`, `Ed25519` | `ed25519.PublicKey` | ```go muzak.JWTBearer(muzak.JWTOptions{ Issuers: []string{"orders-service"}, Audience: "orders-api", Algorithms: []string{"HS256"}, Keys: []muzak.JWTKey{ {ID: "2026-10", Secret: settings.SigningKey}, {ID: "2026-07", Secret: settings.PreviousSigningKey}, }, }) ``` A short RSA key, a private key passed where the public one belongs, an ECDSA key on a curve no allowed algorithm uses, a point that is not on its curve and an HMAC secret shorter than its hash are all build errors. `JWTKey.Algorithm` pins a key to one algorithm, which is how a 32-byte secret is used for HS256 while HS512 is allowed for another key. ### Key sets With `JWKS.URL` set, the scheme fetches the issuer's JSON Web Key Set and keeps it. | `JWKSOptions` | Default | Bound | |---|---|---| | `Client` | `muzak.NewClient` with public addresses only | An `*http.Client` is accepted too | | `MaxBytes` | 1 MiB | The document is refused past it | | `MaxKeys` | 32 | At most 1024; a document with more is refused whole | | `Timeout` | 10 seconds | Per fetch | | `MinRefreshInterval` | 30 seconds | Least time between two fetches | | `MinCacheTTL`, `MaxCacheTTL` | 5 minutes, 24 hours | Bounds on the response's `max-age` | - **Fetched over https.** Plain http is accepted only for a loopback address, in a test. The same holds for every URL a redirect leads to: a set that arrives over plain http from anywhere else is refused, so a redirect down to http cannot replace the keys. - **Through the SSRF-safe client.** The default client refuses private, loopback and link-local addresses. An identity provider on a private network, such as a Keycloak beside the service, needs `muzak.NewClient(muzak.ClientOptions{AllowPrivateNetworks: true})`, or `AllowedNetworks` naming its subnet. See [HTTP Client](/docs/techniques/http-client). The size limit and the timeout apply whichever client you pass. - **Cached as the issuer says.** `Cache-Control: max-age` decides how long a set is kept, held within `MinCacheTTL` and `MaxCacheTTL`; a response without one is kept for an hour. - **Refreshed in the background.** The set is a lifecycle component named `jwks `. It is fetched as the application starts, without holding the start up, and again in the last tenth of each copy's life, so a token rarely waits for it. - **Fetched by the first request that needs one.** A token that arrives while no set is held (in a test without the lifecycle, or before the first background fetch has finished) starts a fetch, and that request waits for it up to `Timeout`, whatever its own deadline, so that its client leaving does not fail the fetch for everyone else. A request waiting for a fetch another one started stops waiting when its own context ends. - **Rotation without a flood.** A token naming a `kid` the set does not hold makes it fetched again, at most once per `MinRefreshInterval` however many such tokens arrive, and every request waiting for the same fetch shares it. A token with a random `kid` costs the issuer nothing. - **The last good set is kept.** A fetch that fails, answers with an error, sends something that is not a key set or sends one that is too large keeps the set already in use, and the failure is logged. There is no age past which the last good set is dropped, however long fetches keep failing, so watch the log for them. Until a first fetch has succeeded, a token is answered with `503`, which is the server's failure and not the client's. - **Strict keys.** Only `RSA`, `EC` and `OKP` keys are read: a symmetric `oct` key in a public set is a secret anyone can read and is never used. A key must be meant for signatures (`use` absent or `sig`, `key_ops` absent or allowing `verify`), its `alg`, when present, must be allowed and fit the key, an EC point must be on its curve, an RSA modulus must be 2048 to 8192 bits, and a key carrying private members is refused. A key that fails is skipped without costing the rest of the set. ### Scopes The scopes `Require` names are checked against what the token grants: the space-separated `scope` claim, an `scp` claim that is an array or such a string, or both. ```go r.Post("/orders", handlers.CreateOrder, muzak.WithSecurity(muzak.Require("oidc", "orders:write"))) ``` A valid token without the scope is answered with `403` and an `insufficient_scope` challenge naming the scopes required. ## API keys ```go "partners": muzak.APIKeyVerifier(muzak.APIKeyOptions{ Name: "X-API-Key", Keys: []muzak.APIKey{ {ID: "acme", Key: settings.AcmeKey, Scopes: []string{"orders:read"}}, {ID: "globex", Key: settings.GlobexKey}, }, }), ``` `In` is `header` by default, or `query` or `cookie`, matching `APIKeyHeader`, `APIKeyQuery` and `APIKeyCookie` in the document. The verified caller is a `*muzak.APIKeyPrincipal` with an `ID`, the `Scheme` that verified it and the `Scopes` it grants. - **Constant time.** Keys are held as SHA-256 digests, and a request's key is hashed and compared with every one of them without stopping at a match, so neither which key matched, nor how much of one did, nor how long one is shows in the response time. - **No ambiguity.** A request that sends the key twice, in two headers, parameters or cookies, is refused. - **Never logged.** Neither the key nor its digest reaches a log or a response. - **Checked at build.** A key under 16 bytes or over 1024 (the most read from a request), two principals sharing a key and two keys sharing an ID are build errors. A key in a query string is written to logs and browser history along with the URL, so prefer a header. A key in a cookie is an ambient credential: the browser attaches it to a request another site makes it send, as it does a session cookie. Pair a cookie key with [cross-origin protection](/docs/security/cross-origin-requests), which refuses a state-changing request a browser sends from another origin. A key set too large to list, or one stored in a database, is resolved with `Lookup`, which runs only for a key no static key matched: ```go Lookup: func(ctx *muzak.Context, key string) (*muzak.APIKeyPrincipal, error) { digest := sha256.Sum256([]byte(key)) client, err := store.ClientByKeyDigest(ctx.Context(), digest[:]) if errors.Is(err, store.ErrNotFound) { return nil, nil // 401 } if err != nil { return nil, err // 503, logged } return &muzak.APIKeyPrincipal{ID: client.Name, Scopes: client.Scopes}, nil }, ``` Store keys as digests, as above, so a leaked table is not a leaked set of keys. A lookup error is logged, so it must not quote the key. ## Combining schemes `WithSecurity` takes alternatives, and a `SecurityRequirement` names schemes that are needed together. Muzak enforces both exactly as the document describes them. ```go // A token, or a partner key. muzak.WithSecurity(muzak.Require("oidc"), muzak.Require("partners")) // A token and a partner key together. muzak.WithSecurity(muzak.SecurityRequirement{"oidc": nil, "partners": nil}) // A token when there is one, anonymous otherwise. muzak.WithSecurity(muzak.Require("oidc"), muzak.SecurityRequirement{}) ``` The first requirement a request meets admits it, and its principals reach the route. A request that presents a credential is judged by it even where anonymous requests are admitted: an expired token is refused rather than quietly served as nobody, so the client learns it needs a new one. A route may name a verifying scheme and a descriptive one in **one** requirement: the verifying one is enforced, and the guard that checks the other refuses on its own. Alternatives that mix the two are a build error, because a request that satisfies a described alternative cannot be told from one that satisfies nothing. A declaration replaces the one above it instead of adding to it, as it does in the document, since alternatives and combinations cannot be told apart once they are merged. That cuts both ways. A route's `WithSecurity` replaces its router's, and an inner router's `Public()`, or its own `WithSecurity` naming only descriptive schemes, replaces a verifying requirement given where it is included: the routes beneath it are then no longer enforced. Declare the requirement on the innermost router that needs it, or check the security list of each operation in the document. ## What is covered | Request | Enforced | |---|---| | The route itself | Yes | | `HEAD` answered by a `GET` route | Yes, it is that route | | `OPTIONS` and `405` from the route table | Nothing runs; they disclose only the methods | | `Router.Mount`, `Router.Static`, `Router.Frontend` beneath the router | Yes, they inherit its declaration | | `/openapi.json` and `/docs` | Yes, for a declaration on `muzak.New` itself | | A route or mount with `Public()` | No | The gate runs after the rate limit is counted, so a client guessing tokens spends its budget, before a body `CaptureBody` keeps is read, so a request without a valid credential cannot make the server buffer one, and before every guard and provider, so neither ever sees an unauthenticated request. A response behind a verifying scheme carries `Cache-Control: private, no-cache` unless the handler sets its own, and `Vary: Authorization` (or the API key's header, or `Cookie`). Like guards declared on `muzak.New`, a requirement declared there, as in `muzak.New(opts, muzak.WithSecurity(muzak.Require("oidc")))`, covers `/openapi.json` and `/docs`. Guarded documentation is sent `Cache-Control: private, no-cache` and counts against the application's rate limit. To publish the documentation while the API stays private, declare the requirement where the routers are included instead, or set `AppOptions.DisableDocs` to serve neither. See [OpenAPI](/docs/fundamentals/openapi#guarding-the-documentation). ## Refusals | Situation | Status | Challenge | |---|---|---| | No credential | `401` | `Bearer realm="api"` | | A credential that is not valid | `401` | `Bearer realm="api", error="invalid_token"` | | A valid credential without a required scope | `403` | `Bearer realm="api", error="insufficient_scope", scope="orders:write"` | | No key set fetched yet, or a lookup failed | `503` | none | The body is the standard error envelope with its standard sentence, `Authentication is required.` or `You do not have access to this resource.`, rendered by the configured error renderer, so `AppOptions.ProblemDetails` applies. It never quotes the token, and never says whether the signature, the issuer, the audience or the expiry was wrong. An API key scheme names no challenge, since there is no standard one for it. ## Protected resource metadata An OAuth client that knows only an API's URL, an MCP client among them, discovers where to get a token from the API's RFC 9728 metadata. `ResourceMetadata` publishes it: ```go muzak.JWTBearer(muzak.JWTOptions{ // ... ResourceMetadata: &muzak.ResourceMetadata{ Resource: "https://api.example.com", ScopesSupported: []string{"orders:read", "orders:write"}, }, }) ``` ```http GET /.well-known/oauth-protected-resource { "resource": "https://api.example.com", "authorization_servers": ["https://login.example.com/"], "scopes_supported": ["orders:read", "orders:write"], "bearer_methods_supported": ["header"] } ``` The document is public, whatever `WithSecurity` the application declares, and left out of the OpenAPI document. The application's own guards still run for it, as they do for every route, so an application-wide guard keeps clients from reading it; declare such a guard on an included router instead. `AuthorizationServers` defaults to the scheme's issuers. A resource with a path, `https://api.example.com/v1`, is described at `/.well-known/oauth-protected-resource/v1`, as the RFC places it (`muzak.ProtectedResourcePath` followed by the resource's path). Every challenge then names it: ```http WWW-Authenticate: Bearer realm="api", error="invalid_token", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource" ``` It is off by default. The path is the one in `Resource`, served by this application as it stands, so an application behind a proxy that strips or adds a prefix must make the public URL and the served path agree. This is the document an MCP client reads to find where to sign in; see [MCP Tools](/docs/techniques/mcp-tools). ## Shared secrets For an internal service, a webhook receiver or an admin subtree, the credential is one secret both sides know. Two guards ship with the framework. ```go // An Authorization: Bearer header. muzak.RequireBearerToken(settings.AdminToken) // Any named header, such as an internal service token or a webhook // signing key. muzak.RequireHeaderToken("X-Token", settings.AdminToken) ``` ```go [cmd/main.go] app.Include(routers.Admin(), muzak.WithPrefix("/admin"), muzak.WithTags("admin"), muzak.WithDependencies(muzak.RequireHeaderToken("X-Token", settings.AdminToken)), ) ``` Both compare in constant time, and `RequireBearerToken` answers a missing credential with a `WWW-Authenticate` header so a client knows which scheme to use. They are guards, so the document does not know about them unless you declare a descriptive scheme too; see [Documenting a guard](#documenting-a-guard). A provider of your own reads a bearer credential with `muzak.BearerToken(ctx)`, which reports whether a well-formed one was present and matches the scheme name case-insensitively, as RFC 9110 requires. ## Comparing secrets ```go if !muzak.SecureCompare(given, expected) { return muzak.Unauthorized("") } ``` Comparing a credential with `==` leaks its contents: the comparison returns as soon as two bytes differ, so an attacker who can time the response can recover the secret one byte at a time. `SecureCompare` hashes both inputs first and compares the digests, which also keeps the length of the expected secret from leaking. Use it for tokens, API keys and signatures. It is **not** a password verification function. A password must be checked against a slow, memory-hard hash such as the one `golang.org/x/crypto/argon2` provides. ## Resolving a user A verified token says who the caller is. Your application usually wants its own record of them, which is a provider that reads the claims: ```go func GetCurrentUser(ctx *muzak.Context) (CurrentUser, error) { claims := muzak.From[*muzak.Claims](ctx) users := muzak.From[*core.UserStore](ctx) user, err := users.BySubject(ctx.Context(), claims.Subject) if err != nil { return CurrentUser{}, muzak.Unauthorized("").Wrap(err) } return CurrentUser{Username: user.Username}, nil } ``` ```go r.Get("/users/me", handlers.CurrentUser, muzak.WithSecurity(muzak.Require("oidc")), muzak.Needs(core.GetCurrentUser)) ``` The provider runs after the gate, so the claims are always there. `Wrap` records the real cause in the log and keeps it out of the response. See [Dependencies](/docs/getting-started/dependencies#providers). ## Signing in with a session A browser flow exchanges credentials for a session once, then presents the session cookie with every request. `AppOptions.Sessions` keeps the session, encrypted in the cookie or in a store, and requires `AppOptions.CrossOriginProtection` beside it; see [Sessions](/docs/security/sessions) and [Cross-Origin Requests](/docs/security/cross-origin-requests). The sign-in handler checks the password and records who signed in: ::code-group ```go [handlers/login.go] func Login(ctx *muzak.Context, in schemas.LoginIn) (schemas.LoginOut, error) { expected, known := accounts[in.Username] // The comparison runs even for an unknown account, and the same answer is // given either way, so the endpoint is not an account enumerator. matches := subtle.ConstantTimeCompare([]byte(expected), []byte(in.Password)) == 1 if !known || !matches { return schemas.LoginOut{}, muzak.Unauthorized("the username or password is incorrect") } // A new session first, so a session someone planted in the browser is // worth nothing at the moment it would have become valuable. session := ctx.Session() if err := session.Regenerate(); err != nil { return schemas.LoginOut{}, err } if err := session.Set("user", in.Username); err != nil { return schemas.LoginOut{}, err } return schemas.LoginOut{Username: in.Username}, nil } ``` ```go [routers/login.go] r.Post("/login/", handlers.Login, muzak.WithResponseDoc(http.StatusUnauthorized, "The username or password is incorrect"), // Counted before the handler, so a wrong password costs the same budget // as a right one. muzak.RateLimit(muzak.Quota{Name: "login", Window: time.Minute, Limit: 5})) ``` :: Four things matter on a sign-in route, all visible above: - **The same answer either way.** Telling an unknown account from a wrong password turns the endpoint into an account enumerator. - **The same timing either way.** Returning early on an unknown account says the same thing more quietly, which is why the comparison runs regardless. - **A rate limit counted before the guards.** A request a guard refuses is still counted, so failed sign-ins cost the attacker their budget. `RateLimitOptions.AfterDependencies` would give that up, which is why a sign-in route never uses it. - **A password checked against a slow hash.** The example compares plain values because there is nothing to protect in it. A real one stores an argon2 or bcrypt digest. Resolve the caller on the way back in with a provider: ```go func GetCurrentUser(ctx *muzak.Context) (CurrentUser, error) { username, ok := muzak.SessionGet[string](ctx.Session(), "user") if !ok { return CurrentUser{}, muzak.Unauthorized("sign in first") } return CurrentUser{Username: username}, nil } ``` ## Credentials on a WebSocket A browser cannot set headers on a WebSocket handshake or an `EventSource`, so a JWT scheme, which reads `Authorization`, only covers clients that are not browsers. A browser's WebSocket authenticates with the session cookie it attaches itself, or a short-lived ticket in the query, resolved by a provider: ```go func GetTicket(ctx *muzak.Context) (Ticket, error) { if user, ok := muzak.SessionGet[string](ctx.Session(), "user"); ok { return Ticket{User: user}, nil } if t := ctx.Query("ticket"); t != "" { return tickets.Redeem(ctx.Context(), t) // single use, short-lived } return Ticket{}, muzak.Unauthorized("a session or a ticket is required") } ``` A cookie is ambient authority a browser attaches without being asked, which is why a cross-origin handshake is refused by default. A token in a query string ends up in access logs and browser history; Muzak's own access log records no query strings for that reason. See [WebSockets](/docs/realtime/websockets). A credential is judged once, when the connection or the stream opens. A WebSocket or an event stream that stays open outlives the token that admitted it: nothing closes it when the token's `exp` passes. Set `WSOptions.MaxLifetime` or `SSEOptions.MaxLifetime` to something near the lifetime of your tokens, and the client reconnects with a fresh one. ## Verifying a signature over the body A signed *request* is verified from its bytes. Declare `muzak.CaptureBody()` and the bytes are kept for the request. They are read after the rate limit is counted and after a verifying scheme has admitted the request, and before the guards and providers run, so the guard below refuses an unsigned request before anything decodes it. ```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 { return Verified{}, muzak.NewHTTPError( http.StatusInternalServerError, "this route does not capture its body") } expected := hmac.New(sha256.New, secret) expected.Write(body) if !hmac.Equal(expected.Sum(nil), presented) { return Verified{}, muzak.Unauthorized("the signature does not match") } return Verified{}, nil } ``` **Verify the original, never a re-encoding of it.** Marshalling the bound input back to JSON produces a different document, and a signature over it never matches. ## Documenting a guard A guard is a function that Muzak can run and cannot read, so a route protected by one is documented with a descriptive scheme, which says what the guard checks and enforces nothing: ```go app := muzak.New(muzak.AppOptions{ SecuritySchemes: map[string]muzak.SecurityScheme{ "admin": muzak.APIKeyHeader("X-Token"), // descriptive }, }) admin := muzak.NewRouter( muzak.WithSecurity(muzak.Require("admin")), muzak.WithDependencies(muzak.RequireHeaderToken("X-Token", settings.AdminToken)), ) ``` `BearerAuth`, `BasicAuth`, `HTTPAuth`, `APIKeyHeader`, `APIKeyQuery`, `APIKeyCookie`, `OAuth2` and `OpenIDConnect` are descriptive, and behave exactly as they always have. `JWTBearer` and `APIKeyVerifier` appear in `components.securitySchemes` exactly as `BearerAuth("JWT")` and the matching `APIKey...` constructor do, so switching a route from a descriptive scheme and a guard to a verifying scheme changes nothing in the document. See [OpenAPI](/docs/fundamentals/openapi#declaring-how-a-route-authenticates). ## Testing A test signs its own tokens with a key the test configures. An HMAC secret is the shortest path: ```go func testApp(t *testing.T) *muzak.App { secret := bytes.Repeat([]byte("k"), 32) return buildApp(muzak.JWTBearer(muzak.JWTOptions{ Issuers: []string{"test"}, Audience: "orders-api", Algorithms: []string{"HS256"}, Keys: []muzak.JWTKey{{Secret: secret}}, })) } ``` ```go func TestListOrdersNeedsAToken(t *testing.T) { client := testclient.New(t, testApp(t)) res := client.Get("/v1/orders") res.AssertStatus(http.StatusUnauthorized) res.AssertErrorCode(muzak.CodeUnauthorized) } ``` `App.Override` replaces providers, and a verifying scheme is not one: like a guard, it keeps refusing in a test exactly as it does for a client, so a test that wants to be let in presents a credential. A suite whose every request needs one gives it to the client once: ```go client := testclient.New(t, testApp(t), testclient.WithHeader("Authorization", "Bearer "+signTestToken(t))) ``` ## Where to go next [Authorization](/docs/security/authorization) covers what a verified caller is allowed to do, [Sessions](/docs/security/sessions) covers the session a browser signs in to, [HTTP Client](/docs/techniques/http-client) covers reaching an identity provider on a private network, and [MCP Tools](/docs/techniques/mcp-tools) covers serving routes to an MCP client that signs in through the protected resource metadata. -------------------------------------------------------------------------------- title: "Authorization" description: "Deciding what an authenticated caller may do, with guards that cover a subtree, checks that need the resolved identity, and ownership checks in the handler." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/security/authorization" -------------------------------------------------------------------------------- # Authorization Authentication answers "who is this". Authorization answers "may they do this". In Muzak the second is a guard, a provider, or a check in the handler, depending on what the answer depends on. | The decision depends on | Put it in | |---|---| | The route alone | A guard on the router, declared where it is included | | The caller's identity or role | A guard or provider that reads the resolved caller | | The specific record being touched | The handler, after the record is loaded | ## Authorizing a whole subtree The clearest place for a subtree decision is where the subtree is mounted. ```go [cmd/main.go] // The admin router is written without a prefix or a guard. Both are applied // here, which is what 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.StatusForbidden, "The caller is not an administrator"), ) ``` Guards run in declaration order, outermost first: those declared on the application run before those declared at the include, which run before the route's own. The first guard to return an error stops the chain and produces the response. ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{Title: settings.AppName}, // Runs for every route in the application, including the admin subtree. muzak.WithDependencies(core.GetQueryToken), ) ``` ## Authorizing on a role Every guard on a request runs before any provider does, whatever scope each was declared on. A guard therefore cannot read a value a provider produced: `muzak.From` would panic and `muzak.TryFrom` would report that nothing was resolved. That is not a limitation to work around. A role check is part of resolving the caller, so it belongs in the provider, and the result is a type that only exists when the check passed. ::code-group ```go [core/authorization.go] // Admin is a caller who has been shown to hold the admin role. // // Having one is proof of the check, because the only thing that produces one is // the provider below. type Admin struct { Username string } // GetAdmin resolves the caller and insists they are an administrator. func GetAdmin(ctx *muzak.Context) (Admin, error) { user, err := GetCurrentUser(ctx) if err != nil { return Admin{}, err } if !slices.Contains(user.Roles, "admin") { return Admin{}, muzak.Forbidden("this action requires the admin role") } return Admin{Username: user.Username}, nil } ``` ```go [routers/admin.go] func Admin() *muzak.Router { r := muzak.NewRouter(muzak.Needs(core.GetAdmin)) r.Post("/", handlers.AdminAction, muzak.Status(http.StatusCreated), muzak.Summary("Admin action"), muzak.Description("Performs a privileged action. Requires the admin role.")) return r } ``` ```go [handlers/admin.go] func AdminAction(ctx *muzak.Context, in schemas.AdminActionIn) (schemas.AdminActionOut, error) { // Reaching this line is proof of the check. admin := muzak.From[core.Admin](ctx) return schemas.AdminActionOut{Name: in.Name, Message: "acted on by " + admin.Username}, nil } ``` :: `Needs` on a router applies to every route beneath it, so the whole subtree is covered by one line. A check declared **outside** cannot be removed by a declaration of the same type inside. Every provider along a route's chain runs, outermost first, so an `app.Include(admin, muzak.Needs(RequireAdmin))` keeps rejecting non-admins even when the admin router, or a route in it, declares `Needs(CurrentUser)` of the same type. Until 0.2.8 the nearer declaration silently replaced the outer one, and the outer authorization never ran. `muzak.From` still returns the innermost value. The same chain, guards and providers alike, protects the files a `Frontend` or `Static` mount serves and the handler a `Mount` serves: each runs the rate limit, the verifying security schemes, the guards and every inherited provider of the router it was registered on. Put assets meant to be public on a router without those options. When a check does not need the caller's identity, a guard is the simpler tool, and it can read anything on the request directly: ```go // RequireMaintenanceWindow refuses a destructive route outside the window it is // allowed to run in. It reads nothing a provider produced, so a guard is enough. func RequireMaintenanceWindow(ctx *muzak.Context) error { if !inMaintenanceWindow(time.Now()) { return muzak.Forbidden("this action is only available during the maintenance window") } return nil } ``` ## Authorizing on a scope When the caller is proved by a verifying security scheme, a JWT bearer token or an API key the application checks itself, the scopes a route needs are part of its requirement, and they are checked before anything of the route runs: ```go [cmd/main.go] app.Include(routers.Admin(), muzak.WithPrefix("/admin"), // A token without the admin scope is refused with 403 before any guard, // provider or handler beneath this router runs. muzak.WithSecurity(muzak.Require("oidc", "admin")), ) ``` A credential that is missing or invalid is answered `401`, and one that is valid but lacks a scope `403`, whose bearer challenge says `insufficient_scope`. The verified caller reaches the route as a `*muzak.Claims` or a `*muzak.APIKeyPrincipal`, so a provider that decides on a role, as `GetAdmin` does above, can start from it rather than from a header. See [Authentication](/docs/security/authentication) for declaring the scheme. ## 401 against 403 | Status | Means | |---|---| | `401 Unauthorized` | No usable credential was presented, or the one presented could not be verified | | `403 Forbidden` | The credential was understood, and it is not enough | The distinction matters to a client: a `401` says "sign in", a `403` says "signing in again will not help". Muzak classifies them as `unauthorized` and `forbidden`, which is the `code` a client should branch on rather than the status. Where the status alone is too coarse, add a classifier of your own: ```go return muzak.Forbidden("this workspace is on the free plan"). WithCode("plan_upgrade_required") ``` ## Authorizing a specific record Ownership is not knowable until the record is loaded, so it belongs in the handler. ```go [handlers/items.go] func UpdateItem(ctx *muzak.Context, in schemas.ItemUpdateIn) (schemas.ItemOut, error) { store := muzak.From[*core.ItemStore](ctx) user := muzak.From[core.CurrentUser](ctx) item, err := store.Get(in.ID) if err != nil { return schemas.ItemOut{}, asHTTPError(err) } if item.Owner != user.Username { // The same answer a missing item gets. Telling a caller that an item // exists but is not theirs turns the endpoint into a way to enumerate // other people's identifiers. return schemas.ItemOut{}, muzak.NotFound("Item not found") } updated, err := store.Rename(in.ID, in.Name) if err != nil { return schemas.ItemOut{}, asHTTPError(err) } return schemas.ItemOut{ID: updated.ID, Name: updated.Name, Owner: user.Username}, nil } ``` Answering `404` rather than `403` for someone else's record is a deliberate choice: a `403` confirms the record exists. Use it where the existence of the record is not itself a secret, and `404` where it is. ## Authorization and the response body The strongest guarantee here is not a check at all. The handler's return type is the response model, so a field a caller must not see cannot be returned by accident: it is not part of the type. ```go // The store's own type. Adding a field here changes nothing a client can see. type Item struct { ID string Name string Owner string InternalNote string } // What a client receives. Owner is present only where the route resolves the // caller, and InternalNote has no way to be written at all. type ItemOut struct { ID string `json:"id"` Name string `json:"name"` Owner string `json:"owner,omitzero"` } ``` Where two audiences need two shapes, write two output types and two routes rather than one type with a filtering step. ## Ordering, once more For every request: 1. Middleware. The allowed-hosts check, the HTTPS redirect, the CORS policy and the cross-origin check, each when it is configured, run in front of anything installed with `App.Use`; a health probe is answered before all of them. 2. The rate limit count, unless the policy defers it. 3. The route's `Timeout` deadline starts, when it has one. 4. The verifying security schemes the route's `WithSecurity` names. 5. The body a `CaptureBody` route keeps is read. 6. Guards, outermost first. 7. Providers, in declaration order. 8. The rate limit count, if the policy deferred it. 9. `Dep` fields are filled, then binding. 10. Validation. 11. The handler. Guards running before validation is what keeps an unauthenticated caller from learning your schema by sending it garbage, and a verifying scheme judged before the body is read is what keeps a caller without a valid credential from making the server buffer one. Rate limiting running before the guards is what makes a rejected request still cost the caller something. See [Rate Limiting](/docs/techniques/rate-limiting), and [Dependencies](/docs/getting-started/dependencies#the-order-things-run-in) for what follows the handler. ## Hiding an endpoint is not authorizing it ```go r.Get("/healthz", handlers.Health, muzak.Hidden(), muzak.SkipRateLimit()) ``` `Hidden` leaves a route fully routable and merely absent from the document. It is right for a health check or an internal endpoint whose existence is uninteresting; it is not a substitute for a guard. The probes a platform sends need no route of your own: `AppOptions.Health` answers them ahead of routing, unauthenticated by design and out of the document, and says no more than whether the service is up unless `ReportChecks` asks for the name of each check. See [Health Checks](/docs/deployment/health-checks). Nor is a guard a way to hide that a path exists. `OPTIONS` and `405` answers come from the route table alone, as `net/http.ServeMux` answers them, so they skip a route's security schemes, guards and rate limit: an unauthenticated client learns which paths exist and which methods they take, and never a route's data. Middleware installed with `App.Use` still covers them, so put a guard or a limit there when the existence of a path is itself a secret. Percent-encoding cannot route a request around a guard either. Static segments are compared percent-decoded, so `GET /users/%61dmin` reaches the guarded `/users/admin` and is not answered by a public `/users/{id}` with `id` set to `admin`. ## Testing an authorization rule ```go func TestAdminActionRequiresTheRole(t *testing.T) { client := testclient.New(t, buildApp(), authorizedAs("editor")) res := client.Post("/admin/", testclient.JSON(map[string]string{"name": "plumbus"})) res.AssertStatus(http.StatusForbidden) res.AssertErrorCode(muzak.CodeForbidden) } func TestUpdatingSomebodyElsesItem(t *testing.T) { client := testclient.New(t, buildApp(), authorizedAs("morty")) res := client.Put("/items/ricks-portal-gun", testclient.JSON(map[string]string{"name": "mine now"})) res.AssertStatus(http.StatusNotFound) } ``` A test per rule is cheap here, because the client exercises the whole chain: middleware, guards, providers, binding and the handler. ## Where to go next [Authentication](/docs/security/authentication) covers producing the identity these rules read, and [Safe Defaults](/docs/security/safe-defaults) covers what the framework refuses without being asked. -------------------------------------------------------------------------------- title: "CORS" description: "A cross-origin policy that denies everything until it is written down, refuses the one combination browsers reject anyway, and does not cover a WebSocket handshake." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/security/cors" -------------------------------------------------------------------------------- # CORS Cross-origin resource sharing is configured rather than installed. The zero value denies every cross-origin request, and no CORS middleware is installed at all until a policy names an origin or supplies a decision function. That is the only safe default. A permissive policy set by accident hands any web page on the internet the ability to read authenticated responses from the browser of anyone visiting it. ## Configuring it ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", CORS: muzak.CORSOptions{ AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"}, AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE"}, AllowedHeaders: []string{"Content-Type", "Authorization", "X-Request-Id"}, ExposedHeaders: []string{"X-Request-Id", "RateLimit-Remaining"}, AllowCredentials: true, MaxAge: 10 * time.Minute, }, }) ``` | Field | Default | Effect | |---|---|---| | `AllowedOrigins` | none | The exact origins permitted, each written `scheme://host[:port]` in lower case, with no path and no default port. The single entry `"*"` allows any. A pattern and `"null"` are build errors | | `AllowOriginFunc` | none | A dynamic decision, consulted only for an origin `AllowedOrigins` did not already allow | | `AllowedMethods` | `GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS` | The methods a cross-origin request may use | | `AllowedHeaders` | `Content-Type, Authorization, X-Request-Id` | The request headers a client may send | | `ExposedHeaders` | none | The response headers a client may read. Browsers expose only a small safelist otherwise | | `AllowCredentials` | off | Permits cookies and `Authorization` headers on cross-origin requests | | `MaxAge` | 10 minutes | How long a browser may cache the preflight result. Browsers cap it regardless | Origins are matched exactly, scheme and port included. `https://app.example.com` and `https://app.example.com:8443` are two different origins, and so are the `http` and `https` spellings of the same host. Because an entry is compared byte for byte with the `Origin` header a browser writes, an entry spelled any other way would allow nobody, so it is refused when the application is built, with the spelling to use instead: - a trailing slash, as in `https://app.example.com/`, or any other path, query or fragment - upper case in the scheme or host, as in `HTTPS://App.example.com` - the scheme's default port, as in `https://app.example.com:443` or `http://app.example.com:80` - a pattern such as `*.example.com`, which belongs in `AllowOriginFunc` - an entry with no scheme, such as `app.example.com` `"null"` is refused for the opposite reason: it matches far more than it appears to. It is the origin a sandboxed iframe, a page loaded from a `file:` URL and a request redirected across origins all send, so any page can arrange to present it, and listing it would let every one of them read the response. Every entry that fails is reported together, joined with the other build errors. ## The combination that is refused A wildcard origin combined with credentials is refused as a configuration error rather than served. ```go // This does not start. muzak.ErrCORSWildcardCredentials is reported when the // application is built. muzak.CORSOptions{ AllowedOrigins: []string{"*"}, AllowCredentials: true, } ``` Browsers reject that pairing anyway, so accepting it here would only hide the mistake until it reached a browser and then look like a bug in the framework. ## Deciding dynamically ```go muzak.CORSOptions{ AllowOriginFunc: func(origin string) bool { return strings.HasPrefix(origin, "https://") && strings.HasSuffix(origin, ".example.com") }, AllowCredentials: true, } ``` It is consulted only when `AllowedOrigins` does not already allow the origin, and it runs on every cross-origin request, so it must be cheap and free of side effects. Be careful with suffix matching. `strings.HasSuffix(origin, "example.com")` without the dot also matches `https://notexample.com`. Prefer an exact list, or parse the origin and compare the host. ## Exposing response headers A browser lets a script read only a small safelist of response headers unless the policy says otherwise. If your clients read the request identifier or the rate limit budget, name those headers: ```go ExposedHeaders: []string{ muzak.HeaderRequestID, muzak.HeaderRateLimitLimit, muzak.HeaderRateLimitRemaining, muzak.HeaderRateLimitReset, }, ``` A browser-based MCP client, such as an inspector served from another origin, sends headers of its own (the session and protocol version, and on the 2026-07-28 revision the method and tool name) and has to read the session identifier `initialize` answers with, which a browser hides from it unless the policy exposes it: ```go AllowedHeaders: []string{"Content-Type", "Authorization", muzak.HeaderMCPSessionID, muzak.HeaderMCPProtocolVersion, muzak.HeaderMCPMethod, muzak.HeaderMCPName}, ExposedHeaders: []string{muzak.HeaderMCPSessionID}, ``` The endpoint also checks the `Origin` itself, against `MCPOptions.AllowedOrigins`, so such a client's origin is listed in both places. See [MCP Tools](/docs/techniques/mcp-tools). ## Where CORS sits in the chain CORS runs **before** any middleware installed with `App.Use`, and in front of the documentation routes and the router, so it covers the API, `/openapi.json` and any documentation page alike. ``` RequestID -> tracing and Observer* -> SecurityHeaders -> Recovery -> health probes* -> Locale* -> AccessLog -> AllowedHosts and RedirectHTTPS* -> CORS -> cross-origin check* -> your middleware -> /docs and /openapi.json -> routes and mounts ``` The pieces marked `*` are there only when they are configured: `AppOptions.Tracing` or `Observer`, `Health`, `I18n`, `AllowedHosts` or `RedirectHTTPS`, and `CrossOriginProtection`. A health probe, and a request that `AllowedHosts` refuses or `RedirectHTTPS` redirects, are answered before the policy runs and carry no CORS headers. The cross-origin check runs inside it, so a refusal sent to an origin the policy allows carries the headers that let the page read why. The order matters for a browser. A preflight carries no credentials, so an authenticating middleware that ran in front of the policy refused every preflight, and its own `401` or `429` carried no `Access-Control-Allow-Origin`, which a browser reports as a network error rather than the status. Now a preflight is answered by the policy and never reaches a `Use` middleware, and a response such a middleware writes itself carries the policy's headers for an allowed origin and none for a denied one. A 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 policy nothing changes. A preflight `OPTIONS` is answered by the CORS middleware. Routes also answer `OPTIONS` automatically with an `Allow` header when nothing else does, which is a different mechanism for a different question: `Allow` says which methods a path serves, and the CORS headers say which a cross-origin caller may use. ### Caches Under a policy that names origins, every response carries `Vary: Origin`, whether or not the origin was allowed. It used to be added only when the origin was allowed, so a shared cache could serve the allowed origin a response without CORS headers. Preflight responses also vary on `Access-Control-Request-Method` and `Access-Control-Request-Headers`. These are recorded on the response and merged into `Vary` when it is written, so a handler that sets its own `Vary` adds to them rather than erasing them. A wildcard policy sends `Access-Control-Allow-Origin: *`, and `Access-Control-Expose-Headers` when it names any, on every response, including one to a request with no `Origin`. Its answer is then the same whatever the request says, which is why it needs no `Vary: Origin`. Before 0.2.9 those headers went only to a request carrying an `Origin`, so a browser or shared cache that kept the answer to a plain navigation, a `Static` or `Frontend` file with its `Last-Modified` above all, handed it to a later cross-origin fetch, which the browser refused. A policy that cannot be served is reported with the other build errors, where it used to be recorded after the start-up log had reported the routes registered, so every request was answered with a bare `500` and the log named no reason. ## What CORS does not cover **A WebSocket handshake.** It is not subject to the same-origin policy and is never preflighted, which is what makes cross-site hijacking possible in the first place. `AppOptions.CORS` has no bearing on it. The separate check is `WSOptions.AllowedOrigins`, and a cross-origin handshake is refused until it names one. See [WebSockets](/docs/realtime/websockets). **An event stream** is covered, because an `EventSource` is an ordinary request subject to the same-origin policy and to CORS like any other. See [Server-Sent Events](/docs/realtime/server-sent-events). **A server-to-server client.** CORS is a browser mechanism. `curl`, a Go client and a mobile app ignore it entirely, so it is not an access control: it decides what a *browser* will let a page read, and nothing more. Authorization is a [guard or a provider](/docs/security/authorization). **Cross-site request forgery.** CORS decides what a page may read, not what it may send. A form a page elsewhere submits, or a fetch it makes without a preflight, still reaches the handler with the user's cookies attached; only the answer is withheld. Refusing that request is `AppOptions.CrossOriginProtection`, which refuses a state-changing request a browser says came from another origin, and which sessions require. An origin CORS allows to read is not thereby allowed to change state: a frontend on another origin is listed in `CrossOriginOptions.TrustedOrigins` as well. See [Cross-Origin Requests](/docs/security/cross-origin-requests) and [Sessions](/docs/security/sessions). ## Testing it ```go func TestCORSAllowsTheApp(t *testing.T) { client := testclient.New(t, buildApp()) res := client.Options("/items/", testclient.Header("Origin", "https://app.example.com"), testclient.Header("Access-Control-Request-Method", "POST")) res.AssertHeader("Access-Control-Allow-Origin", "https://app.example.com") } func TestCORSDeniesAnyoneElse(t *testing.T) { client := testclient.New(t, buildApp()) res := client.Get("/items/", testclient.Header("Origin", "https://evil.example")) if got := res.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("Access-Control-Allow-Origin = %q, want no header at all", got) } } ``` ## Where to go next [Safe Defaults](/docs/security/safe-defaults) covers the rest of what is denied until it is configured, and [TLS](/docs/security/tls) covers serving the origins above over HTTPS. -------------------------------------------------------------------------------- title: "TLS" description: "Serve HTTPS from a certificate pair or a tls.Config, and know what changes in the rest of the application once you do." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/security/tls" -------------------------------------------------------------------------------- # TLS `App.Run` serves TLS whenever `ServerOptions` supplies a certificate pair or a `*tls.Config`. `ServerOptions` is embedded in `AppOptions`, so both are set inline. ## From a certificate on disk ::code-group ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", Addr: ":8443", CertFile: settings.TLSCertFile, KeyFile: settings.TLSKeyFile, }) log.Fatal(app.RunSignals()) ``` ```bash [.env] ADDR=:8443 TLS_CERT_FILE=/etc/ssl/certs/api.example.com.pem TLS_KEY_FILE=/etc/ssl/private/api.example.com.key ``` ```go [core/config.go] type Settings struct { Addr string `env:"ADDR" default:":8080"` TLSCertFile string `env:"TLS_CERT_FILE"` TLSKeyFile string `env:"TLS_KEY_FILE"` } ``` :: Leaving both empty serves plain HTTP, which is what a development run and a deployment behind a TLS-terminating proxy both want. Set **both or neither**. A certificate without a key, or a key without a certificate, is a build error. With only one of `CertFile` and `KeyFile` set, the server used to come up in plaintext on the port its operator believed was HTTPS, with nothing said: ``` muzak: ServerOptions.CertFile is set without ServerOptions.KeyFile; set both to serve HTTPS, or neither ``` ## From a tls.Config A `*tls.Config` covers everything a certificate pair cannot: several certificates, client certificates, a pinned minimum version, or a certificate that comes from somewhere other than a file. ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Addr: ":8443", TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}, }, }) ``` ### Mutual TLS ```go pool := x509.NewCertPool() pool.AppendCertsFromPEM(caPEM) app := muzak.New(muzak.AppOptions{ Title: "Internal API", Addr: ":8443", TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{serverCert}, ClientCAs: pool, ClientAuth: tls.RequireAndVerifyClientCert, }, }) ``` The verified client certificate is on the request, so a provider can turn it into an identity like any other credential: ```go func GetClientIdentity(ctx *muzak.Context) (ClientIdentity, error) { state := ctx.Request().TLS if state == nil || len(state.PeerCertificates) == 0 { return ClientIdentity{}, muzak.Unauthorized("a client certificate is required") } return ClientIdentity{CommonName: state.PeerCertificates[0].Subject.CommonName}, nil } ``` ### A certificate that renews itself `GetCertificate` is consulted per handshake, which is what an ACME client or a certificate that rotates on disk needs. ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Addr: ":8443", TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS13, GetCertificate: certificates.Current, }, }) ``` Whatever keeps `certificates` up to date is a resource with a start and a stop, so give it a `muzak.Lifecycle` and publish it. See [Lifecycle](/docs/getting-started/lifecycle). ## What changes once you serve HTTPS **Cookies.** Set `Secure: true` on every cookie, so a browser refuses to send it over plain HTTP. ```go ctx.SetCookie(&http.Cookie{ Name: "theme", Value: theme, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true, MaxAge: 3600, }) ``` A session needs no cookie of your own: `AppOptions.Sessions` writes one that is Secure, HttpOnly and SameSite=Lax by default, under a `__Host-` name a browser accepts only from a secure origin. See [Sessions](/docs/security/sessions). **Origins.** The `https` and `http` spellings of a host are different origins. Update `CORSOptions.AllowedOrigins` and `WSOptions.AllowedOrigins` accordingly. **WebSocket URLs.** A page served over `https` must dial `wss`, not `ws`. **The `Servers` list.** Point the generated document at the address clients actually use: ```go Servers: []muzak.Server{ {URL: "https://api.example.com", Description: "production"}, }, ``` ## Terminating TLS somewhere else A load balancer, an ingress controller or a reverse proxy commonly holds the certificate and speaks plain HTTP to the service behind it. Muzak then serves HTTP, and three things need saying explicitly: - **Name the proxy** in `ClientIPOptions.TrustedProxies`, or every request is attributed to the proxy's address rather than the client's. See [Behind a Proxy](/docs/deployment/behind-a-proxy). - **Keep cookies `Secure`** anyway. The browser leg of the connection is the one that matters, and it is HTTPS. - **Redirect plain HTTP** with `AppOptions.RedirectHTTPS`, if the proxy forwards it rather than redirecting itself. It believes the scheme a named proxy reports in `X-Forwarded-Proto`, answers `301` for GET and HEAD and `308` otherwise, and only ever names a host that `AppOptions.AllowedHosts` admitted or `RedirectHTTPSOptions.Host` gives. See [Allowed Hosts and HTTPS](/docs/security/hosts-and-https). ## HTTP/2 without TLS Over TLS, HTTP/2 is negotiated as usual. A platform that speaks HTTP/2 to the container in the clear, such as Cloud Run with an `h2c` container port, needs `ServerOptions.UnencryptedHTTP2`, which accepts HTTP/2 without TLS, sent with prior knowledge, on the same port as HTTP/1: ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", ServerOptions: muzak.ServerOptions{UnencryptedHTTP2: true}, }) ``` Leave it off anywhere the proxy speaks HTTP/1, and never for a server reachable directly from an untrusted network. A WebSocket handshake is not carried over HTTP/2 and is answered `426`. What was measured on Cloud Run, and why a request is cancelled the moment its client leaves over `h2c` but not over HTTP/1, is on [Running on Cloud Run](/docs/deployment/cloud-run). ## Strict transport security Muzak's `SecurityHeaders` middleware sets `X-Content-Type-Options`, `X-Frame-Options` and a referrer policy on every response. A response to a request that arrived over TLS also gets `Strict-Transport-Security: max-age=31536000`, which tells the browser to use HTTPS for this host for a year, so that a visitor's next request cannot be downgraded to plain HTTP on the way. It commits only the host that has just answered over TLS. It leaves out `includeSubDomains` and `preload`, which commit hosts this application may not serve and are hard to take back, and it is not sent for `localhost`, a `.localhost` name or an IP address, so a development server with a local certificate does not pin every other server on the developer's machine to HTTPS. A browser ignores it over plain HTTP, so it is not sent there, and behind a proxy that terminates TLS the request Muzak sees is plain, so the proxy is the place to set it. A value the handler sets replaces it. To add `includeSubDomains` once every subdomain is on HTTPS, set the header yourself. `Set` replaces the default: ```go func HSTS(maxAge time.Duration) muzak.Middleware { value := "max-age=" + strconv.Itoa(int(maxAge.Seconds())) + "; includeSubDomains" return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.TLS != nil { w.Header().Set("Strict-Transport-Security", value) } next.ServeHTTP(w, r) }) } } ``` ```go app.Use(HSTS(365 * 24 * time.Hour)) ``` The `r.TLS != nil` check keeps the header off a plain-HTTP response, where it means nothing and where a redirect, which `AppOptions.RedirectHTTPS` sends, is the more useful answer. Behind a terminating proxy the check has to read whatever header the proxy sets instead. A host that also serves plain HTTP on another port, or may stop serving HTTPS, takes the header off instead, because a browser pins the whole host along with it. Remove it in middleware installed with `App.Use`, which runs after `SecurityHeaders` has set it, or send `max-age=0`: ```go app.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // This host also serves plain HTTP on another port, which a pinned // browser could no longer reach. w.Header().Del("Strict-Transport-Security") next.ServeHTTP(w, r) }) }) ``` ## Development certificates ```bash go run filippo.io/mkcert@latest -install go run filippo.io/mkcert@latest localhost 127.0.0.1 ``` ```bash [.env] ADDR=:8443 TLS_CERT_FILE=./localhost+1.pem TLS_KEY_FILE=./localhost+1-key.pem ``` Tests need none of this. The test client serves the application on a loopback port in plain HTTP, whatever TLS is configured, so there is nothing to encrypt. See [Testing](/docs/fundamentals/testing). ## Where to go next [Behind a Proxy](/docs/deployment/behind-a-proxy) covers running with TLS terminated in front of you, and [Server Configuration](/docs/deployment/server-configuration) covers the rest of the listener. -------------------------------------------------------------------------------- title: "Allowed Hosts and HTTPS" description: "Refuse requests for hosts the application does not serve, and redirect plain HTTP to https without ever naming a host a client chose." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/security/hosts-and-https" -------------------------------------------------------------------------------- # Allowed Hosts and HTTPS The `Host` header is whatever the client wrote. An application that builds a password reset link from it, keys a cache on it, names it in a redirect, or listens on an address a DNS rebinding page can reach, is trusting a value anyone can set. `AppOptions.AllowedHosts` is the list of hosts the application answers to, and everything else is refused before any of the application runs. ::code-group ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: settings.AppName, // The names this service answers to. A request for any other host is // refused with 421 before a route, a mount or the documentation sees it. AllowedHosts: settings.AllowedHosts, // Plain HTTP is redirected to https on one of the names above. RedirectHTTPS: &muzak.RedirectHTTPSOptions{}, ClientIP: muzak.ClientIPOptions{TrustedProxies: settings.TrustedProxies}, }) ``` ```go [core/config.go] type Settings struct { // AllowedHosts lists the Host header values this service answers to. A // leading "*." allows every subdomain; an entry without a port allows // any port. AllowedHosts []string `env:"ALLOWED_HOSTS"` } ``` ```bash [.env] ALLOWED_HOSTS=example.com,*.example.com,localhost:8080 ``` :: ## How a host is matched | Entry | Admits | Refuses | |---|---|---| | `example.com` | `example.com`, `EXAMPLE.COM`, `example.com.`, `example.com:8080` | `www.example.com`, `example.com.evil.com`, `example.com@evil.com` | | `*.example.com` | `api.example.com`, `a.b.example.com:443` | `example.com`, `aexample.com`, `.example.com` | | `api.example.com:8443` | `api.example.com:8443` | `api.example.com`, `api.example.com:443` | | `[::1]` | `[::1]`, `[0:0::1]:8080` | `::1`, `[::1%25lo0]` | | `127.0.0.1` | `127.0.0.1`, `127.0.0.1:5000` | `127.1`, `2130706433`, `[::ffff:127.0.0.1]` | | `xn--bcher-kva.de` | `xn--bcher-kva.de`, `XN--BCHER-KVA.DE` | the same name in UTF-8 | - Case is ignored for **ASCII letters only**. A letter outside ASCII that Unicode folds to an ASCII one, the Kelvin sign to `k` or the long s to `s`, is never taken for it. - One trailing dot on the request's host, the fully qualified spelling, is ignored. - An entry without a port allows any port. An entry with a port allows that port only, so a request that names no port matches only an entry that names none. A request whose `Host` ends in an empty port, as in `example.com:`, names none. - A leading `*.` allows every subdomain, of one label or more, and **not** the domain itself. List the domain too when it is served. - An IPv6 address is written in brackets and compared in its canonical form, so every spelling of one address matches. A zone is never accepted. - An internationalized name is written in its punycode (`xn--`) form, which is what a client sends. - `X-Forwarded-Host`, `Forwarded: host=` and every other forwarding header are ignored, from any peer. Behind a proxy, list the `Host` the proxy passes on. An entry that can never match is a build error, with every bad entry reported at once: an empty one, or one with a scheme, a path, credentials, a query or fragment, whitespace, a `*` anywhere but a leading `*.`, a bare IPv6 address, a trailing colon with no port after it, a port of `0` or over `65535`, a wildcard in front of an address, a character outside ASCII, or anything else that is not a host name or an address. Surrounding whitespace is ignored, since a list read from the environment is usually split at commas. Unset, the default, no check is made and nothing is installed, so it costs nothing. ## The refusal A request for a host that is not listed is answered **421 Misdirected Request** through the error renderer, so it carries the error envelope (or a problem, with [Problem Details](/docs/techniques/problem-details) on), the request identifier, the security headers and `Cache-Control: no-store`: ```json { "error": { "code": "misdirected_request", "message": "This server does not serve the requested host.", "status": 421 }, "request_id": "0611f4b2-2f0a-7b57-9c1a-6e6a2e2f9b31" } ``` The host is not echoed. It is logged at warn level, on every refused request, quoted with every byte that is not printable ASCII escaped, so a header crafted to forge a log line records as inert text, and the access log records the `421`. A scanner that sends foreign hosts all day therefore writes a warning per request; filter the message in your log pipeline if that is noise to you. 421 rather than 400 because RFC 9110 defines it for exactly this: a request "directed at a server that is unable or unwilling to produce an authoritative response for the target URI". It is also the status an HTTP/2 or HTTP/3 client acts on. A browser that reused one connection for two hosts its certificate covers retries on a connection of its own when told 421, where a 400 would end the request as the client's fault. The check runs before anything else of the application: before the CORS policy, the cross-origin protection, the middleware installed with `App.Use`, routing, mounts, frontends and the documentation. It runs inside request identification, the security headers, the locale and the access log, so a refusal is recorded like any other response and translated like any other error. The message is translatable at `muzak.http.421`. The one thing answered ahead of it is a [health probe](/docs/deployment/health-checks). A platform probes a replica by its own address, with whatever `Host` that makes and over plain HTTP, so the health endpoints answer for any host and are never redirected; otherwise every probe would be refused and every replica taken out of rotation. They say only whether the process is up and ready, unless `HealthOptions.ReportChecks` adds the name and outcome of each check, which any page that reaches the port through DNS rebinding can then read too. Leave `ReportChecks` off where the port is reachable from outside the platform. Two things net/http settles before the application sees the request: - A request with an absolute target, `GET http://evil.com/ HTTP/1.1`, has its host taken from the target rather than the `Host` header, as RFC 9112 requires, and that is the host checked. - A request with two `Host` headers, or a `Host` holding a byte no host carries, such as one outside ASCII or the `@` of `example.com@evil.com`, is refused by net/http with `400` before any handler runs. The allowlist refuses the same values for a request that reaches `ServeHTTP` some other way. An HTTP/1.0 request with no `Host` at all reaches the application with an empty host, which no entry matches, so it is refused with `421`. The two host lists of a [WebSocket](/docs/realtime/websockets) are separate. A WebSocket route's own `WSOptions.AllowedHosts` still decides which handshakes count as same-origin, and is not filled in from `AppOptions.AllowedHosts`; with `AppOptions.AllowedHosts` set, a handshake for any host it does not list never reaches the route at all. ## Redirecting plain HTTP ```go app := muzak.New(muzak.AppOptions{ AllowedHosts: []string{"example.com", "www.example.com"}, RedirectHTTPS: &muzak.RedirectHTTPSOptions{}, }) ``` A request that arrived over plain HTTP is answered with a redirect to the same host, path and query over https: | Method | Status | Why | |---|---|---| | `GET`, `HEAD` | `301 Moved Permanently` | Understood by every client, and kept by a browser, so the next navigation goes to https without a plaintext round trip. | | everything else | `308 Permanent Redirect` | Keeps the method and the body, which a client following a 301 for a `POST` may turn into a `GET` without one. | The redirect has no body and sends no `Strict-Transport-Security`; `SecurityHeaders` sends that on responses served over TLS, which is where a browser honours it. A redirect is a convenience for navigation: the body of a request that was redirected has already crossed the network in the clear, and HSTS is what keeps a returning browser from sending it at all. ### Which host it names A redirect is only as trustworthy as the host it names, so it never names one the application has not vouched for: - With `AllowedHosts` set, the request's own host is kept, once the allowlist has admitted it, lowercased and without the trailing dot. A host the allowlist refuses is refused, never redirected. - `RedirectHTTPSOptions.Host` names one canonical host every redirect is sent to instead, which also lets the redirect be used without an allowlist. - With neither, enabling the redirect is a **build error**: a forged `Host` would otherwise make it a redirect to anywhere. The port the request named is never kept, because it is the one plain HTTP was served on. Set `Port` when https is served somewhere other than `443`: ```go muzak.RedirectHTTPSOptions{Port: 8443} // https://example.com:8443/... muzak.RedirectHTTPSOptions{Host: "www.example.com"} // https://www.example.com/... muzak.RedirectHTTPSOptions{Host: "www.example.com:8443"} // https://www.example.com:8443/... ``` `Host` is written as an `AllowedHosts` entry is, without a wildcard, since a redirect needs one host to send the client to. A port of `0` or `443` is left out of the URL. A `Host` that names a port while `Port` is set too, a wildcard `Host`, and a `Port` outside `0` to `65535` are build errors. ### The path and query The path and query are copied with every byte that is not plainly allowed in them percent-encoded: a control character, a space, a backslash, a quote or a byte outside ASCII never reaches the `Location` header, an escape the client wrote is kept as written, and a stray `%` becomes `%25`. A path that does not begin with `/`, the `*` of `OPTIONS *` say, is redirected to the root. Because the `Location` always names its host, a path beginning `//evil.com` or `/\evil.com` is a path on the vouched host and nothing else. ### Behind a proxy Behind a proxy that terminates TLS, every request arrives as plain HTTP, and only the proxy knows what the client used. A request counts as https when the peer that sent it is a proxy named in `ClientIPOptions.TrustedProxies` and that proxy says so: | `ClientIPOptions.Header` | The scheme is read from | |---|---| | unset, `X-Forwarded-For`, `X-Real-IP`, anything else | `X-Forwarded-Proto` | | `Forwarded` | the `proto` parameter of `Forwarded` | Only the value the trusted proxy itself wrote is read, which is the last one: the last comma-separated value of the last `X-Forwarded-Proto` line, or the `proto` of the last `Forwarded` entry. An earlier value may have come from the client through a proxy that appends rather than replaces, and a client that could claim https would have its plain request served, and kept by a cache, as though it were secure. From a peer that is not trusted, the header is ignored whatever it says. Responses whose treatment depended on the header carry it in `Vary`. The proxy has to set the header. One that does not, or a server listening on a Unix socket, where no peer address can be trusted, sees every request as plain and redirects every one of them. A chain of proxies has to pass the edge's value through rather than write its own hop's scheme. ### What is never redirected A request for an ACME HTTP-01 challenge, `/.well-known/acme-challenge/` followed by one token of base64url characters, is served over plain HTTP, because a certificate authority validates a host that has no certificate yet. Nothing that only begins like one is exempt: a second segment, a `..` or an encoded slash is redirected like any other path. The health endpoints are not redirected either, for the reason given [above](#the-refusal): a platform probes them over plain HTTP. ## Checking it works ```bash curl -si http://localhost:8080/ -H 'Host: evil.com' # 421 curl -si http://localhost:8080/a?b=1 -H 'Host: example.com' # 301 to https://example.com/a?b=1 curl -si http://localhost:8080/a -H 'Host: example.com' \ -H 'X-Forwarded-Proto: https' # 301 unless the peer is a trusted proxy ``` ## Where to go next [Behind a Proxy](/docs/deployment/behind-a-proxy) covers naming the proxy whose headers are believed, [TLS](/docs/security/tls) serving https directly, and [Health Checks](/docs/deployment/health-checks) the probes that are answered ahead of both checks. -------------------------------------------------------------------------------- title: "Sessions" description: "Encrypted cookie sessions or server-side stores, read only when a handler asks, written once when the request succeeds, and regenerated on sign-in so a planted session is worth nothing." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/security/sessions" -------------------------------------------------------------------------------- # Sessions A session is what a browser carries between requests once a user has signed in: a few values, usually just who the user is, that the server can trust because the client cannot forge them. `AppOptions.Sessions` turns them on, and `ctx.Session()` returns the request's session. Sessions are opt-in. An application that leaves the option unset pays nothing for it, and one that sets it still pays nothing on a request whose handler, guards and providers never call `ctx.Session()`: no cookie is parsed, nothing is decrypted, no store is asked and nothing is written. ## Configuring them ::code-group ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: settings.AppName, // Sessions, kept in a cookie encrypted under the configured secrets. Sessions: &muzak.SessionOptions{Secrets: settings.SessionSecrets}, // Required with sessions; see "Cross-origin protection is required" below. CrossOriginProtection: &muzak.CrossOriginOptions{}, }) ``` ```go [core/config.go] type Settings struct { // SessionSecrets are the keys the session cookie is encrypted under, as a // comma-separated list: the first encrypts and every one decrypts, which // is how a key is rotated. Each must be at least 32 random bytes. SessionSecrets []string `env:"SESSION_SECRETS" secret:"true"` } ``` :: Generate a secret with `openssl rand -base64 32` and keep it out of the source. Nothing else needs setting: every default below is the safe one. The example application gives `SESSION_SECRETS` a default, so that it runs as it is. A deployment that keeps that default has sessions anyone who has read the source can forge: set your own before you deploy it. | Field | Default | Effect | |---|---|---| | `Secrets` | none | The keys the cookie store encrypts with, each at least 32 bytes (`muzak.MinSessionSecretLength`). The first encrypts, all decrypt. Required by the cookie store, unused by a server-side store | | `Store` | the encrypted cookie | A `SessionStore` that keeps sessions on the server; the cookie then carries only a random identifier | | `Name` | `__Host-session` | The cookie's name; see [The cookie](#the-cookie) | | `Path` | `/` | The cookie's path. A narrower one is not a security boundary, and gives up the `__Host-` prefix | | `Domain` | none | Sends the cookie to every subdomain too, and gives up the `__Host-` prefix | | `SameSite` | `Lax` | `Strict` also withholds the cookie from links followed from other sites; `None` needs Secure | | `AllowInsecure` | off | Sends the cookie without Secure, for development on a host other than localhost | | `IdleTimeout` | 2 hours | Ends a session that has not been used for this long | | `MaxLifetime` | 24 hours | Ends a session this long after it began, however busy | | `MaxSize` | what fits in a cookie, or 16 KiB with a store | The largest encoded session; a change past it fails where it is made | | `StoreTimeout` | 2 seconds | Bounds each call to a `SessionStore` | Everything that cannot be served as written is a build error, reported with the others: no secret or a short one, a negative duration or size, an idle timeout longer than the lifetime, a `MaxSize` larger than a cookie can carry, a `Path` that does not begin with `/`, `SameSite=None` without Secure, `http.SameSiteDefaultMode` (which leaves the choice to each browser), a name that is not a token, a `__Host-` or `__Secure-` name whose attributes a browser would refuse, and a name, path and domain so long that the cookie has room for less than 256 bytes of session. No secret is ever quoted in the message. ## Signing in The example application signs in with a form posted to `/login/`, and signs out at `/logout/`: ::code-group ```go [handlers/login.go] func Login(ctx *muzak.Context, in schemas.LoginIn) (schemas.LoginOut, error) { expected, known := accounts[in.Username] matches := subtle.ConstantTimeCompare([]byte(expected), []byte(in.Password)) == 1 if !known || !matches { return schemas.LoginOut{}, muzak.Unauthorized("the username or password is incorrect") } session := ctx.Session() // Regenerating comes first, before anything that says who the user is. if err := session.Regenerate(); err != nil { return schemas.LoginOut{}, err } if err := session.Set("user", in.Username); err != nil { return schemas.LoginOut{}, err } return schemas.LoginOut{Username: in.Username, Next: in.Next}, nil } ``` ```go [routers/login.go] r.Post("/login/", handlers.Login, muzak.Summary("Exchange a username and password for a session"), muzak.WithResponseDoc(http.StatusUnauthorized, "The username or password is incorrect"), muzak.RateLimit(muzak.Quota{Name: "login", Window: time.Minute, Limit: 5})) r.Post("/logout/", handlers.Logout, muzak.Summary("End the session")) ``` :: ### Always regenerate on sign-in **Call `Regenerate` on every sign-in, before you set anything that says who the user is.** Without it, session fixation works: an attacker gets a session of their own, plants it in the victim's browser (through a sibling subdomain when the cookie names a `Domain`, through a page served over plain HTTP on the same host when it is not Secure, or through a cross-site scripting bug), and waits. When the victim signs in, the session the attacker already holds becomes the victim's signed-in session. `Regenerate` gives the values a new session, a new identifier with the old one deleted from a server-side store, and a new ciphertext and lifetime with the cookie store, so whatever the attacker holds is worthless at the moment it would have become valuable. The default `__Host-` cookie closes the first two routes; regenerating closes all of them. Call it whenever the privilege a session carries changes, such as stepping up to an administrator, as well. The session is not repeated in the response body. A credential in a body is one any script on the page can read, which is exactly what the cookie's `HttpOnly` attribute exists to prevent. ## Reading and writing values A session holds string keys and JSON values. `Set` encodes a value, and `muzak.SessionGet` decodes one into the type asked for: ```go func Profile(ctx *muzak.Context, _ muzak.Empty) (ProfileOut, error) { user, ok := muzak.SessionGet[string](ctx.Session(), "user") if !ok { return ProfileOut{}, muzak.Unauthorized("sign in first") } return loadProfile(ctx.Context(), user) } ``` | Call | Does | |---|---| | `muzak.SessionGet[T](s, key)` | The value under `key` decoded as `T`, and whether there was one. A value that does not decode as `T`, because an earlier release stored something else there, reads as absent | | `s.Has(key)` | Whether there is a value, without decoding it | | `s.Set(key, value)` | Stores `value` as JSON. Fails for an empty key, one that is not UTF-8, a value JSON cannot encode, a session that would grow past `MaxSize` (`muzak.ErrSessionTooLarge`) and a session whose store could not be read; the session is left as it was | | `s.Delete(key)` | Removes one value | | `s.Clear()` | Removes every value. An emptied session is removed from the browser rather than written empty | | `s.IsNew()` | Whether the request came without a valid session, or its session has been destroyed since, which is how a handler tells an anonymous visitor | | `s.Err()` | Why a server-side store could not be read; see [Server-side stores](#server-side-stores) | Keep a session small: an identifier and a preference or two. It travels with every request, and with the cookie store it is the cookie. A change that would make it too large is refused where it is made rather than silently truncated on the way to the browser, so the error reaches the code that caused it. A `*Session`, like the `*muzak.Context` it came from, is not safe for concurrent use and must not be used once the handler has returned. A session kept past its request refuses every change. ## Signing out ```go func Logout(ctx *muzak.Context, _ muzak.Empty) (muzak.Empty, error) { ctx.Session().Destroy() return muzak.Empty{}, nil } ``` `Destroy` removes every value, removes the cookie from the browser and, with a server-side store, deletes the session's entry, so a copy of the cookie taken earlier is worthless too. A value set afterwards in the same request begins a new session under a new identifier. The cookie store cannot revoke. Its session is the cookie, so a copy taken before `Destroy` ran, by malware or from a shared machine, stays valid until its idle timeout or lifetime ends it, and nothing the server holds can say otherwise. An application that must end a session everywhere, at sign-out, on a password change or when an account is suspended, wants a server-side store. ## When the session is written The session is written at most once per request, at one point: after the handler has returned, after every [dependency release](/docs/getting-started/dependencies) has run, and before the response is written. A transaction that fails to commit therefore takes the session change with it. What is written, and when: - **Only a request that succeeded.** A handler that returns an error, one that answers with a status of 400 or above, one that panics and one whose release fails write nothing, so a half finished request never leaves a half changed session. - **Only a session that changed, or is due for renewal.** Reading a session writes nothing. A session in use is renewed once a tenth of its idle timeout has passed since it was written, so a busy user's cookie is rewritten every twelve minutes at the default, not on every request. - **Never a session the request did not touch.** - **Once.** The response never carries two cookies for one session. `Save` writes the session immediately, for a change that must be kept whatever happens next, such as a count of failed sign-in attempts or a message for the next page: ```go attempts, _ := muzak.SessionGet[int](s, "attempts") _ = s.Set("attempts", attempts+1) if err := s.Save(); err != nil { return LoginOut{}, err } return LoginOut{}, muzak.Unauthorized("the username or password is incorrect") ``` A change made after `Save` is written when the request ends, if it succeeds, and replaces what `Save` wrote. A handler that writes its response itself, through `ctx.ResponseWriter()` or by returning a `muzak.Stream`, has its session written as the status line goes out, if that status is below 400, because a cookie cannot follow the first byte. A change made after that is not saved. An [event stream](/docs/realtime/server-sent-events) and a [WebSocket](/docs/realtime/websockets) begin their response before their handler runs, so a session changed there is not saved either; read the session in a guard and change it in an ordinary route. Either way the lost change is logged once as a warning naming the route, unless the response it would have gone out with had a status of 400 or above, which discards a change on purpose. `Save` after the response has started returns an error. A response that carries the session cookie is marked `Cache-Control: private, no-cache`, replacing a policy that would let a shared cache keep it, since a cache that stored it would hand one user's session to the next. A `Cache-Control` that already says `private` or `no-store` is left alone. This holds whatever sets `Cache-Control` after the cookie: a handler that sets a public policy after `Save`, an error renderer that lets an error be cached, or the file server dropping the header from a range it cannot serve (see [Files, Streams and Redirects](/docs/techniques/files-streams-redirects)). The response is made private again as its header goes out, and an event stream's `no-transform` is kept when it is. A response from a handler that read the session varies on `Cookie` and is marked the same way unless the handler set a `Cache-Control` of its own. An event stream and a WebSocket keep their own `Cache-Control` when a guard or provider reads the session. ## The cookie ``` Set-Cookie: __Host-session=AUYbFNGP...; Path=/; Max-Age=7200; HttpOnly; Secure; SameSite=Lax ``` - **`HttpOnly`** keeps it from scripts, so a cross-site scripting bug cannot read it. - **`Secure`** keeps it off plain HTTP, where anyone on the network can read it. Browsers accept a Secure cookie from `http://localhost`, so development needs nothing; `AllowInsecure` exists for another host over plain HTTP, and cannot be combined with `SameSite=None` or a prefixed name. - **`SameSite=Lax`** keeps it off requests other sites start, except a top-level navigation by `GET`. It does not keep it off a request from a sibling subdomain, which is the same site; that is what [cross-origin protection](/docs/security/cross-origin-requests) is for. - **The `__Host-` prefix** is the one most applications never think about. A browser accepts a cookie with that name only from a secure origin, only with `Path=/` and only without a `Domain`. A sibling subdomain, which can otherwise set a cookie for the whole domain, and a page served over plain HTTP on the same host, which can otherwise set a cookie of the same name, can set neither. That is what stops an attacker from planting a session of their choosing in the user's browser, or shadowing the real one with their own. - **`Max-Age`** is how long the session has left, the idle timeout or what remains of the lifetime. It only tells the browser when to drop the cookie: the timeouts themselves are kept inside the encrypted or stored session, never taken from the cookie, which the client controls. Setting `Domain`, or a `Path` other than `/`, gives up the `__Host-` prefix and the default name becomes `__Secure-session`; setting `AllowInsecure` gives up both prefixes and it becomes `session`. A name you choose is checked against the same rules, without regard to case, as browsers check them. ## The encrypted cookie store With no `Store`, the session is the cookie: its values and its two timestamps, encrypted and authenticated with AES-256-GCM and encoded as unpadded base64url. - Each secret is reduced to a key with HKDF-SHA256 when the application is built. - Each cookie draws a fresh random 192-bit nonce, from which HKDF derives an AES-256 key and a GCM nonce used for that cookie alone. AES-GCM with a random 96-bit nonce under one key is good for about 2^32 messages before a repeated nonce becomes a concern, and a repeated nonce lets an attacker forge cookies; a busy application renewing sessions could get there. A key per cookie removes the limit. - The cookie's name, the format's version and a label naming the purpose are bound as additional data, so a cookie cannot be decrypted under another name, by another use of the same secrets or as another version of the format. A cookie lifted from one application's `__Host-session` and presented to another as `__Host-admin` is refused, even with the same secrets. A cookie that was tampered with anywhere, truncated, extended, spelled differently, encrypted under a secret no longer listed, or past its idle timeout or lifetime reads as no session: the request proceeds signed out, and nothing is logged of the cookie or its contents. It is never an error page, because a client can send any cookie it likes and an error would only tell it which of them were interesting. ### Rotating a secret 1. Put the new secret first and keep the old one after it. New cookies are encrypted under the new one; existing cookies still open under the old one. 2. Wait for `MaxLifetime` to pass, 24 hours by default. No cookie encrypted under the old secret can still be valid. 3. Remove the old secret. Decrypting tries each secret in turn, so keep the list short. To invalidate every session at once, after a leak say, replace the secrets rather than adding to them. ### How much fits The cookie must stay within 4096 bytes, name and attributes included, which is the most every browser is sure to keep: one past it is dropped by some of them without a word. With the default name that leaves a little under 3 KB of encoded JSON. `MaxSize` defaults to that, and a larger value is a build error. An application that needs more wants a server-side store. ## Server-side stores ```go app := muzak.New(muzak.AppOptions{ Sessions: &muzak.SessionOptions{ Store: muzak.NewMemorySessionStore(muzak.MemorySessionOptions{MaxEntries: 50_000}), }, CrossOriginProtection: &muzak.CrossOriginOptions{}, }) ``` With a `Store`, the cookie carries only a random 256-bit identifier, and the store holds the session under the SHA-256 digest of that identifier, never the identifier itself. A backup or a dump of the store therefore holds no cookie anyone could present, and since nobody can choose a digest, how long a lookup takes says nothing about which identifiers exist. An identifier a client sends that the store does not know is never adopted: the session the request begins gets an identifier of the application's own. A cookie that cannot be an identifier this application issued is not looked up at all. `MemorySessionStore` keeps sessions in the process. It is for tests and for an application that runs as one process: sessions are not shared with a second replica, and a restart signs everyone out. It holds at most `MaxEntries` sessions (10,000 by default), discards the least recently used when full, which signs that user out, and discards an entry once its time has passed, a few at a time as it is used, so it starts no goroutine. Every operation is constant time. ### Writing a store ```go type SessionStore interface { Load(ctx context.Context, key string) (data []byte, found bool, err error) Create(ctx context.Context, key string, data []byte, ttl time.Duration) error Update(ctx context.Context, key string, data []byte, ttl time.Duration) (found bool, err error) Delete(ctx context.Context, key string) error } ``` The data is opaque, and carries its own timestamps, which are checked against the idle timeout and the lifetime whatever the store returns. The TTL is how long the store may keep the entry. There is no single `Save`, and that is deliberate. A request that read a session just before the user signed out in another tab would otherwise write the session back as it finished, and the sign-out would not stick. `Create` saves a new session, and `Update` saves only over one that still exists, reporting `false` otherwise. Against Redis that is `SET` with `NX` and with `XX`; against SQL, an `INSERT` and an `UPDATE` whose row count is checked: ```go func (s *RedisSessions) Create(ctx context.Context, key string, data []byte, ttl time.Duration) error { ok, err := s.client.SetNX(ctx, "session:"+key, data, ttl).Result() if err == nil && !ok { err = errors.New("session key already exists") } return err } func (s *RedisSessions) Update(ctx context.Context, key string, data []byte, ttl time.Duration) (bool, error) { return s.client.SetXX(ctx, "session:"+key, data, ttl).Result() } ``` Every call must return when its context ends; each is bounded by `StoreTimeout`, and the writes are not cancelled by a client hanging up, so a sign-out is not abandoned half way. A store that also implements `Lifecycle` is started with the application and stopped after it drains. A store that cannot be read does not sign the user out and then save over their session. The request proceeds with an empty session that refuses every change, `s.Err()` reports `muzak.ErrSessionUnavailable` wrapping the store's error, and the failure is logged without the identifier. `Destroy` still works: the cookie is removed and the deletion attempted. A handler that would rather fail than serve such a request signed out checks for it: ```go if err := ctx.Session().Err(); err != nil { return Out{}, err } ``` A store that fails a write fails the request with a `500`, as a release that fails would, and the work registered with `AfterResponse` does not run. A sign-out whose deletion fails still removes the browser's cookie, and still answers `500`, so the user knows the session may outlive it. ## Timeouts The idle timeout and the lifetime are both kept inside the session, as the time it began and the time it was last written, and both are checked every time it is read. - **`IdleTimeout`**, 2 hours by default, ends a session that has not been used for that long. Because a session in use is only rewritten every tenth of the timeout, it lapses between nine tenths of the timeout and the whole of it after its last use. - **`MaxLifetime`**, 24 hours by default, ends a session that long after it began, however busy. It is what bounds a stolen cookie that its thief keeps alive. `Regenerate` starts it again. A session that has expired reads as no session, and the session the request begins replaces it. ## Two requests, one session Two requests carrying the same session each get a copy, and whichever writes last decides what is kept, its cookie or its entry in the store. Neither ever writes half of one: a store must keep whole records, and `MemorySessionStore` does. With a server-side store, a request that finishes after another destroyed or regenerated the session finds it gone and writes nothing, neither the entry nor a cookie that would overwrite the browser's newer one, so a sign-out in one tab is not undone by a request from another (with the one exception under [Limits](#limits)): - **What it read goes too.** A `Save` that finds the session gone leaves it new and empty, so a value set later in the same request begins a new session holding nothing the ended one held. - **`Regenerate` checks first.** Regenerating creates a new entry rather than updating the old one, which would not show that the old one was gone, so the old entry is looked up before it is replaced. When another request has ended the session, nothing is written and the request succeeds without a session; when the store cannot answer, the request fails with `500`. ## Cross-origin protection is required A session cookie is an ambient credential: the browser attaches it to a request whichever page made it. `SameSite=Lax` keeps it off a form another site posts, but not off one posted from a sibling subdomain, and `SameSite=None` keeps it off nothing. Sessions are therefore a build error without `AppOptions.CrossOriginProtection`: ``` muzak: AppOptions.Sessions is set without AppOptions.CrossOriginProtection; a session cookie is sent with requests other pages make, so state-changing requests must be checked for where they came from. Set CrossOriginProtection: &muzak.CrossOriginOptions{}, ... ``` It is one line, it needs no token in any form, and it refuses nothing a same-origin page does. See [Cross-Origin Requests](/docs/security/cross-origin-requests). ## What a hostile client cannot do | Attempt | What happens | |---|---| | Edit, truncate or extend the cookie | Authenticated encryption refuses it; the request is anonymous | | Present a cookie from another application or another cookie name | The name is bound into the encryption; refused | | Keep using a cookie past its timeout, or set a longer `Max-Age` | The times are inside the authenticated session and checked on every read | | Plant a session before the victim signs in | `Regenerate` replaces it; `__Host-` stops subdomains and plain HTTP from planting one | | Invent an identifier for a server-side store | Never adopted; a new session gets the application's own identifier | | Read a stolen store dump for cookies | The store holds digests of identifiers, not identifiers | | Flood the server with sessions | Nothing is stored for a visitor until a handler sets a value; the memory store is bounded and evicts the least recently used | | Send a huge cookie | Refused unread past 4096 bytes; every check is linear in the cookie | | Get a session cached and served to others | A response carrying the cookie is `private`, and one that read the session varies on `Cookie` | | Forge a request from another site | Refused by cross-origin protection before anything runs | ## Limits - **One store, one application.** A server-side store holds each session under the digest of its identifier and nothing else: neither the application nor the cookie's name is part of the key. Two applications given one store, or two configurations with different cookie names, accept each other's sessions. Give each its own store, or its own key prefix in a store you write (the `"session:"` in the Redis example above is where it goes). - **`Regenerate` leaves a short race.** The old entry is looked up before it is replaced, and a sign-out that lands between the lookup and the replacement is undone. The window is as long as one call to the store. - **A client that hangs up during a load is logged at debug level.** Loading runs under the request's context, so a client that disconnects while the store is being read ends the load; that is logged at debug level, as a stream its client ended is, while a load that fails or outruns `StoreTimeout` is logged at error level as "the session store could not be read". ## Testing The test client's cookie jar plays the browser, and treats the loopback address it serves on as secure, as browsers treat localhost, so the default Secure `__Host-` cookie works unchanged: ```go func TestSignIn(t *testing.T) { client := testclient.New(t, buildApp()) // /me is served by the Profile handler above. client.Get("/me").AssertStatus(http.StatusUnauthorized) client.Post("/login/", testclient.Body("application/x-www-form-urlencoded", strings.NewReader("username=rick&password=wubbalubbadubdub"))). AssertStatus(http.StatusOK) client.Get("/me").AssertStatus(http.StatusOK) client.Post("/logout/").AssertStatus(http.StatusOK) client.Get("/me").AssertStatus(http.StatusUnauthorized) } ``` ## Where to go next [Cross-Origin Requests](/docs/security/cross-origin-requests) covers the protection sessions require, [Authentication](/docs/security/authentication) covers proving who a caller is in the first place, [Dependencies](/docs/getting-started/dependencies) covers the releases a session is written after, and [Cookies](/docs/techniques/cookies) covers cookies of your own. -------------------------------------------------------------------------------- title: "Cross-Origin Requests" description: "Refuse state-changing requests a browser sends from another origin, the shape of cross-site request forgery, with no tokens, rendered through your error format, and required wherever sessions are." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/security/cross-origin-requests" -------------------------------------------------------------------------------- # Cross-Origin Requests A browser attaches cookies to a request whichever page made it. A page on another site can submit a form to your application, or make a fetch, and the browser sends the user's session cookie with it: the request arrives authenticated, and nothing in the cookie says the user did not ask for it. That is cross-site request forgery, and a session alone cannot tell the difference. `AppOptions.CrossOriginProtection` can. It refuses a request other than `GET`, `HEAD` or `OPTIONS` that a browser says came from another origin, with `403`, before anything of the application runs. It is net/http's own `http.CrossOriginProtection`, which reads the headers every browser has sent since 2023, so there is no token to put in a form, no cookie to pair it with and nothing to forget on one route. ## Turning it on ```go app := muzak.New(muzak.AppOptions{ Title: settings.AppName, Sessions: &muzak.SessionOptions{Secrets: settings.SessionSecrets}, CrossOriginProtection: &muzak.CrossOriginOptions{}, }) ``` The zero value is the whole protection, and it refuses nothing a page on your own origin does. Sessions cannot be configured without it: a session cookie is exactly the ambient credential forgery exploits, so that combination is a build error rather than a warning nobody reads. An application that authenticates with cookies of its own wants it just as much, and that includes an API key that `APIKeyVerifier` reads from a cookie (see [Authentication](/docs/security/authentication)): the browser attaches it to a forged request as readily as a session. | Field | Default | Effect | |---|---|---| | `TrustedOrigins` | none | Origins allowed to send state-changing requests from elsewhere, such as your frontend on another origin | | `InsecureBypassPatterns` | none | `ServeMux` patterns exempt from the check, such as a webhook that verifies a signature | ## What is refused A browser says where a request came from in `Sec-Fetch-Site`. A browser too old to send it is judged by whether its `Origin` names the host the request was sent to. | Request | `Sec-Fetch-Site` | `Origin` | Outcome | |---|---|---|---| | `GET`, `HEAD`, `OPTIONS` | anything | anything | Allowed: these must never change state | | A form or fetch from your own pages | `same-origin` | your origin | Allowed | | Typed into the address bar, a bookmark | `none` | none | Allowed | | From a sibling subdomain | `same-site` | `https://other.example.com` | **Refused**: same site is not same origin | | From another site | `cross-site` | `https://evil.test` | **Refused** | | From another site, `Origin` withheld | `cross-site` | none | **Refused** | | An old browser, from another site | none | `https://evil.test` | **Refused** | | An old browser, a sandboxed frame | none | `null` | **Refused** | | An old browser, from your own pages | none | your host | Allowed | | curl, a mobile app, another server | none | none | Allowed: no browser attached a credential for it | | From a trusted origin | `cross-site` or `same-site` | a `TrustedOrigins` entry | Allowed | | Matching a bypass pattern | anything | anything | Allowed | A `same-site` request is refused deliberately. `SameSite=Lax` cookies are sent with it, so a compromised or user-content subdomain is exactly where a forgery against the main application comes from. A form served by your own application, as in [Forms and HTML](/docs/techniques/forms-and-html), posts as `same-origin` and passes, with no token in it. ## Trusted origins ```go CrossOriginProtection: &muzak.CrossOriginOptions{ TrustedOrigins: []string{"https://app.example.com", "http://localhost:5173"}, }, ``` An entry is compared exactly with the `Origin` header, which a browser writes as `scheme://host[:port]` in lower case without the scheme's default port. Entries are checked with the same rules as [CORS origins](/docs/security/cors), and one that could never match is a build error naming the spelling to use: a trailing slash or other path, upper case, a default port such as `:443`, or no scheme. A host outside ASCII is refused too, since a browser sends its punycode (`xn--`) spelling. A pattern such as `https://*.example.com` and `*` are refused, since this list matches whole origins only, and `null` is refused outright, because a sandboxed frame or a `file:` page sends it, so any page can. ## Bypass patterns ```go CrossOriginProtection: &muzak.CrossOriginOptions{ InsecureBypassPatterns: []string{ "POST /webhooks/{provider}", // verified by its signature, not a cookie "POST /payments/return", // a payment provider posts its form here }, }, ``` A bypassed route accepts a forged request from any page, so list only one that authenticates the request some other way, or that carries nothing worth forging. Patterns use [`http.ServeMux`](https://pkg.go.dev/net/http#hdr-Patterns) syntax and match a request only as `ServeMux` would match it directly: a path it would first clean or redirect, such as `/webhooks/../items`, is checked as usual. A pattern `ServeMux` refuses, two that conflict, and one whose method is `GET`, `HEAD` or `OPTIONS`, which are never refused and so bypass nothing, are build errors. **A pattern that ends in `/` matches everything beneath it**, as it does in `ServeMux`. `"POST /webhooks/"` exempts every path under `/webhooks/`, so such a pattern is warned of when the application is built; end it in `{$}`, as `"POST /webhooks/{$}"`, to match that one path. A pattern whose path is `/` alone, such as `"POST /"`, with or without a method or a host, is a build error, since it would turn the protection off for everything rather than exempt a route. Name each path in full, or end the pattern with `{$}`, as in `"POST /webhooks/{$}"`, to match that one path alone. ## The refusal A refusal is rendered by the application's error renderer like any other error: ```json { "error": { "code": "cross_origin_request", "message": "Cross-origin requests that change state are not accepted.", "status": 403 }, "request_id": "01a122b9-5eaf-7b37-93ff-ee7cc407d0a4" } ``` With [problem details](/docs/techniques/problem-details) it is a `Problem` whose `code` is `cross_origin_request` and whose type is `cross_origin_request`, or `about:blank` when no `TypeBase` is set. The code is `muzak.CodeCrossOriginRequest`, the message is translatable at `muzak.security.cross_origin`, and the response carries `Vary: Origin, Sec-Fetch-Site` and `Cache-Control: no-store`. Each refusal is logged at warn level with the method, the path, the request identifier and the two headers it was decided by, cut to a bounded length and escaped, so a forged header cannot forge a log line. ## Where it sits in the chain ``` RequestID -> tracing and Observer -> SecurityHeaders -> Recovery -> health probes -> Locale -> AccessLog -> AllowedHosts and RedirectHTTPS -> CORS -> cross-origin check -> your middleware -> /docs and /openapi.json -> routes and mounts ``` Tracing, the observer, health probes, `AllowedHosts` and `RedirectHTTPS` are in the chain only when they are configured. The check runs before anything that belongs to the application: no middleware installed with `Use`, no guard, provider, binder or handler, no mounted handler and no documentation sees a refused request. It runs inside CORS, so a refusal sent to an origin CORS allows carries the `Access-Control-Allow-Origin` that lets the page's script read why it was refused. It runs inside the access log, so a refusal is logged as a response like any other. Health probes are answered before it, and are `GET`s in any case. ## With CORS CORS and this check answer different questions. CORS decides which origins may **read** a response; this decides which may **change state**. An origin CORS allows is not trusted here by that alone, because letting another origin read your API and letting it act as your users are different decisions. A frontend served from its own origin, calling your API with credentials, is listed in both: ```go CORS: muzak.CORSOptions{ AllowedOrigins: []string{"https://app.example.com"}, AllowCredentials: true, }, CrossOriginProtection: &muzak.CrossOriginOptions{ TrustedOrigins: []string{"https://app.example.com"}, }, ``` A preflight is an `OPTIONS`, so it always passes this check and is answered by CORS. The request it announced is then checked as any other. ## With WebSockets A WebSocket handshake is a `GET`, so this check never refuses one. Cross-site WebSocket hijacking is the WebSocket check's to stop: `WSOptions.AllowedOrigins` refuses a handshake from another origin until it is listed. See [The origin check](/docs/realtime/websockets#the-origin-check). ## Safe methods must stay safe `GET`, `HEAD` and `OPTIONS` are never refused, because a page on another site can trigger them by a link or an image without any script at all, and refusing them would break navigation. That only protects you if those methods change nothing. A `GET /logout` or a `GET /delete?id=7` is a forgery waiting to happen, whatever this check does. Put every state change behind `POST`, `PUT`, `PATCH` or `DELETE`. ## Behind a proxy, and over plain HTTP Browsers send `Sec-Fetch-Site` only to a secure origin: `https`, or `localhost`. Over plain HTTP to any other host, and from a browser older than 2023, the check falls back to comparing the host in `Origin` with the request's `Host`. Two consequences follow: - A proxy that rewrites `Host`, to an internal name say, makes that fallback refuse your own pages. Pass the original `Host` through, which is also what [`AllowedHosts`](/docs/security/hosts-and-https) needs. - The fallback cannot see the scheme, so a request from `http://example.com` to `https://example.com` is let through by a browser that sends no `Sec-Fetch-Site`. Every current browser sends it over https, and `Strict-Transport-Security`, which `SecurityHeaders` sends over TLS, keeps the plain HTTP page from existing in the first place. ## Testing ```go func TestFormsFromOtherSitesAreRefused(t *testing.T) { client := testclient.New(t, buildApp()) client.Post("/logout/", testclient.Header("Origin", "https://evil.test"), testclient.Header("Sec-Fetch-Site", "cross-site"), ).AssertStatus(http.StatusForbidden).AssertErrorCode(muzak.CodeCrossOriginRequest) // The test client sends neither header, like any non-browser client. client.Post("/logout/").AssertStatus(http.StatusOK) } ``` ## Where to go next [Sessions](/docs/security/sessions) covers the cookie this protects, [CORS](/docs/security/cors) covers reading responses from another origin, [Problem Details](/docs/techniques/problem-details) covers the error format a refusal can take, and [Safe Defaults](/docs/security/safe-defaults) covers the rest of what is denied until it is configured. -------------------------------------------------------------------------------- title: "Server Configuration" description: "The listener, its timeouts, the run methods, and a shutdown that drains requests before it releases the resources they were using." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/deployment/server-configuration" -------------------------------------------------------------------------------- # Server Configuration `AppOptions` embeds `ServerOptions`, so the listener is configured in the same literal as everything else. ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Version: "1.0.0", Addr: settings.Addr, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, ShutdownTimeout: 15 * time.Second, MaxHeaderBytes: 64 << 10, }) ``` Every one of those already has that value by default. They are written out here to show what they are, not because they need setting. ## Timeouts | Field | Default | Bounds | |---|---|---| | `ReadHeaderTimeout` | 5s | How long a client may take to send the request headers | | `ReadTimeout` | 30s | How long a client may take to send the headers and body together | | `WriteTimeout` | 30s | How long a handler may take to write its response | | `IdleTimeout` | 120s | How long a keep-alive connection may sit unused before it is closed | | `ShutdownTimeout` | 15s | The one deadline for the whole shutdown: the drain delay, the drain, the background tasks and the lifecycle `Stop`s together | | `DrainDelay` | 0 | How long a shutdown keeps serving, with readiness reporting unavailable, before the listeners close. It counts against `ShutdownTimeout` | | `MaxHeaderBytes` | 64 KiB | The size of the request header block | Every timeout is non-zero on purpose. An `http.Server` left with the standard library's zero values holds a connection open indefinitely, which is all a slow-loris client needs to exhaust the connection budget. Setting one to a negative number disables it, which should be reserved for a server behind a proxy that enforces its own. These bound a connection, not the work a route does. A deadline that a route's guards, providers and handler share, answered with a `503` when it passes, is `muzak.Timeout`; see [Timeouts](/docs/techniques/timeouts). **`MaxHeaderBytes` is 64 KiB**, `muzak.DefaultMaxHeaderBytes`, where the default used to be 1 MiB. A client that never finishes a header block holds everything it has sent for as long as `ReadHeaderTimeout` allows, and with no cap on connections as a whole a megabyte each adds up. An ordinary request, with its cookies and a token, needs a small fraction of the new limit. Set `ServerOptions.MaxHeaderBytes` for a deployment that sends larger headers. **Over HTTP/2, `WriteTimeout` also bounds how long the connection's socket may accept no bytes at all.** A write deadline on one HTTP/2 stream cannot interrupt a frame already being written, so a client that granted a large flow-control window and then stopped reading the socket kept every stream on its connection open past every timeout, event streams included, and a shutdown ran to its full timeout. The same client over HTTP/1 was cut in under a second. The server sets `http.HTTP2Config.WriteByteTimeout` from `WriteTimeout`, so a connection whose socket accepts no bytes for that long is closed, and a disabled `WriteTimeout` disables it too. **`ReadHeaderTimeout` does not apply to HTTP/2.** `net/http` reads an HTTP/2 header block under the connection's own timeouts, so a client that never finishes one is held until `IdleTimeout` (2 minutes by default), not for 5 seconds. A declared `Content-Length` over a route's body limit is refused before any of the body is read, whatever `ReadTimeout` is; see [Request Data](/docs/getting-started/request-data#body-size-limits). WebSocket routes and event streams manage their own deadlines. A stream is a response that does not end, so the listener's write deadline is cleared for it and replaced with a deadline that exists only while a write is in progress; the read deadline goes too, once the request body has been read, since it would cancel the request and blame the client. See [Server-Sent Events](/docs/realtime/server-sent-events). ## TLS and HTTP/2 options | Field | Effect | |---|---| | `TLSConfig` | Serve HTTPS from a `*tls.Config` | | `CertFile`, `KeyFile` | Serve HTTPS from a certificate and key on disk. Setting only one of them is a build error | | `UnencryptedHTTP2` | Also accept HTTP/2 without TLS, sent with prior knowledge, on the same port as HTTP/1 | `UnencryptedHTTP2` is for a server behind a platform that speaks HTTP/2 to the container in the clear, such as Cloud Run with an `h2c` port, where a request is cancelled the moment its client leaves. Over HTTP/1 the platform's proxy keeps the connection to the container open and a handler runs on. Leave it off anywhere the proxy speaks HTTP/1, and never for a server reachable directly from an untrusted network. A WebSocket handshake is not carried over HTTP/2 and is answered `426`. See [Running on Cloud Run](/docs/deployment/cloud-run) and [TLS](/docs/security/tls). ## The listen address ```go Addr: ":8080" // the default Addr: "127.0.0.1:8080" // loopback only Addr: ":0" // any free port, which is what a test wants ``` `app.Addr()` reports the address actually being listened on, which is how a test that asked for `:0` discovers the port it was given. It returns an empty string before the server has started. `app.Config()` returns the fully defaulted options the application was built with, which is the reliable way to read a value after `New` has filled in the blanks. ## Running | Method | Stops when | |---|---| | `Run()` | The server fails, or `Shutdown` is called from elsewhere | | `RunContext(ctx)` | `ctx` is cancelled, or the server fails | | `RunSignals()` | `SIGINT` or `SIGTERM` arrives, or the server fails | ```go [cmd/main.go] if err := app.RunSignals(); err != nil { log.Fatal(err) } ``` `RunSignals` is what a `main` function usually wants: `SIGINT` and `SIGTERM` are what a terminal, a container runtime and an init system all send to ask a process to stop. All three build the application first, so a configuration error is reported before any socket is opened, then start the lifecycle components, then listen. A shutdown asked for returns nil, because stopping on request is the expected outcome rather than a failure. An `App` is run by one run method at a time. A second call while one is running, starting or shutting down returns an error at once and touches nothing the first one uses. Once a run has returned, the application may be run again. To serve a second address, build a second `App`. ``` 14:32:07.482 INFO [Server] Starting Muzak application... 14:32:07.483 INFO [Server] Starting 2 lifecycle components in parallel: item-store, ml-model 14:32:07.501 INFO [Server] Started "item-store" (18ms) 14:32:07.512 INFO [Server] Started "ml-model" (29ms) 14:32:07.513 INFO [Server] All lifecycle components ready (29ms total) 14:32:07.514 INFO [Server] Listening on :8080 ``` `RunContext` is the one to reach for when something else decides the process should stop: ```go ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() if err := app.RunContext(ctx); err != nil { log.Fatal(err) } ``` ## Shutting down ```go if err := app.Shutdown(context.Background()); err != nil { log.Printf("shutdown: %v", err) } ``` The sequence is the one that matters: 1. Readiness turns unready. With [health checks](/docs/deployment/health-checks) enabled, the readiness endpoint answers `503` from this moment, while every request is still served, so a load balancer that probes it stops sending traffic. 2. The server keeps serving for `ServerOptions.DrainDelay`, zero by default, which is the time that load balancer needs to notice. Set it instead of a `sleep` in a `preStop` hook. 3. The server stops accepting new connections and drains its in-flight requests, giving up at the deadline and closing whatever remains. At the same time, 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. From the moment the listeners close, `Context.AfterResponse` refuses a new task with `ErrBackgroundShuttingDown`, even from a request still being drained. 4. The [background tasks](/docs/techniques/background-tasks) that handlers registered with `Context.AfterResponse` are run to the end. At the deadline the ones still queued are dropped and the ones running have their context cancelled. 5. The lifecycle components are stopped, with what remains of the deadline, or one second if everything before used all of it. `ShutdownTimeout` is **one deadline for the whole shutdown**, counted from the call, and the drain delay comes out of it rather than being added to it; a `DrainDelay` that leaves no time to drain, or a negative one, is a build error. The WebSocket, event stream and HTTP drains used to take a full timeout each, and `Stop` had no deadline and could run under live handlers. When the deadline passes, every connection still open is closed, hijacked ones included, and handlers get a further 100 milliseconds to notice and return. `Shutdown` returns within `ShutdownTimeout` plus about one second, unless a component's `Stop` ignores its context, and `Run` returns once `Shutdown` has finished, after which the application may be run again. Make `Stop` honour its context, or raise `ShutdownTimeout`. Components stay usable until the very end, because pulling a database connection out from under a request that is still running, or a background task that is still sending an email, would turn an orderly shutdown into a burst of errors. `Shutdown` is safe to call more than once and from more than one goroutine; only the first call does the work. The context argument can cut the wait short; pass `context.Background()` to use the configured timeout alone. Called when no run is in progress, `Shutdown` returns nil and is kept for the next run, which returns nil at once without serving, as `ListenAndServe` returns `ErrServerClosed` after `Shutdown`. That is what makes `go app.Run()` followed by `app.Shutdown(ctx)` reliable, even when the goroutine has not reached `Run` yet. Only that next run is stopped, so a `Shutdown` made after a run has already returned stops the next one too, and code that runs the application again should not shut it down twice in between. ``` 14:32:41.902 INFO [Server] Shutting down, waiting for in-flight requests... 14:32:41.903 INFO [Server] Closed 3 websocket connections 14:32:41.911 INFO [Server] Stopping 2 lifecycle components 14:32:41.913 INFO [Server] Stopped "ml-model" (2ms) 14:32:41.918 INFO [Server] Stopped "item-store" (5ms) 14:32:41.918 INFO [Server] Stopped ``` Give the orchestrator more grace than `ShutdownTimeout`, or it kills the process part way through the drain. The default of 15 seconds is longer than some platforms allow, so this is a setting to check rather than inherit (Cloud Run has [a page of its own](/docs/deployment/cloud-run)): | Platform | Grace after `SIGTERM` | Where it is set | |---|---|---| | Cloud Run | about 10s | fixed, so `ShutdownTimeout` must come down | | Kubernetes | 30s | `terminationGracePeriodSeconds` | | ECS | 30s | `stopTimeout` | Leave room after the drain for anything the application flushes on the way out. A service that publishes to a message broker has to get its buffered messages away once the last request is answered, and that time comes out of the same grace period. ## Serving it yourself `App` implements `http.Handler`, so it can be mounted inside a server you build. ```go if err := app.Build(); err != nil { return err } if err := app.StartLifecycle(ctx); err != nil { return err } defer app.StopLifecycle(context.Background()) server := &http.Server{ Addr: ":8080", Handler: app, ReadHeaderTimeout: 5 * time.Second, } return server.ListenAndServe() ``` Three things become yours when you do that: the timeouts, which Muzak would otherwise have set for you, the lifecycle, which nothing else will start, and the shutdown sequence above, which `App.Shutdown` alone runs. Without it, readiness reports unavailable only once `StopLifecycle` is called, there is no `DrainDelay`, and tasks registered with `Context.AfterResponse` still run but nothing waits for them before the components stop. A build failure is reported as a `500` for every request with the reason logged once, which is why `Build` is worth calling first. The `500` is the default error envelope, or a problem under `AppOptions.ProblemDetails`, in fixed English: a custom `ErrorRenderer` and the translations are not used for it. The same shape mounts the application under a path in a larger server: ```go mux := http.NewServeMux() mux.Handle("/api/", http.StripPrefix("/api", app)) ``` Mounting under a prefix with `muzak.WithPrefix` is usually better, because the generated document then carries the real paths. Behind `http.StripPrefix`, a lookup path that does not begin with `/` matches nothing: the router used to drop the first byte of one, so a request for `/apiXadmin/panel` was served as `/admin/panel`, and a firewall or cache keyed on the URL saw a different path from the one the application routed on. ## A base context ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", BaseContext: func(l net.Listener) context.Context { return context.WithValue(context.Background(), tenantKey{}, tenant) }, }) ``` Every request context derives from what this returns. When it is nil, requests derive from `context.Background`. ## Checking the configuration before deploying ```go func TestAppBuilds(t *testing.T) { if err := buildApp().Build(); err != nil { t.Fatalf("Build = %v", err) } } ``` `Build` reports every problem at once: duplicate routes, unbindable input types, path parameters no field binds or declared twice, duplicate operation identifiers, malformed prefixes, a `MaxConnections` set anywhere but the application, a missing static directory, two mounts at one path, a CORS origin that could never match or is `"null"`, a body type the JSON decoder refuses, a certificate without its key, a `Dep` field no provider fills, sessions without cross-origin protection, a health path a route already answers, and a `DrainDelay` that leaves no time to drain. One test keeps all of them out of a deployment. Configuration calls made after the first build (`Use`, `Options`, `Include`, route and mount registration) panic, naming the call. See [Routers](/docs/getting-started/routers#options-applied-after-creation). ## In production A short list of things worth deciding before the first deployment: ```go app := muzak.New(muzak.AppOptions{ Title: settings.AppName, Version: buildVersion, Addr: settings.Addr, // A generated client should point at the real host. Servers: []muzak.Server{{URL: "https://api.example.com", Description: "production"}}, // Behind a load balancer, or every request is attributed to the balancer. ClientIP: muzak.ClientIPOptions{TrustedProxies: settings.TrustedProxies}, // Liveness at /livez and readiness at /readyz, access-logged at debug level // so a platform probing every second does not drown out real traffic. Health: muzak.HealthOptions{Enabled: true}, // Keep serving, with readiness down, while the load balancer notices. DrainDelay: 5 * time.Second, }) ``` - **Logging** picks JSON on its own when the output is not a terminal, so a container gets parseable logs with no configuration. Force it with `LoggerOptions{Format: muzak.LogFormatJSON}` if something in between confuses the detection. - **A rate limit policy**, with a shared storage if the service runs more than once. See [Rate Limiting](/docs/techniques/rate-limiting). - **A `MaxLifetime`** on the WebSocket and event stream routes a client you do not control can open, so a peer that answers every ping and says nothing cannot hold a slot until the server stops. See [WebSockets](/docs/realtime/websockets#ending-a-connection-on-purpose) and [Server-Sent Events](/docs/realtime/server-sent-events#ending-a-stream-on-purpose). - **`AppOptions.AllowedHosts`**, the names the service answers to. A request for any other host, a WebSocket handshake included, is refused with `421` before anything else runs, which also stops a DNS-rebinding page aimed at a server on a loopback or private address. `WSOptions.AllowedHosts` is the narrower check, for one route's WebSocket handshakes alone. See [Allowed Hosts and HTTPS](/docs/security/hosts-and-https). - **`Cache-Control`** on anything public served from behind a guard, which is otherwise sent `private, no-cache`. - **`DisableDocs`** if the deployment must not describe itself, which stops the document as well as any page. Leaving `DocsUI` unset already means no page is served; this is for the document too. It is worth generating in CI either way, so a breaking change shows up in a diff. See [OpenAPI](/docs/fundamentals/openapi). - **Health checks** for the platform to probe. `AppOptions.Health` answers `/livez` and `/readyz` ahead of routing, out of the document and clear of `App.Use` middleware, guards and the rate limit, and readiness reports unavailable as soon as a shutdown begins. A readiness check such as a database ping goes in `HealthOptions.Checks`. See [Health Checks](/docs/deployment/health-checks). ## Building the binary ```bash go build -o awesome-api ./cmd ``` ```dockerfile [Dockerfile] FROM golang:1.27 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /out/awesome-api ./cmd FROM gcr.io/distroless/static-debian12 COPY --from=build /out/awesome-api /awesome-api EXPOSE 8080 ENTRYPOINT ["/awesome-api"] ``` Muzak has no third-party dependencies, so `CGO_ENABLED=0` and a static base image are enough. A frontend embedded with `//go:embed` travels inside the same binary, so the image holds one file. See [Static Files and Frontends](/docs/techniques/static-files). Make sure the runtime forwards `SIGTERM` to the process rather than to a shell wrapper, or `RunSignals` never sees it and the drain never happens. ## Where to go next [Behind a Proxy](/docs/deployment/behind-a-proxy) covers what changes when something sits in front of the service, and [Lifecycle](/docs/getting-started/lifecycle) covers the components start-up and shutdown are sequencing. -------------------------------------------------------------------------------- title: "Behind a Proxy" description: "Which address a request is attributed to, why no forwarding header is believed until a proxy is named, and what else changes with a load balancer in front." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/deployment/behind-a-proxy" -------------------------------------------------------------------------------- # Behind a Proxy Muzak attributes a request to the peer that opened the connection, and ignores every forwarding header. That is the only safe default: a forwarding header is a request header like any other, and a server that believes one without knowing who wrote it lets any client claim any address it likes. Behind a proxy the default is wrong in the other direction, since every request then appears to come from the proxy. Naming the proxy is what makes the header believable. ## Naming the proxy ::code-group ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: settings.AppName, Addr: settings.Addr, // Which address a request is attributed to. Nothing is believed from a // header until the proxy that wrote it is named here. ClientIP: muzak.ClientIPOptions{TrustedProxies: settings.TrustedProxies}, }) ``` ```go [core/config.go] type Settings struct { // TrustedProxies lists the proxies whose X-Forwarded-For header is // believed, as a comma-separated list of addresses or CIDR prefixes. It is // empty by default, so every request is attributed to the peer that made // it: a forwarding header is written by whatever sent the request, and // believing one from an unknown sender hands every client the ability to // choose which budget it spends. TrustedProxies []string `env:"TRUSTED_PROXIES"` } ``` ```bash [.env] # Proxies whose X-Forwarded-For header is believed, as a comma-separated list of # addresses or CIDR prefixes. Leave it empty when nothing sits in front of this # service. TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12 ``` :: Entries are plain addresses (`10.1.2.3`) or CIDR prefixes (`10.0.0.0/8`), and both address families are accepted. An entry that cannot be parsed is reported when the application is built, rather than quietly widening or narrowing the policy. A prefix written in the IPv4-in-IPv6 form, such as `::ffff:10.0.0.0/104`, means the IPv4 prefix it wraps (`10.0.0.0/8`). It used to parse and then match nothing, leaving the proxy it named untrusted without a message. The header is consulted **only** for a request whose peer is trusted. A request arriving directly from the internet with an `X-Forwarded-For` of its own choosing is attributed to the address it actually came from. ## How the header is read The walk goes **right to left** through the forwarding header, which is the only direction that cannot be lied to. Each hop appends the address it saw, so the rightmost entries were written by the proxies nearest this server, and the leftmost by whoever sent the request, who may have invented them. Each trusted hop is stepped over, and the first entry that is not a trusted proxy is the client. If every hop was trusted, the leftmost entry is as close to the client as this server can see. - A request arriving from an untrusted peer is attributed to that peer, whatever the header says. - An entry that cannot be read (an obfuscated identifier, a mangled address) stops the walk: it hides whatever is to its left, so the nearest hop that was understood is the most that can be claimed. A port after an address, and the brackets IPv6 needs when there is one, are tolerated. - At most 64 entries are read, so a header of ten thousand addresses is not parsed in full on the way to deciding who sent it. - The address is normalised: an IPv4-in-IPv6 address is the IPv4 it carries, and an IPv6 **zone** (`fe80::1%eth0`) is dropped. A zoned address was never inside a trusted prefix, so a trusted proxy that reported itself with a zone was taken for the client, and one client could mint any number of keys by appending `%a`, `%b` and so on to its own address. ## A header other than X-Forwarded-For ```go muzak.ClientIPOptions{ TrustedProxies: []string{"10.0.0.0/8"}, Header: "CF-Connecting-IP", } ``` `DefaultForwardedHeader` is `X-Forwarded-For`. Set `Header` to whatever your proxy actually writes, such as `CF-Connecting-IP` or `X-Real-IP`. ### The standard `Forwarded` header ```go muzak.ClientIPOptions{ TrustedProxies: []string{"10.0.0.0/8"}, Header: "Forwarded", } ``` `Forwarded` is read as RFC 7239 defines it: the address is the `for` parameter of each entry, as in `for=203.0.113.9;proto=https` or `for="[2001:db8::1]:443"`, and the other parameters are skipped. Parameter names in any case, quoted strings, several entries and several header lines are all read, and the walk is the same right-to-left one, under the same trust rule, as for `X-Forwarded-For`. An entry whose `for` is `unknown` or an obfuscated identifier such as `_hidden`, or that has no `for` at all, stops the walk at the nearest trusted hop. A comma or semicolon inside a quoted parameter is data, so a proxy that quotes a client's `Host` into its own entry cannot be talked into a client's `for`. Before 0.2.9 the header was accepted but its entries could not be read, so every client behind the proxy resolved to the proxy and shared one budget. ## Reading the address ```go ctx.ClientIP() // the address as a string, normalised ctx.ClientAddr() // the same as a net/netip.Addr, for comparing against a prefix ``` ```go addr := ctx.ClientAddr() if !addr.IsValid() { // The connection has no address that can be parsed, which happens on a // listener that is not addressed by IP, such as a Unix socket. } if office.Contains(addr) { // ... } ``` The result is normalised, so an address written in IPv4-in-IPv6 form and the same address written plainly are one value rather than two. That is what stops a client from being counted twice, or from evading a count, by rewriting its own address. `ClientIP` returns the empty string when the connection has no parsable address. Code that keys on the result has to handle that, which is what `muzak.IPTracker` does by refusing the request rather than counting every such request under one shared key. ## What depends on getting this right | Feature | Uses the client address for | |---|---| | Rate limiting | `IPTracker` and `IPPrefixTracker` key the budget on it | | WebSockets | `MaxConnectionsPerIP` bounds one client's share of the process | | Server-sent events | `MaxStreamsPerIP` does the same for streams | | IPv6 grouping | `ConnectionIPv6Prefix` (a /56 by default) decides how many addresses count as one client, for the connection caps and for `IPTracker` | | Anything you write | Deny lists, audit logs, geo decisions | With an untrusted proxy in front and nothing named in `TrustedProxies`, every request carries the proxy's address, so the whole world shares one rate limit budget and one connection allowance. With a *wrongly* trusted header, any client picks its own budget by writing a header. Both failure modes are why this is configuration rather than a guess. ## Rate limiting behind a proxy Once the address is right, the tracker follows. The default `IPTracker` counts an IPv4 address exactly and an IPv6 address by its **/56**, the block a provider delegates to one home or small site, so a client that rotates addresses inside its own range still spends one budget. ```go muzak.WithRateLimit(muzak.RateLimitOptions{ Quotas: []muzak.Quota{ {Name: "short", Window: time.Second, Limit: 3}, {Name: "long", Window: time.Minute, Limit: 100}, }, }) ``` `ClientIPOptions.ConnectionIPv6Prefix` changes the prefix for the tracker and for the per-client connection caps together. Set 64 where many unrelated users share one /56, which also loosens the connection caps, or 48 where sites are delegated a /48. For one route, `IPPrefixTracker(32, 64)` keeps IPv4 exact and collapses IPv6 to the length you give it. A proxy that already bounds per-address traffic can set `Tracker: IPPrefixTracker(32, 128)` to count every IPv6 address on its own. See [Rate Limiting](/docs/techniques/rate-limiting). A service running more than once needs a storage the processes share, or a limit of a hundred a minute becomes a hundred a minute per process. ## TLS terminated in front Common, and it changes four things: - The service speaks plain HTTP, so `CertFile`, `KeyFile` and `TLSConfig` stay empty. - Cookies still need `Secure: true`, because the browser leg of the connection is HTTPS and that is the leg the attribute governs. - `SecurityHeaders` sends `Strict-Transport-Security` only when `r.TLS != nil`, so behind a proxy that terminates TLS it is not sent: the request Muzak sees is not the encrypted one. Set it at the proxy, or in middleware gated on whatever header the proxy writes. - `AppOptions.RedirectHTTPS` cannot see the client's scheme either, so it believes the `X-Forwarded-Proto` of a proxy that `TrustedProxies` names (or the `proto` of `Forwarded`, when that is `ClientIPOptions.Header`) and ignores the claim from any other peer. `AppOptions.AllowedHosts` never reads `X-Forwarded-Host`, so list the `Host` the proxy passes on. See [TLS](/docs/security/tls) and [Allowed Hosts and HTTPS](/docs/security/hosts-and-https). ## Streaming through a proxy An event stream is the request most likely to be broken by something in the middle. Muzak sets `Cache-Control: no-cache, no-transform` and `X-Accel-Buffering: no`, and writes a keepalive comment every `SSEOptions.KeepAlive`, so a proxy does not close a connection it believes to be idle. Check the proxy's own settings too. `proxy_buffering off` and a read timeout longer than the keepalive are what nginx needs; the equivalents exist elsewhere. A WebSocket needs the proxy configured to upgrade the connection at all. A keepalive arrives every interval since 0.2.8. Before, it could arrive at 15, 45 and 60 seconds when asked for every 15, twice the silence an idle timeout was meant to be kept off with. And a stream that a proxy would otherwise hold open for as long as its client keeps reading can be given a `MaxLifetime`, so a client that reads nothing is not held until the server stops. See [Server-Sent Events](/docs/realtime/server-sent-events#ending-a-stream-on-purpose). For one platform's measured behaviour, including the address its proxy presents and what changes between HTTP/1 and `h2c`, see [Running on Cloud Run](/docs/deployment/cloud-run). ## Request identifiers Muzak generates an identifier per request and ignores an inbound `X-Request-Id`, because an attacker-controlled identifier is an attacker-controlled log field. Where a trusted proxy assigns the identifier and you want the two logs to agree, accept it: ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", TrustRequestIDHeader: true, }) ``` 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. Turn it on only where nothing untrusted can reach the service directly, since the header would otherwise come straight from the client. ## Trace context With tracing on, a request's `traceparent` is the same question asked of a trace: by default any valid one is continued, sampling decision included, so a client can attach its requests to another trace or have every one of them sampled. For a service the internet reaches, set `TracingOptions.Parent` to `muzak.TraceParentFromTrustedProxies`, which continues a trace only for a connection from an address in `ClientIPOptions.TrustedProxies`: ```go app := muzak.New(muzak.AppOptions{ Title: "Awesome API", ClientIP: muzak.ClientIPOptions{TrustedProxies: settings.TrustedProxies}, Tracing: muzak.TracingOptions{ Tracer: exporter, Parent: muzak.TraceParentFromTrustedProxies, }, }) ``` That is only as good as the proxy. One that passes a client's `traceparent` on unchanged, as many load balancers do, passes the client's choice on with it, so the proxy has to replace or remove the header; where it cannot, use `muzak.TraceParentIgnore`, which always starts a new trace. See [Observability](/docs/fundamentals/observability). ## Body limits in two places The proxy has its own body limit, and so does Muzak. Set the proxy's at or above the route's, or a large upload is refused with the proxy's error page rather than the application's `413` and its error envelope. See [File Uploads](/docs/techniques/file-uploads). Muzak refuses a body whose declared `Content-Length` is over the route's limit before it reads any of it. A proxy that holds the whole request until it is complete, as Cloud Run does over HTTP/1, still takes the upload first: the early refusal cannot spare bytes a proxy has already buffered. ## Checking it works ```bash curl -s http://localhost:8080/whoami -H 'X-Forwarded-For: 203.0.113.9' ``` ```go type WhoAmIOut struct { ClientIP string `json:"client_ip"` } r.Get("/whoami", func(ctx *muzak.Context, _ muzak.Empty) (WhoAmIOut, error) { return WhoAmIOut{ClientIP: ctx.ClientIP()}, nil }, muzak.Hidden()) ``` Called directly, that answers with your own address whatever the header says. Called through a proxy named in `TrustedProxies`, it answers with what the proxy reported. Those two results are the whole configuration, and they are worth checking once per deployment. Remove the route afterwards, or leave it `Hidden` and behind a guard. ## Where to go next [Server Configuration](/docs/deployment/server-configuration) covers the listener itself, and [Rate Limiting](/docs/techniques/rate-limiting) covers the feature that depends on this most. -------------------------------------------------------------------------------- title: "Running on Cloud Run" description: "What was measured on Cloud Run rather than read from its documentation - the address its proxy presents, HTTP/1 against h2c, timeouts, streams and rollouts - and what was not established." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/deployment/cloud-run" -------------------------------------------------------------------------------- # Running on Cloud Run What follows was measured, not read from documentation. A test application built from the framework was deployed to Cloud Run (us-central1, private services, minimum instances 0) on 2026-09-29, once on an HTTP/1 container port and once on an `h2c` container port, and driven from outside with real clients. Each statement names what was observed. Where something was not established, it says so, and the last section lists what. ## Client addresses: trust the proxy or every client is one client The address the application sees as the peer is a link-local one, `169.254.169.126`, for every request. It is Cloud Run's proxy, not the client. With the default `ClientIPOptions`, which trusts no proxy, `Context.ClientIP()` is therefore that address for every user, and everything keyed on it (a rate limit, `MaxConnectionsPerIP`, `MaxStreamsPerIP`) treats all users as one. Set the range: ```go muzak.AppOptions{ ClientIP: muzak.ClientIPOptions{TrustedProxies: []string{"169.254.0.0/16"}}, } ``` With it set, `ClientIP()` was the caller's real address on every request, including ones that arrived with a spoofed `X-Forwarded-For` of one, two or three invented addresses in front of the real one: the proxy appends the real address last, and the framework reads from the right, so the invented ones were ignored. `Forwarded: for="..."` carries the real address too. See [Behind a Proxy](/docs/deployment/behind-a-proxy) for how the header is read and what else depends on the address. ## HTTP/1 or h2c on the container port Cloud Run speaks HTTP/1 to the container unless the port is named `h2c`. The two behaved differently in ways that matter: | | HTTP/1 port | `h2c` port | |---|---|---| | Request body | held by the platform until it is complete; over 32 MiB it is refused by the platform with an HTML 413 before the application sees it | streamed; 33 MiB reached the handler and was answered by it | | Body over the route's `MaxBodySize`, declared in `Content-Length` | refused by the framework in 390 ms for 64 KiB bodies, but a large declared body is held by the platform first: with `Expect: 100-continue` the platform answered `100 Continue` itself within 50 ms and then waited for the whole body, so the framework's early refusal could not spare the upload | refused by the framework after 196 KB had arrived, in 394 ms | | Client leaves mid-request | the handler kept running: still running 45 seconds after the client disconnected | the handler's context was cancelled 400 ms after | | WebSocket | works | the platform refused the handshake with 502 ("protocol error") | So: serve an API that has no WebSocket routes on an `h2c` port, and set `ServerOptions.UnencryptedHTTP2` so the server accepts it. Keep WebSocket routes on an HTTP/1 port, which means a separate service if one application needs both. ```go muzak.AppOptions{ ServerOptions: muzak.ServerOptions{UnencryptedHTTP2: true}, } ``` `UnencryptedHTTP2` accepts HTTP/2 without TLS, sent with prior knowledge, on the same port as HTTP/1. Leave it off anywhere the proxy speaks HTTP/1, and never for a server reachable directly from an untrusted network. A WebSocket handshake is not carried over HTTP/2 and is answered `426`. `App.Run` and `App.RunSignals` could not serve h2c before it existed. On an HTTP/1 port, do work that should stop when its client leaves in steps that check a deadline of their own, since the handler's context is not cancelled by the disconnect. ## Timeouts The framework's default `WriteTimeout` is 30 seconds and Cloud Run's request timeout is longer (300 seconds by default, up to 3,600). A non-streaming handler that runs past 30 seconds is ended by the server, and the client saw a 503 from the platform after 60 seconds in the test. Raise `ServerOptions.WriteTimeout` for a route that takes that long, or stream. WebSocket and event-stream routes lift the deadline themselves. `WriteTimeout` bounds the connection, not the work. A route that should give up on its own, and tell the client so with a `503` and `Retry-After` rather than leave the answer to the platform, takes a `muzak.Timeout` shorter than `WriteTimeout`: its guards, providers and handler see the deadline on the request's context. See [Timeouts](/docs/techniques/timeouts). ## Streams - **Event streams** arrived incrementally through the platform: a producer sending every 500 ms was received at gaps of 495 to 506 ms, so nothing buffers them. - **Keepalive** comments keep an idle stream open. The test found the keepalive arriving at 15, 45 and 60 seconds when asked for every 15, a bug fixed in 0.2.8 (it skipped a tick whenever its own previous write had landed just after the one before). After the fix it arrives every interval. - **`MaxLifetime`** ended a stream cleanly after 20 seconds, and a reconnect with `Last-Event-ID` resumed from the next event on the same instance. - **WebSockets** on the HTTP/1 port survived 100 seconds with no application traffic, with the default keepalive (a ping every 30 seconds) and for a handler that only reads. A message over the read limit ended the connection; the client saw the connection lost (1006) more often than the 1009 the server sends. That was a fault in the server, fixed in 0.2.8: it closed the socket with the client's data still unread, which makes the kernel reset the connection and drop the close frame in flight. It now keeps reading until the peer answers or the grace period ends, and in a local test the `1009` reached the client in 50 of 50 runs, where it had reached it in 0 of 50. That was not re-measured on Cloud Run. ## Deployments A connection open when a new revision starts serving is not closed by the rollout: a WebSocket and an event stream were both still open five minutes after the new revision took over. They are closed when Cloud Run scales the old instance in, and then the framework sends its going-away close (1001) or ends the stream cleanly. A connection that must not outlive a deploy should be given a `MaxLifetime`. ## Not established - **Limits across several instances.** The framework's in-memory rate limiter counts per instance. The test asked for several by allowing one request at a time per instance, but Cloud Run served all twenty concurrent requests from one instance within the ten seconds of the test, so the budget across instances was not measured. A limit that must hold across instances needs a store they share. See [Rate Limiting](/docs/techniques/rate-limiting). - **Cloud Run's second generation execution environment, and regions other than us-central1,** were not compared. ## Where to go next [Server Configuration](/docs/deployment/server-configuration) covers the timeouts and the shutdown deadline (Cloud Run allows about ten seconds after `SIGTERM`, so `ShutdownTimeout` has to come down), and [Behind a Proxy](/docs/deployment/behind-a-proxy) covers the address a request is attributed to. -------------------------------------------------------------------------------- title: "Health Checks" description: "Liveness and readiness endpoints a platform can probe, readiness checks that a flood of probes cannot turn into a flood of pings, and a shutdown that takes the instance out of rotation before it closes the socket." version: "0.3.1" last_updated: "2026-10-10T11:16:05.000Z" source: "https://muzak.dev/docs/0.3.1/deployment/health-checks" -------------------------------------------------------------------------------- # Health Checks A platform asks a running service two different questions. **Liveness** asks whether the process should be restarted, and the only honest answer is whether it still serves at all. **Readiness** asks whether it should be sent traffic right now, which depends on its database, its cache and on whether it is starting up or shutting down. Muzak answers both once `AppOptions.Health` is enabled. ```go [cmd/main.go] app := muzak.New(muzak.AppOptions{ Title: "Awesome API", Health: muzak.HealthOptions{ Enabled: true, Checks: []muzak.HealthCheck{ {Name: "database", Check: db.PingContext}, {Name: "cache", Check: func(ctx context.Context) error { return redisClient.Ping(ctx).Err() }}, }, }, // Keep serving for five seconds after readiness drops, so the load // balancer has time to notice before the socket closes. DrainDelay: 5 * time.Second, }) ``` The zero value of `HealthOptions` serves nothing and costs nothing: no route, no middleware, no goroutine. Setting a field such as `Checks` without `Enabled` is a build error rather than configuration that silently does nothing. ## The two endpoints | Path | Answers | When | |---|---|---| | `/livez` | `200 {"status":"ok"}` | Whenever the process is serving at all | | `/readyz` | `503 {"status":"unavailable"}` | Until the lifecycle components have started | | `/readyz` | `200 {"status":"ok"}` | Once they have, and every check passes | | `/readyz` | `503 {"status":"unavailable"}` | When a check fails, and from the moment a shutdown begins | Both take `GET` and `HEAD` and answer anything else with `405` and `Allow: GET, HEAD`, rendered by the application's error renderer like any other 405. Both are sent with `Cache-Control: no-store`, so no cache between the platform and the process can answer a probe with a stale result. Liveness never runs a check. A database that is down is a reason to stop sending traffic, not a reason to restart every replica at once, which is what a liveness probe that pinged the database would do. The paths move with `LivenessPath` and `ReadinessPath`: ```go Health: muzak.HealthOptions{ Enabled: true, LivenessPath: "/_/alive", ReadinessPath: "/_/ready", }, ``` A path is matched exactly, so `/readyz/` is not the readiness endpoint. It has to be absolute, in its clean form, and made of letters, digits and `-._~/`, which a probe never has to encode. The two paths must differ. A path a route, a mount or the documentation already answers is a build error, because the health endpoint is answered first and would take that path from it without a word. A frontend or a handler mounted at `/` is the exception: it answers only what nothing else does, as it does for every route. Its root is its own, though, so a health path of `/` beside one is a build error too. ## Answered ahead of the application The health endpoints are answered right after the request identifier, tracing and the request observer, the security headers and panic recovery, and before everything else: - **No middleware installed with `App.Use`.** An authenticating middleware that refuses a probe takes every replica out of rotation. - **No guard, provider or rate limit**, including those given to `New`. A probe carries no credentials, and a platform probing every few seconds is the one client that must never be told to slow down. - **No CORS policy, host allowlist or redirect to HTTPS.** No browser has any business reading a probe, and a probe is sent to the pod's own address over plain HTTP, which `AllowedHosts` and `RedirectHTTPS` would refuse or redirect. - **Not in the OpenAPI document.** The endpoints are for the platform, not for API clients. Tracing and the request observer are outside them, so with [`Tracing` or `Observer`](/docs/fundamentals/observability) configured every probe is observed, with an empty route, and a sampled one gets a server span named after its method alone. A readiness `503` counts as a server error there, as any `5xx` does. A `Sampler` sees the request, so it can decline to record the probes' traces. Since nothing authenticates a probe, the endpoints say very little. The body is `{"status":"ok"}` or `{"status":"unavailable"}` and nothing else; the error a check returned is written to the log, never to the response. A service whose readiness endpoint is reachable only from inside the platform can ask for the name of each check with `ReportChecks`: ```go Health: muzak.HealthOptions{ Enabled: true, ReportChecks: true, Checks: checks, }, ``` ```json {"status":"unavailable","checks":{"database":"fail","cache":"ok"}} ``` It is off by default because the names of a service's dependencies, and which of them is down right now, are worth something to an attacker probing an unauthenticated endpoint. Since the host allowlist does not apply to the health paths, a web page that points its own name at the service's address (DNS rebinding) can read the body from a browser on the same network as well. Turn `ReportChecks` on only where the port is reachable from the platform alone. ## Readiness checks ```go type HealthCheck struct { // Name identifies the check in logs and, with ReportChecks, in the body. Name string // Check returns nil when the dependency is usable. Check func(ctx context.Context) error // Timeout bounds one run, defaulting to one second. Timeout time.Duration } ``` A `Check` with the signature `func(context.Context) error` fits most clients as they are: `(*sql.DB).PingContext` is one. A name is required, unique, at most 64 characters, and made of letters, digits, `-`, `.` and `_`, so it never needs escaping in a log line or a body. The checks run concurrently, each under its own timeout, so readiness takes as long as the slowest check rather than the sum. Keep `Timeout` below the probe's own timeout (Kubernetes defaults `timeoutSeconds` to 1), so a slow dependency is reported as a `503` rather than as a probe that never got an answer. ### What a flood of probes costs | Bound | Default | Field | |---|---|---| | Runs of the checks | One per interval, however many probes arrive | `CacheInterval`, 1s | | Probes arriving during a run | Wait for that run; none starts another | | | Length of one run | The longest check's timeout | `HealthCheck.Timeout`, 1s | | Goroutines per check | One, ever | | The combined result is reused for `CacheInterval`. Probes that arrive while the checks are running wait for that run instead of starting their own, so three thousand probes at once run each check exactly once. A negative `CacheInterval` runs the checks for every probe that does not find a run already in progress: concurrent probes still share a run, but the rate is no longer bounded. A check that ignores its context and never returns is the case that matters. The probe is answered at the check's timeout with a `503`, and the check is not started again while its goroutine is still running: every later probe reports it as failing at once, so a hung dependency costs one goroutine, not one per probe. A check that panics is recovered and counts as a failure, and a check that answers after its timeout counts as failed even if it returned nil. ### What is logged A check that starts failing is logged at warn level with its error, once. While it keeps failing it is logged at debug level only, and when it passes again that is logged at info level, so the log grows with changes of state rather than with the rate of probes. ``` 14:32:41.902 WARN [Server] muzak: a readiness check failed; readiness reports unavailable check=database error="dial tcp 10.0.0.5:5432: connect: connection refused" 14:33:02.117 INFO [Server] muzak: a readiness check passes again check=database ``` The error is the check's own text, cut to a bounded length, and may name a host or a user. Nothing is redacted out of it, since the log is where an operator needs to read it, but it never reaches a response. Probes are recorded in the access log at debug level whatever their status, since a platform probing every few seconds would otherwise outnumber real traffic and a `503` during start-up is expected rather than an error. Change the level with `AccessLogLevel`, or leave a path out entirely with `AccessLogOptions.SkipPaths`. ## Start-up and shutdown Readiness follows the lifecycle. It answers `503` until `StartLifecycle` has succeeded, which the run methods and the test client do before the socket opens, and again once a shutdown begins. **Readiness drops before anything else happens.** The first thing `Shutdown` does is report unavailable, before any listener closes or any connection is touched, so a load balancer probing it stops sending traffic while requests in flight still finish. Liveness keeps answering `200` throughout: a process draining its requests is alive, and restarting it would cut the drain short. **`DrainDelay` keeps serving while the load balancer catches up.** A load balancer learns about a shutdown on its next probe, and Kubernetes removes a terminating pod from its Service while it sends `SIGTERM`, not before. Requests sent in that gap would find the socket closed. With `DrainDelay` set, the shutdown waits that long with readiness down and the listeners still accepting, before it closes them: ``` 14:32:41.902 INFO [Server] Shutting down, waiting for in-flight requests... 14:32:41.902 INFO [Server] Readiness now reports unavailable; waiting 5s before closing the listeners 14:32:46.903 INFO [Server] Stopping 2 lifecycle components 14:32:46.910 INFO [Server] Stopped ``` The delay counts against `ShutdownTimeout` rather than being added to it, because the platform's grace period counts it too. A `DrainDelay` that is not shorter than `ShutdownTimeout` would leave no time to drain, and is a build error; so is a negative one. The default of zero closes the listeners at once. The delay applies whether or not health is enabled, since a platform that removes endpoints on its own clock needs it either way, and it replaces the `sleep` a `preStop` hook would otherwise hold. An application run again after a shutdown reports `503` until its components have started once more, and does not reuse a readiness result from the previous run. The whole sequence, in order: 1. Readiness reports unavailable. 2. The server keeps serving through `DrainDelay`. 3. The listeners close and requests in flight are drained, while WebSockets are told the server is going away and event streams are ended. 4. The [background tasks](/docs/techniques/background-tasks) those requests registered are waited for. 5. The lifecycle components are stopped. The [test client](/docs/fundamentals/testing) shuts an application down the same way when a test ends, so a `DrainDelay` in the configuration a test builds delays the end of that test by as much. Leave it out of the configuration tests use. ## Kubernetes ```yaml livenessProbe: httpGet: {path: /livez, port: 8080} periodSeconds: 10 readinessProbe: httpGet: {path: /readyz, port: 8080} periodSeconds: 5 timeoutSeconds: 2 terminationGracePeriodSeconds: 30 ``` ```go ShutdownTimeout: 25 * time.Second, // below terminationGracePeriodSeconds DrainDelay: 5 * time.Second, // at least one readiness period ``` ## Serving it yourself An application served through `App.ServeHTTP` in a server you built answers the health endpoints the same way, but nothing starts its lifecycle for it, so readiness answers `503` until you call `StartLifecycle`, and again after `StopLifecycle`. `DrainDelay` belongs to `Shutdown`, which such a server does not use. ## Where to go next [Server Configuration](/docs/deployment/server-configuration#shutting-down) covers `ShutdownTimeout` and the rest of the shutdown, [Lifecycle](/docs/getting-started/lifecycle) the components readiness waits for, and [Background Tasks](/docs/techniques/background-tasks) the work a shutdown drains after its requests.