Help us shape the future of Go! Take the 2026 Go Developer Survey

serve

package
v0.6.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package serve runs a rig server: a connection pool, an HTTP server, and a shutdown that finishes what it started.

None of that is interesting, and all of it is easy to get subtly wrong — a pool that is never pinged so a bad password surfaces on the first request, a server with no header timeout, a SIGTERM that drops requests in flight. It is the same forty lines in every project, so it is here instead, and a main.go is left with the part that is actually about the application: which services are wired to which repositories, and how a caller is identified.

Index

Constants

View Source
const NoProbe = "-"

NoProbe is a LivenessPath or ReadinessPath that should not be served.

A probe path has no zero value meaning "none": the empty string is what a config that said nothing looks like, and telling those two apart is the whole point. So not wanting one is said with this rather than by omission, and a project that forgot gets an error instead of a server with no way to be checked.

Not a path, so it cannot collide with one: an HTTP server is only ever asked for a route beginning with a slash.

Variables

This section is empty.

Functions

func Main

func Main(cfg Config, mount Mount)

Main runs a server until it is asked to stop, then exits.

It is Run with the signals wired up and a non-zero exit on failure — the whole of a main function, so that a main function can be the wiring and nothing else. Use Run directly to keep control of the process.

func Once

func Once(ctx context.Context, cfg Config, task Task) error

Once opens the pool, runs f, and closes it again.

For the work that is not a server: applying migrations, a backfill, a job that runs and exits. It reads the same configuration a server would, so the task and the server it ships with connect the same way, to the same place, with the same tuning.

func Run

func Run(ctx context.Context, cfg Config, mount Mount) (err error)

Run opens the pool, serves until the context is cancelled, and shuts down.

Cancelling the context is the ordinary way to stop: the stop sequence gets MaxShutdown to finish, and only then does the pool close. Run returns nil when that completes, and an error when it does not.

func Unclean

func Unclean(err error) bool

Unclean reports whether err is only about a shutdown that did not go well.

Only: a startup failure joined with a failed teardown is still a startup failure, and still fatal.

Types

type App

type App struct {
	// Pool is the database, already connected and pinged.
	Pool *pgxpool.Pool

	// Logger is the server's, so a request line, a shutdown step and anything
	// a dependency says all land in the same place. Run always sets it, to
	// Config.Logger or the default.
	//
	// A rig-generated Mount replaces it with the same logger wrapped in
	// [github.com/simonjanss/rig/runtime/apibase.LogHandler] before the
	// application's own wiring is called, so that everything built out of it
	// says which request a line belongs to. This package does none of that and
	// knows nothing about it; it is noted here because this is the field the
	// substitution is visible on.
	Logger *slog.Logger
	// contains filtered or unexported fields
}

App is what a mount function is given: the pool, a logger, and somewhere to say how the things it builds are shut down.

A server is rarely only a server. There is a queue consumer, a cache, a metrics exporter, a client with a connection pool of its own — each built while the routes are being wired, and each needing to stop in a particular order relative to the HTTP server. Without somewhere to record that, a main function ends up with a package-level variable per dependency and a shutdown function that has to be kept in step with a constructor somewhere else.

func (*App) Close

func (a *App) Close(name string, f func(ctx context.Context) error)

Close registers work for after the server has stopped answering.

Registered functions run in reverse: the last thing built is the first thing closed, because the order things were built in is the order their dependencies came to exist. Everything registered runs even if one fails, and all of it runs before the pool closes — so a final write still has a database to write to.

A dependency registered before the server ever listens is closed too. Failing halfway through startup is exactly when a half-built process most needs taking apart.

func (*App) CloseFunc

func (a *App) CloseFunc(name string, f func() error)

CloseFunc registers something whose shutdown takes no context, which is most third-party clients.

func (*App) CloseWithin

func (a *App) CloseWithin(name string, timeout time.Duration, f func(ctx context.Context) error)

CloseWithin is Close with a limit of its own.

Without one, a step is bounded only by what is left of MaxShutdown, which is the right default: the sequence as a whole cannot overrun, and a flush that legitimately takes ten seconds is not cut off at an arbitrary two. Give a step its own limit when it should not be allowed to spend the budget the steps after it will need.

