Before the First Request and After the Last: a Go HTTP Server's Edges

The nurago httpserver package examined at its edges: startup validation that fails in New, a listener bound before serving, request contexts that survive shutdown, the shutdown choreography, and the timeout lattice.


Serving HTTP is the easy middle of a server’s life. net/http handles it well, and it is rarely where the trouble starts. The places where a server can embarrass itself are the edges: the startup that half-works until the first request finds the misconfiguration, and the shutdown that drops in-flight requests, leaks a goroutine, or hangs the deploy. The httpserver package in nurago is a bootstrap around net/http (routing, middleware, operational endpoints, TLS, graceful shutdown), and its most interesting engineering lives at those edges, so that is where this post stays.

import "github.com/tecnickcom/nurago/pkg/httpserver"

srv, err := httpserver.New(ctx, binder,
    httpserver.WithServerAddr(":8017"),
    httpserver.WithRequestTimeout(30*time.Second),
    httpserver.WithEnableDefaultRoutes(httpserver.PingRoute, httpserver.StatusRoute),
)
if err != nil {
    return err // misconfigurations surface here, not at the first request
}

srv.StartServer()

Routes come from a Binder, an interface with one method returning the route list, which keeps route definition testable and separate from server assembly.


Fail in New, not at the first request

A server that constructs successfully but cannot serve is a delayed failure with worse timing. So New front-loads validation and reports problems as wrapped, errors.Is-matchable sentinels rather than panics or surprises.

Routes are checked for an empty or non-uppercase method, and the uppercase rule has a concrete reason: the underlying httprouter matches methods case-sensitively, so a route registered as get would be bound, reachable by nothing standard, and silently dead. Paths must begin with /; handlers and every middleware entry must be non-nil; a duplicate method-and-path pair is ErrDuplicateRoute. The router itself panics on conflicting wildcards and malformed patterns, so registration runs under a recover that converts any router panic into ErrRouteRegistration. Unknown default-route identifiers are rejected by the options that accept them.

TLS gets the same treatment. tls.NewListener performs no configuration validation, so a TLS configuration with no certificate material would bind successfully and then fail every handshake at runtime; New replicates the check tls.Listen would have done, before anything is bound, and returns ErrInvalidTLSConfig. A configuration built by WithTLSCertData advertises HTTP/2 and HTTP/1.1 via Application-Layer Protocol Negotiation (ALPN), with Transport Layer Security (TLS) 1.2 as the floor, and the server pins its accepted protocol set explicitly rather than inheriting whatever future net/http defaults might be; a test drives a real HTTP/2-over-TLS exchange.


The listener exists before the server runs

New binds the listener immediately: on successful return, the address is held. That makes binding to :0 useful, with Addr() reporting the ephemeral port the operating system assigned, which is exactly what integration tests want.

It also creates an easily missed obligation. http.Server.Shutdown closes only the listeners that Serve registered, so a server that was constructed but never started would hold its bound port until process exit. Shutdown on a never-started server therefore closes the listener explicitly; the tests cover the never-started path, including a listener whose close itself fails.


Requests inherit values, not cancellation

The context passed to New carries application-scoped values (loggers, feature flags) that request handlers reasonably want. But if request contexts simply inherited it, cancelling the application context to begin shutdown would instantly cancel every in-flight request, which is the opposite of graceful. The base context for requests is therefore context.WithoutCancel(ctx): values flow through, cancellation does not, and cancelling the application context instead triggers the shutdown sequence, during which in-flight requests get the shutdown grace period to finish. A test pins the drain: a request in flight when the application context is cancelled completes.


The shutdown choreography

Shutdown can begin from three directions: a value (or close) on a shared signal channel, cancellation of the start context, or a direct Shutdown call. The first two are watched by a monitor goroutine that StartServerCtx launches; the third closes an internal channel that the monitor also watches, so a direct call does not leave the monitor blocked forever on a signal that will not come.

The bookkeeping is where such code usually goes wrong, and here it is deliberately narrow. Starting is a no-op if the server has already started or already shut down, which keeps the external wait group balanced: one increment when the server starts, one decrement, exactly once, in Shutdown via sync.Once, and only for a server that was actually started. Shutdown is safe to call repeatedly and from multiple paths concurrently, which matters because the package itself calls it from the monitor goroutine while your deploy script may be calling it too; the idempotence is pinned by tests.

One more edge: Serve can fail for reasons other than shutdown. In that case the failure is published on a buffered channel exposed by ServeError() (size one, never a nil value, not overwritten), and the server drives its own shutdown so the wait group is released and the monitor unblocked. Without that, an application would wait patiently on a server that had already died.


The timeout lattice

A production server carries several timers with different jobs, and the package keeps them distinct rather than blending them.

The connection-level ones map to http.Server fields, each defaulting to one minute: read-header, read, write, and idle. The request-level one, WithRequestTimeout, is enforced per route with http.TimeoutHandler, which answers 503 when the handler overruns. That handler buffers the whole response and supports neither Flusher nor Hijacker, so it is wrong for streaming endpoints; a route opts out with the DisableTimeout sentinel (or overrides the global value with its own positive Timeout). The built-in pprof route ships with DisableTimeout because CPU profiles stream for ?seconds=N. The documentation is then careful about the interaction: the server-wide write timeout is a connection deadline that still applies to exempted routes, so it must be sized to the longest response the server is expected to produce.

Input is bounded separately: WithServerMaxHeaderBytes caps header parsing (the net/http default is 1 MiB), and WithMaxRequestBodyBytes caps bodies via http.MaxBytesHandler, whose overruns surface as *http.MaxBytesError so a handler can answer 413. The body guard is the outermost middleware on purpose, so it wraps the original response writer and also caps what the debug request dump can buffer.


The middle, briefly

The request path between the edges: middleware composes as body guard, then request logger, then timeout, then the globals from WithMiddlewareFn, then per-route middleware. The logger derives a per-request logger (never reassigning the shared one, which a concurrency test pins) carrying the trace ID, method, path, redacted query, and, on completion, the response code and body size; a handler that never calls WriteHeader is logged with the implicit 200. The wrapped writer forwards Flusher, Hijacker, Pusher, io.ReaderFrom, and http.ResponseController, but not the deprecated CloseNotifier. Panics in handlers are logged with a stack trace and answered with 500, except http.ErrAbortHandler, which is re-raised because net/http documents it as the way to abort a connection silently. Two router behaviours are documented rather than hidden: httprouter may answer trailing-slash redirects and automatic OPTIONS before the middleware pipeline runs, so those responses bypass the request logger.

The optional default routes (/ping, /status, /metrics, /pprof/*, /ip, and an index of registered routes) are off unless enabled, and the package documentation is blunt about why: pprof serves goroutine stacks and memory layouts, the index enumerates every endpoint, and /ip calls out to a third-party service. They belong on internal or administrative listeners, or behind authentication middleware.


The edges are where a server’s quality is decided precisely because they run rarely: startup once per deploy, shutdown once per deploy, the failure paths almost never. Code that runs constantly gets debugged by traffic; code that runs twice a day gets debugged by incidents. Moving that logic into a bootstrap that validates eagerly, shuts down deterministically, and writes its caveats into documentation is an attempt to have those debugging sessions once, in one place, rather than once per service.