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:    1 << 20,
})

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
ShutdownTimeout15sHow long a graceful shutdown waits for in-flight requests
MaxHeaderBytes1 MiBThe 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.

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 per event; the read deadline goes too, since it would cancel the request and blame the client. See Server-Sent Events.

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.

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. 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.
  2. The server stops accepting new connections and drains its in-flight requests, giving up after ShutdownTimeout and closing whatever remains.
  3. The lifecycle components are stopped.

Components stay usable until the very end, because pulling a database connection out from under a request that is still running 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. Calling it on a server that was never started returns nil.

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. In Kubernetes that is terminationGracePeriodSeconds.

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()

Two things become yours when you do that: the timeouts, which Muzak would otherwise have set for you, and the lifecycle, which nothing else will start. 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 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.

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, duplicate operation identifiers, malformed prefixes, a MaxConnections set anywhere but the application, a missing static directory. One test keeps all of them out of a deployment.

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},

    // A health check polled every second should not drown out real traffic.
    AccessLogOptions: muzak.AccessLogOptions{SkipPaths: []string{"/healthz"}},
})
  • 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.
  • 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.
  • A liveness route, routable and out of the document:
r.Get("/healthz", handlers.Health,
    muzak.Summary("Liveness probe"),
    // Routable, but left out of the documentation.
    muzak.Hidden(),
    // A monitor polling every second is the one client that should never be
    // told to slow down.
    muzak.SkipRateLimit())

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 · sustained by the people who ship on it.Built on net/http, and nothing else.