func (*App) Drain

func (a *App) Drain(name string, f func(ctx context.Context) error)

Drain registers work for the start of the shutdown: after readiness turns false, before the server stops accepting requests.

This is for anything that fetches its own work rather than being handed it — a queue consumer, a scheduler, a poller. Stopping those first means the process spends its drain window finishing what it has instead of picking up more it will not finish. Requests in flight are unaffected: the server is still serving.

func (*App) DrainWithin

func (a *App) DrainWithin(name string, timeout time.Duration, f func(ctx context.Context) error)

DrainWithin is Drain with a limit of its own.

Use it for something whose stop is unbounded — a fetch loop mid-request, a long poll — so that the rest of the shutdown is not spent waiting on it.

func (*App) Ready added in v0.2.0

func (a *App) Ready(name string, probe func(ctx context.Context) error)

Ready registers a dependency this instance cannot serve without, so that Config.ReadinessPath fails while it is away and names it when it does.

It is here rather than on the Config for the reason Drain and Close are: the thing being checked is built by the mount function, and a Config is filled in by a main that runs before the mount. Config.Ready is the same hook for a dependency that does exist by then.

Register only what genuinely makes this instance not worth sending work to. The temptation is to list everything the server talks to, and the cost of giving in is the outage the probe was supposed to contain: one dependency being slow takes every replica out of the load balancer at once, including the requests that never touch it. A dependency the server degrades gracefully without — a sync service whose shapes fall back to a snapshot, a cache — is something to log and to show, not something to fail readiness on.

The probes run after the pool ping and after Config.Ready, in the order they were registered, and all of them share Config.ProbeTimeout.

func (*App) RunDrain

func (a *App) RunDrain(ctx context.Context) error

RunDrain stops the things that pull their own work, in the order they were registered.

Run calls it at the start of the shutdown, once readiness is already false and before the server stops accepting. It is exported for the caller that builds the shipped handler from a bare pool and owns the ending itself — an integration test standing an httptest.Server over what main mounts, whose Close blocks on exactly the long polls only this releases.

Every failure is logged where it happens as well as returned, for the reason [App.runClose] gives. Logger is what that line goes to, and Run is what normally sets it — so an App somebody built themselves may have none, and [App.log] is why that is a default rather than a panic on the one path where something has already gone wrong.

type Config

