Server Configuration
AppOptions embeds ServerOptions, so the listener is configured in the same literal as
everything else.
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 Stops 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.
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.
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.
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 and TLS.
The listen address
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 |
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:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := app.RunContext(ctx); err != nil {
log.Fatal(err)
}
Shutting down
if err := app.Shutdown(context.Background()); err != nil {
log.Printf("shutdown: %v", err)
}
The sequence is the one that matters:
- Readiness turns unready. With health checks enabled,
the readiness endpoint answers
503from this moment, while every request is still served, so a load balancer that probes it stops sending traffic. - The server keeps serving for
ServerOptions.DrainDelay, zero by default, which is the time that load balancer needs to notice. Set it instead of asleepin apreStophook. - 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 Awayand open event streams are ended. Both are tracked by the application precisely so this can happen: a hijacked connection is no longer onenet/httpknows 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.AfterResponserefuses a new task withErrBackgroundShuttingDown, even from a request still being drained. - The background tasks that handlers registered with
Context.AfterResponseare run to the end. At the deadline the ones still queued are dropped and the ones running have their context cancelled. - 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):
| 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.
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:
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
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
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.
In production
A short list of things worth deciding before the first deployment:
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.
- A
MaxLifetimeon 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 and Server-Sent Events. AppOptions.AllowedHosts, the names the service answers to. A request for any other host, a WebSocket handshake included, is refused with421before anything else runs, which also stops a DNS-rebinding page aimed at a server on a loopback or private address.WSOptions.AllowedHostsis the narrower check, for one route's WebSocket handshakes alone. See Allowed Hosts and HTTPS.Cache-Controlon anything public served from behind a guard, which is otherwise sentprivate, no-cache.DisableDocsif the deployment must not describe itself, which stops the document as well as any page. LeavingDocsUIunset 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.- Health checks for the platform to probe.
AppOptions.Healthanswers/livezand/readyzahead of routing, out of the document and clear ofApp.Usemiddleware, guards and the rate limit, and readiness reports unavailable as soon as a shutdown begins. A readiness check such as a database ping goes inHealthOptions.Checks. See Health Checks.
Building the binary
go build -o awesome-api ./cmd
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.
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 covers what changes when something sits in front of the service, and Lifecycle covers the components start-up and shutdown are sequencing.