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
| 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 | How long a graceful shutdown waits for in-flight requests |
MaxHeaderBytes | 1 MiB | 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.
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
| 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.
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:
- 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 onenet/httpknows about, and a streaming handler would otherwise make the drain wait out its whole deadline once per stream. - The server stops accepting new connections and drains its in-flight requests, giving up
after
ShutdownTimeoutand closing whatever remains. - 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.
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.- 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.