type Config struct {
	// DatabaseURL is the connection string. Empty means $DATABASE_URL, and
	// failing that the server refuses to start rather than guessing.
	DatabaseURL string

	// Addr is the listen address. Empty means $ADDR, and refused after that.
	Addr string

	// Monitor is served on a listener of its own, at MonitorAddr, in this same
	// process. Nil — the default — opens no second listener.
	//
	// It is an http.Handler and not rig's monitoring page, because this package
	// is imported by every generated server and rig/observe brings
	// OpenTelemetry with it. Pass observe's:
	//
	//	Monitor:     page.Handler(),
	//	MonitorAddr: page.Addr(),
	//
	// which are both zero when the page is unarmed, so a deployment with no
	// monitoring password opens no port rather than one that refuses.
	//
	// A separate listener rather than a route on the handler below, because a
	// bind address is a boundary the kernel keeps and a path is not: bound to
	// loopback, the page is reachable by this machine and by nothing else, in
	// every deployment and behind every proxy. Anything else that should be
	// reachable on those terms and not on the API's — a pprof mux, an operator
	// endpoint — belongs here too.
	Monitor http.Handler

	// MonitorAddr is where Monitor listens, as host:port. Required when Monitor
	// is set, and refused when it is not: the two say one thing together, and
	// either one alone is a monitoring page somebody believes is running.
	//
	// There is no default and no environment fallback here, unlike Addr. What
	// fills it in is observe's own $RIG_MONITOR_ADDR, resolved before the page
	// is built, so that the address and the page's own idea of it cannot
	// disagree.
	MonitorAddr string

	// LivenessPath answers whether the process is running, and nothing else.
	//
	// Required, like ReadinessPath: a path this package chose would be one an
	// orchestrator has to be told about anyway. [NoProbe] is how a project says
	// it wants none.
	// It never touches the database, and that is the whole point: a liveness
	// probe that fails when a dependency does turns one database blip into
	// every replica being killed at once, which is the outage the restart was
	// supposed to prevent.
	//
	// Empty registers nothing.
	LivenessPath string

	// ReadinessPath answers whether this instance should be sent traffic. It
	// pings the database, runs Ready if there is one, and turns false the
	// moment a shutdown begins — so a load balancer stops sending work before
	// the server stops accepting it.
	//
	// Empty registers nothing.
	ReadinessPath string

	// Ready is checked by ReadinessPath after the database, for a dependency
	// this package knows nothing about. An error means not ready, and its text
	// goes to the caller: a readiness probe is read by whoever is debugging the
	// deployment, not by the public.
	Ready func(ctx context.Context) error

	// DrainDelay is how long to keep serving after readiness turns false and
	// before the shutdown starts. Zero is none, which is a real answer here
	// rather than an absent one. It comes out of MaxShutdown either way.
	//
	// It exists because removing an instance from a load balancer is not
	// instant: the probe has to fail, and the change has to propagate. Requests
	// sent during that window arrive at a server that has already stopped
	// accepting them. A delay of a few seconds is what turns a rolling deploy
	// with dropped requests into one without.
	DrainDelay time.Duration

	// Logger receives the two lines this package has to say. Nil uses the
	// default logger.
	Logger *slog.Logger

	// Pool tunes the pool before it is opened — MaxConns, an AfterConnect
	// hook, a tracer.
	Pool func(*pgxpool.Config) error

	// Tasks are subcommands: an argument that names one runs it against an
	// open pool and exits, instead of starting the server.
	//
	//	Tasks: map[string]serve.Task{"migrate": migrate.Apply(migrations, opt)}
	//
	// It is here rather than in every main function because the alternative is
	// everybody writing the same three lines against os.Args, and because a
	// task and a server that must connect identically should be reading one
	// configuration.
	//
	// Only Main dispatches them. Run always serves.
	Tasks map[string]Task

	// Migrate runs before the server listens, with the pool already open. An
	// error stops the process before it has served anything.
	//
	// This package does not know what a migration is; rig/migrate does, and
	// keeping the two apart is why an application that migrates some other way
	// carries no dependency on it.
	//
	// Whether this should apply migrations or only refuse to start without them
	// is a real decision with a wrong answer at scale. rig/migrate's package
	// documentation lays out the three places migrations can run and what each
	// costs; migrate.Require is the one to reach for by default, and
	// migrate.Apply the one for a single instance.
	Migrate Task

	// MaxStartup is the longest the process may take between Run being called
	// and the server listening: opening the pool, the Migrate hook, and the
	// mount function.
	//
	// The counterpart of MaxShutdown, and for the same reason. A boot that
	// hangs on something slow is not obviously different from a boot that is
	// merely slow, and the difference matters most at three in the morning. A
	// project that migrates on the way up should raise this: the migration is
	// inside the budget.
	MaxStartup time.Duration

	// ConnectTimeout bounds the first connection, inside MaxStartup — soon
	// enough to say "the database" rather than "startup" when that is what went
	// wrong.
	//
	// Setting it longer than MaxStartup is refused: connecting alone would use
	// the whole budget.
	ConnectTimeout time.Duration

	// Hint is what to tell somebody whose database is not there — most often
	// how to start one:
	//
	//	Hint: "run `rig db up` to start a local Postgres, or set $DATABASE_URL"
	//
	// It is said as soon as the first connection attempt fails rather than at
	// the end of ConnectTimeout, and again with the error if the wait runs out.
	// The reason it is a field and not a sentence in this package is that only
	// the project knows how its database is meant to be started: a container in
	// development, a managed instance in production, and printing the wrong one
	// is worse than printing nothing.
	Hint string

	// ProbeTimeout bounds what a readiness check is allowed to wait for: a probe
	// that hangs is a probe that has already failed.
	ProbeTimeout time.Duration

	// ReadHeaderTimeout bounds how long a client may take to send its headers.
	// Worth stating rather than turning off: without it one open connection
	// sending nothing occupies a goroutine forever.
	ReadHeaderTimeout time.Duration
	// ReadTimeout bounds the whole request.
	ReadTimeout time.Duration
	// WriteTimeout bounds the response. Its clock starts when the request's
	// headers were read rather than when the body starts going out.
	//
	// It is the bound for an ordinary route. A route that legitimately outlives
	// it lifts it for its own request through http.NewResponseController — the
	// file transfers and the shape proxy both do — rather than this field being
	// raised to suit the slowest thing in the application, which would weaken
	// every other route to do it. That is why there is no per-route timeout
	// here and should not be one.
	WriteTimeout time.Duration
	// IdleTimeout bounds a kept-alive connection between requests.
	IdleTimeout time.Duration

	// MaxShutdown is the longest the whole stop sequence may take, from the
	// signal to the process being gone: the drain delay, the drain steps, the
	// requests in flight, and every dependency registered with App.Close.
	//
	// Required. It is the only field here with no default, because it is the
	// only one that leaves the program: this is the number that belongs in
	// Kubernetes' terminationGracePeriodSeconds, and a default would be a value
	// nobody wrote deciding how long something outside this process waits before
	// sending SIGKILL. A server that is stopped faster than its own budget does
	// not drain, it is killed mid-flight.
	//
	// It is a stated maximum rather than the sum of its parts so that it can be
	// read off and copied — adding up whatever the mount function happened to
	// register is not something anybody will do twice. The parts are checked
	// against it before the server listens, so the two cannot drift apart
	// quietly, and a config that states nothing is refused there with the total
	// and each part named. That refusal is where the number to write comes from.
	MaxShutdown time.Duration

	// Shutdown sizes the individual steps inside MaxShutdown, for a deployment
	// that wants numbers other than the ones they were registered with.
	//
	// The generated api package's Shutdown is what fills it, and it is a struct
	// with a field per step this project actually registers:
	//
	//	Shutdown: api.Shutdown{
	//		Notifications: 10 * time.Second,
	//		Presence:      2 * time.Second,
	//	},
	//
	// Optional, and the ordinary case is to leave it out: the numbers a step is
	// registered with are rig's answer to what that step costs, and MaxShutdown
	// is already the sum of them. This is for the deployment those numbers do
	// not suit — most often one whose terminationGracePeriodSeconds was decided
	// by somebody else, which is exactly the fact rig.yaml cannot carry because
	// the same build runs where it is thirty seconds and where it is five.
	//
	// A number here replaces the limit a step was registered with rather than
	// giving one to a step that has none — see [Step]. What that leaves alone is
	// the drain half of the notification engine, which stops it claiming more
	// work and is deliberately bounded only by what is left of the budget.
	//
	// It does not raise MaxShutdown. The parts are checked against the whole
	// before the server listens either way, so a set that outgrows the budget is
	// a process that refuses to start and names what no longer fits, and a step
	// named here that nothing registers a limit for is refused there too.
	Shutdown Shutdown

	// OnListen is called with the address actually bound, which is the only
	// way to learn it when Addr asks for port zero.
	OnListen func(net.Addr)

	// OnMonitorListen is OnListen for MonitorAddr, and is not called at all
	// when there is no second listener.
	//
	// A second callback rather than a role argument on the first, so that a
	// test waiting for one port cannot be handed the other and block until it
	// times out.
	OnMonitorListen func(net.Addr)

	// OnExit runs last, on every way out of Main, including the ones that exit
	// non-zero.
	//
	// It exists because `defer` does not survive os.Exit, and Main exits: a
	// flush written as a deferred call in a main function runs when the server
	// stopped cleanly and is skipped on the three paths where something went
	// wrong — a task that failed, a boot that failed, a subcommand that does
	// not exist. Those are the runs whose spans and logs somebody actually
	// wants.
	//
	// Only Main calls it. Run returns, so a caller using Run directly still has
	// its own defers.
	OnExit func()
}

