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

FieldDefaultBounds
ReadHeaderTimeout5sHow long a client may take to send the request headers
ReadTimeout30sHow long a client may take to send the headers and body together
WriteTimeout30sHow long a handler may take to write its response
IdleTimeout120sHow long a keep-alive connection may sit unused before it is closed
ShutdownTimeout15sThe one deadline for the whole shutdown: the drain delay, the drain, the background tasks and the lifecycle Stops together
DrainDelay0How long a shutdown keeps serving, with readiness reporting unavailable, before the listeners close. It counts against ShutdownTimeout
MaxHeaderBytes64 KiBThe 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

FieldEffect
TLSConfigServe HTTPS from a *tls.Config
CertFile, KeyFileServe HTTPS from a certificate and key on disk. Setting only one of them is a build error
UnencryptedHTTP2Also 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

MethodStops 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:

  1. Readiness turns unready. With 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 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):

PlatformGrace after SIGTERMWhere it is set
Cloud Runabout 10sfixed, so ShutdownTimeout must come down
Kubernetes30sterminationGracePeriodSeconds
ECS30sstopTimeout

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 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 and Server-Sent Events.
  • 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.
  • 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.
  • 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.

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.

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