Config is everything the server needs that is not a route.

Nothing here has a default. A value this package invented for a config that said nothing is one nobody chose, discovered only by what it costs when it is wrong — so every field that would otherwise be one is refused unset, all of them named at once, before anything opens or listens. What a deployment supplies rather than states is still read from the environment: DatabaseURL and Addr fall back to $DATABASE_URL and $ADDR, and are refused after that.

The exceptions are the fields where saying nothing means there is nothing to do rather than something to pick — Ready, Pool, Monitor, the On… hooks, Hint, Tasks, Migrate and DrainDelay. Nil is the whole of what those have to say.

type Mount

type Mount func(ctx context.Context, app *App) (http.Handler, error)

Mount builds the handler once the pool is open.

It returns a handler rather than being handed a mux to fill in, and that is the extension point. Cross-cutting concerns — tracing, a request log, panic recovery, compression — are wrappers around a handler, so a mount function that ends in one line per concern reads in the order they run:

mux := http.NewServeMux()
api.Register(mux, api.Handlers{…})
return otelhttp.NewHandler(logRequests(mux), "todo"), nil

It also leaves the router open: a project that wants a second API on another prefix, static files, or something other than http.ServeMux registers what it likes and returns that.

What this package adds is deliberately outside whatever comes back. The liveness and readiness paths are answered before the returned handler is reached, so a probe every second is not a traced request, a line in the request log, or a row in a latency histogram.

Anything it builds that has to be shut down is registered on the App it is given, next to the line that built it.

type Shutdown added in v0.3.0

type Shutdown interface {
	// Steps is the set, and only the steps it names are affected.
	Steps() []Step
}

Shutdown is how long each of rig's own shutdown steps may take, for a deployment that wants numbers other than the ones it was generated with.

An interface rather than a slice field so that the thing filling it can be a struct with a field per step this project actually registers — which is what the generated api package emits, and what makes a step name a compile error rather than a string that quietly matches nothing:

serve.Config{
	MaxShutdown: 47 * time.Second,
	Shutdown: api.Shutdown{
		Notifications: 10 * time.Second,
		Presence:      2 * time.Second,
	},
}

It belongs to the deployment rather than to rig.yaml for the reason the span destination and the monitoring address do: one binary runs on a laptop, in CI and in production, and how long a stop may take is usually decided by a terminationGracePeriodSeconds somebody else set. A number here is one an environment can supply.

type ShutdownError

type ShutdownError struct{ Err error }

ShutdownError reports that the process stopped, but did not tidy up cleanly: a dependency that would not close, a flush that did not finish.

It is a distinct type because the two failures deserve different answers. A server that could not start has not done its job, and a non-zero exit is how it says so. A server that served for a week and then failed to close an exporter has done its job, and exiting non-zero would mark an ordinary rollout as a crashed container in every dashboard that counts them.

func (*ShutdownError) Error

func (e *ShutdownError) Error() string

Error prefixes the cause with "shutdown: ", which is the only thing that distinguishes it in a log from the startup failure it is not.

func (*ShutdownError) Unwrap

func (e *ShutdownError) Unwrap() error

Unwrap returns what would not close, so errors.Is and errors.As reach it.

type Step added in v0.3.0

type Step struct {
	// Name is the step this is about, and a name no step here has a limit for is
	// refused before the server listens rather than silently doing nothing.
	Name string

	// Timeout is how long that step may take. Zero leaves it alone, which is
	// what makes a partly-filled set mean "these, and the rest as generated".
	Timeout time.Duration
}

Step is one shutdown step named, and how long it may take.

It is what Shutdown is a set of, and what Config.Shutdown carries: a number in it replaces the one whatever registered that step asked for. The name is the one given to App.DrainWithin or App.CloseWithin — for rig's own steps, "traces", "notifications", "presence", "shapes" and "auth", which the generated api package's Shutdown spells as fields rather than as strings.

A limit is replaced rather than imposed, so a step registered through App.Drain or App.Close — which are the two that say "bounded only by what is left of the budget" — keeps that whatever a set says. [App.within] is why it has to work that way.

type Task

type Task func(ctx context.Context, pool *pgxpool.Pool) error

Task is work that runs against the database and finishes: a migration, a backfill, a nightly job. It is the same shape everywhere in this package, so the function that migrates on the way up and the one behind a subcommand can be the same value.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL