servo package

import "github.com/okian/servo/v3/servo"

Who this is for: anyone reading a generated file’s calls into the runtime, or handling a Report in their own code.

This package is two unrelated halves. The markers (Build, Root, Bind, Override, Scoped, HTTP, Value, Include, ConfigFile, and the two scope options) are read as syntax by servo generate and panic if they ever execute. The runtime is what generated code — and, for the HTTP types, your handlers — actually calls at run time. It imports nothing outside the standard library, and neither it nor any generated output imports reflect for servo’s own wiring (encoding/json, used by emitted HTTP servers, reflects internally like it does for everyone).

Your components never import this package. Capability interfaces are satisfied structurally, by having the method.

Index

Identifier Kind  
Build func Declares an injector (marker)
Root func Declares a graph root (marker)
Bind func Binds an interface to a concrete type (marker)
Override func Test-only binding (marker)
Scoped func Declares a keyed, refcounted instance (marker)
HTTP func Opts the injector into serving //servo: routes (marker)
Group, Use, Route funcs Listener groups and middleware attachment (markers)
Extract func Declares a per-request parameter extractor (marker)
HTTPOption type Group/Use/Route’s opaque return type
Value func Declares a type the caller supplies (marker)
Include func Splices another function’s marker list in (marker)
ConfigFile func Declares the config file //servo:config types read (marker)
Trace func Declares a generated OpenTelemetry wrapper for an interface (marker)
Expected func Names an error the wrapper leaves off the span (marker)
TraceOption type Expected’s opaque return type
Linger, Max funcs Scope policy (markers)
Marker type The markers’ opaque return type
ScopeOption type Linger/Max’s opaque return type
DefaultLinger, DefaultMax consts What an omitted scope option becomes
ErrNoScopeKey … ErrScopeClosed vars The four scope errors
ScopeStats type A point-in-time view of one scope
LingerOverride var Test hook for the linger window
LingerWindow func Reads that hook; called by generated code
Initializer … Readier types The seven capability interfaces
Report type Every node’s outcome for one pass
NodeResult type One node’s outcome
NodeStatus type ok, failed, or abandoned
MergeNodeResults func Combines a node’s per-phase results
RunStop func Runs a stop call under a budget
DefaultStopBudget var The budget every stop call gets
Graph, GraphNode, GraphScope types The resolved graph as data
GraphHTTP, GraphRoute, GraphUse, GraphExtractor types The emitted HTTP plan as data
StartupReport, StartupNode types Per-node Init timings
Response type The sealed base every response kind implements
Json, Xml types Typed response wrappers — the signature carries the schema
JSON, XML funcs Construct the typed wrappers
Text, HTML, Blob, Redirect, Stream funcs The untyped response kinds
WriteResponse func Writes any Response; called by generated adapters
HTTPStatus type One HTTP status, usable as an error
Status var The table of statuses (Status.NOT_FOUND, …)
HTTPConfig type The emitted servers’ listen/TLS/limit config
HTTPListener type One named group’s listener inside HTTPConfig.Groups

Markers

All of them exist to be read, not run. Each one panics if executed, which is what a missing servoinject build tag or a skipped generation looks like at run time — a loud failure instead of a nil app. Full semantics are on Spec file and markers.

Build

func Build(...Marker)

Declares an injector’s roots and bindings. Every argument must be a marker call written inline with explicit type arguments.

Root

func Root[T any]() Marker

Declares T as a root: T and everything it transitively depends on is constructed, and nothing else.

Bind

func Bind[I, C any]() Marker

Declares that concrete type C satisfies interface I wherever I is requested. Wins over structural search, including over an otherwise-unambiguous match.

Override

func Override[I, C any]() Marker

Declares a test-only replacement for I, used only when emitting NewTestApp. Takes priority over a Bind for the same interface.

Scoped

func Scoped[T, I any](...ScopeOption) Marker

Declares T as a keyed, refcounted, lifecycle-managed instance instead of a singleton, reachable through the accessor interface I that you declare in your own package. T must have a ScopeKey method. Panics if it ever runs.

Full treatment: Scoped instances.

HTTP

func HTTP(...HTTPOption) Marker

type HTTPOption struct{}

func Group(name string) HTTPOption
func Use[T any](...HTTPOption) HTTPOption
func Route(pattern string) HTTPOption
func Extract[T any]() Marker

HTTP declares that this injector serves the module’s //servo: route directives: servo generate emits one server per served group — router, typed request decoding, middleware chain, lifecycle — into the generated file. The graph must provide a *HTTPConfig. Group declares a named listener group (and, inside Use, selects one); Use[T] attaches a middleware node (Middleware(next http.Handler) http.Handler) server-, group- or route-wide; Route selects one route inside a Use; Extract[T] declares an extractor node (Extract(r *http.Request) (V, error)) that turns handler parameters of type V into per-request values. All panic if they ever run.

Full treatment: HTTP routes; the marker grammar is on Spec file and markers.

Value

func Value[T any]() Marker

Declares that T is supplied by the caller rather than built by a provider. It beats any provider that also produces T — declaring one is how you say “this comes from the caller”, which is only meaningful if it wins — and a declared T nothing in the graph depends on is a generate-time diagnostic rather than a struct field every caller keeps supplying and the app never reads.

Declaring at least one changes the generated API additively: the injector keeps New(ctx) and gains type Values struct{…} and func NewWith(ctx context.Context, v Values) (*App, error). See Generated API, and Spec file and markers for the full contract.

Include

func Include(func() []Marker) Marker

Splices the marker list returned by the named function into this Build call, so a set of declarations shared by several injectors is written once. The function is named, never called: its body is read as syntax exactly as Build’s own argument list is, which is why it must be exactly return []servo.Marker{ …marker calls… } and why the file it lives in needs the servoinject build tag the same way a spec file does.

It may name a function in another package, includes may nest, a cycle is a diagnostic, and a Bind/Override written locally in the spec file supersedes an included one for the same interface. Full contract: Spec file and markers.

ConfigFile

func ConfigFile(path string) Marker

Declares the config file this injector’s //servo:config types read alongside the environment. The path must be a string literal ending in .json, .yaml, .yml, or .toml — the extension decides which decoder the generated code carries. Precedence per setting is default, then file, then environment; CONFIG_FILE overrides the path at runtime. At most one per Build, and one no config in the graph would read is a diagnostic. Full contract: Generated configuration and Spec file and markers.

Trace

func Trace[I any](...TraceOption) Marker
func Expected(err error) TraceOption
type TraceOption struct{}

Declares that every consumer of interface I receives a generated OpenTelemetry wrapper around whatever I resolves to: a span named <pkg>.<I>/<Method> around every method with a leading context.Context, the span’s context handed on, a non-nil error recorded unless an Expected option names it. The wrapper depends on go.opentelemetry.io/otel/trace.TracerProvider as an ordinary graph node your own constructor supplies. Expected is only legal inside Trace and names a package-level error variable or constant, read as syntax and never evaluated. TraceOption carries no data. Full contract: Traced interfaces.

Linger and Max

func Linger(time.Duration) ScopeOption
func Max(int) ScopeOption

const DefaultLinger = 30 * time.Second
const DefaultMax    = 10_000

Linger is how long a scope keeps an instance alive after its last holder releases it; Max caps how many keys it will hold instances for at once. Both arguments must be constant expressions — the spec file is read, never run — and both panic if they ever execute.

DefaultLinger and DefaultMax are what servo generate bakes in when the option is omitted. They are generate-time constants: changing them changes what the next generation emits and has no effect on already-generated code.

Marker

type Marker struct{}

The opaque return type of every Build marker — Root, Bind, Override, Scoped, HTTP, Extract, Value, Include, ConfigFile and Trace. Carries no data; it exists so Build’s argument list type-checks, and so an included function has a slice element type to return.

ScopeOption

type ScopeOption struct{}

Linger and Max’s opaque return type, for the same reason Marker exists: it gives them a type that makes Scoped’s argument list type-check. It carries no data.

Capability interfaces

type Initializer interface{ Init(ctx context.Context) error }
type Runner      interface{ Run(ctx context.Context) error }
type Drainer     interface{ Drain(ctx context.Context) error }
type Flusher     interface{ Flush(ctx context.Context) error }
type Finalizer   interface{ Stop(ctx context.Context) error }
type Healther    interface{ Health(ctx context.Context) error }
type Readier     interface{ Ready(ctx context.Context) error }

Detected with types.Implements at generation time, never with a runtime type assertion. Declare these method signatures on your component and it joins the corresponding phase; declare none and no lifecycle code is emitted for it. There is no base type to embed and nothing to register.

What each one is for, and when to write one:

Interface It means Write one when
Initializer Acquire what construction could not: dial the connection, open the file, run the migration. Failure aborts startup and rolls back everything already built. Your constructor can produce the value but cannot yet use it. Keep the constructor pure and put the I/O here.
Runner A loop that owns the process’s time: an HTTP server, a queue consumer, a ticker. Returns when its context is cancelled. Your component does work nobody calls it for.
Drainer Stop accepting new work; let what is in flight finish. Requests, messages or jobs can be mid-flight when shutdown starts, and cutting them off would lose them.
Flusher Push buffered state somewhere durable. You hold data in memory that would be lost on exit — a write buffer, a metrics batch, a spool.
Finalizer Release the resource: close the pool, disconnect, unsubscribe. You hold something the OS or a remote service is also tracking.
Healther “This process is not broken.” Restart me if it fails. A dependency can fail in a way that a restart would fix.
Readier “Send me traffic.” Take me out of the load balancer if it fails. You can be alive but temporarily unable to serve — warming a cache, waiting on a leader election.

Drain, Flush and Stop must tolerate a component that was constructed but never Inited. This is the one contract here that is easy to miss and expensive to get wrong. Both rollback paths inside New reach the stop methods of nodes whose Init never ran — see Lifecycle — so a Stop that assumes Init succeeded panics during the unwind of an unrelated failure:

// Wrong: nil pool if Init never ran.
func (d *DB) Stop(ctx context.Context) error { return d.pool.Close() }

// Right.
func (d *DB) Stop(ctx context.Context) error {
	if d.pool == nil {
		return nil
	}
	return d.pool.Close()
}

Three distinctions decide most of the questions people have here:

  • Drain vs Stop. Drain waits for work to finish; Stop releases the resource. A server implements both: Drain stops accepting connections and waits for open ones, then Stop closes the listener. If you only have one of the two behaviours, only write that one.
  • Health vs Ready. Neither is called by servo — you call app.Health(ctx) or app.Ready(ctx) yourself, typically from a handler. The distinction is the Kubernetes one, and conflating them means a slow cache warm-up gets your pod killed rather than briefly removed from the load balancer.
  • A method vs a cleanup func(). Prefer Stop: it takes a context and returns an error, and a cleanup func does neither. Reach for the closure only when teardown is not about the value — removing a temp directory the value never knew about, undoing a global. See the cleanup func.

Every one of these is optional and independent. Implement three and exactly those three are called. When each runs, in what order, and under what budget: Lifecycle.

Reports

Report

type Report struct {
	Nodes []NodeResult
}

func (r Report) Clean() bool
func (r Report) Error() string
func (r Report) Unwrap() []error

Every node’s outcome for one Shutdown, Health or Ready pass, in the order the framework processed them. Shutdown’s report is in the order nodes were actually stopped, which is what makes it assertable rather than assumed.

Clean reports whether every node reached StatusOK. An empty report is clean — a graph with nothing to stop stopped fine.

Error renders every non-OK node as one message, ; -separated: *api.Server: failed: connection reset; *postgres.DB: abandoned: context deadline exceeded. For a clean report it returns the empty string.

Unwrap returns one wrapped error per node that carried one, so errors.Is and errors.As traverse into them normally.

Report satisfies error by value, which composes nicely with errors.Join — the generated New uses exactly that to combine an Init failure with the shutdown it triggered, when that shutdown had something to say. When the rollback comes out clean it returns the bare error instead, because joining a clean report appends its empty Error() as a trailing newline. It also means one trap worth naming:

// Always non-nil, even on a completely clean shutdown.
var err error = app.Shutdown(ctx)

// Correct.
if r := app.Shutdown(ctx); !r.Clean() {
	log.Print(r)
}

A Report value in an error interface is never nil. Check Clean().

NodeResult

type NodeResult struct {
	Name   string
	Status NodeStatus
	Err    error
}

One node’s outcome. Name is the node’s full type string — the same string used as its identity everywhere else, so it lines up with servo explain, servo graph, and GraphNode.Type. Err is nil when Status is StatusOK.

NodeStatus

type NodeStatus int

const (
	StatusOK NodeStatus = iota
	StatusFailed
	StatusAbandoned
)

func (s NodeStatus) String() string
Constant String() Meaning
StatusOK ok Returned nil within its budget
StatusFailed failed Returned an error within its budget
StatusAbandoned abandoned Didn’t return in time; the goroutine was left running

Any other value stringifies as unknown. Health and Ready never produce StatusAbandoned — they apply no budget.

MergeNodeResults

func MergeNodeResults(name string, results ...NodeResult) NodeResult

Combines one node’s per-phase results (Drain, Flush, Stop, cleanup) into the single per-node outcome a Report enumerates. Abandoned outranks failed outranks OK, and every phase error is joined with errors.Join. Called by generated code; exported because generated code is in your package, not servo’s.

Scopes

Scope errors

var ErrNoScopeKey  = errors.New("servo: no scope key in context")
var ErrNoLifetime  = errors.New("servo: context has no Done channel — …")
var ErrScopeFull   = errors.New("servo: scope is at its Max live-instance cap")
var ErrScopeClosed = errors.New("servo: scope is shut down")
Error Returned by Means
ErrNoScopeKey Your own ScopeKey method The context carries no key. A convention, not a requirement — return any error you like
ErrNoLifetime Acquire The context can never be done, so the release backstop would never fire. Background, TODO and WithoutCancel are refused
ErrScopeFull Acquire The scope already holds Max live instances and this key is not one of them
ErrScopeClosed Acquire Shutdown has begun; the scope no longer accepts acquires

All four are distinct sentinels — match them with errors.Is.

ScopeStats

type ScopeStats struct {
	Live      int    `json:"live"`
	Refs      int    `json:"refs"`
	Acquires  uint64 `json:"acquires"`
	Evictions uint64 `json:"evictions"`
	Failures  uint64 `json:"failures"`
}

Returned by a generated accessor’s Stats(). Live counts instances, including one whose teardown is still running — so waiting for it to reach zero is a valid way to wait for a scope to go quiet. Refs is outstanding references across every instance. Acquires and Evictions are monotonic totals, and an eviction counts once its instance has finished draining and stopping. Failures is how many of those evictions did not come out clean — the only signal there is for a mid-life teardown that failed, since no Report is being assembled at the time.

Live and Refs are sampled a nanosecond apart under a scope other goroutines are still using: a snapshot of a moving system, not two halves of one atomic read.

Test- and debug-facing. Wiring it to Prometheus is your job — servo exports no metrics.

LingerOverride and LingerWindow

var LingerOverride time.Duration = -1

func LingerWindow(declared time.Duration) time.Duration

Generated code calls LingerWindow exactly once per scope, inside New, to decide that scope’s window. LingerOverride replaces every declared window when it is non-negative — which is why the “no override” sentinel is -1 and not 0: zero is a real policy.

Set it through servotest.Linger rather than directly. Like DefaultStopBudget, it is a package variable, so tests that use it must not run in parallel with each other or with tests that depend on a scope’s real window, and must set it before New.

HTTP

The types a //servo: handler and its emitted server exchange. Full semantics — the directive grammar, binding rules, and the status contract — live on HTTP routes; this is the API surface only.

The Response family

type Response interface{ /* sealed */ }

type Json[T any] interface { Response; Value() T }
type Xml[T any]  interface { Response; Value() T }

func JSON[T any](v T) Json[T]                          // application/json
func XML[T any](v T) Xml[T]                            // application/xml
func Text(s string) Response                           // text/plain
func HTML(s string) Response                           // text/html
func Blob(contentType string, data []byte) Response    // caller-set type
func Redirect(url string) Response                     // Location header; pair with a 3xx Status
func Stream(contentType string, r io.Reader) Response  // io.Copy body

func WriteResponse(w http.ResponseWriter, code int, res Response) error

Response is what a handler’s first result must satisfy. Its method is unexported, so every value came from one of these constructors — which is why the family can grow without the generator changing. The typed wrappers keep the payload schema in the signature; the untyped kinds declare plain servo.Response. All are interfaces, so the error path is return nil, err, and a nil response written through WriteResponse produces the status code alone (the 204 idiom: return nil, servo.Status.NO_CONTENT). WriteResponse exists because generated adapters live in the user’s package, where the sealed method is unreachable. The typed constructors are spelled JSON/XML because Go permits one identifier per name per package, and the types own Json/Xml.

HTTPStatus and Status

type HTTPStatus struct{ /* unexported */ }

func (s HTTPStatus) Code() int
func (s HTTPStatus) Error() string // canonical RFC 9110 text
func (s HTTPStatus) New(msg string) error
func (s HTTPStatus) Newf(format string, args ...any) error
func (s HTTPStatus) Wrap(err error) error
func (s HTTPStatus) Wrapf(format string, args ...any) error

var Status = struct{ CONTINUE, OK, CREATED, /* … the full RFC 9110 + IANA set */ HTTPStatus }{…}

Status’s fields are the only HTTPStatus values, named as the registry spells them (NOT_FOUND, UNPROCESSABLE_CONTENT, CONTENT_TOO_LARGE). An HTTPStatus is an error, and the constructors return errors carrying both the status and your message, so errors.As recovers the code and errors.Is still finds a %w-wrapped cause — and errors.Is(err, Status.NOT_FOUND) holds too. A bare 2xx entry returned in a handler’s error position is the deliberate way to pick a success code other than 200. Wrap(nil) returns the bare status.

HTTPConfig

type HTTPConfig struct {
	IP   string // empty = all interfaces
	Port uint16

	CertFile string // both set → TLS
	KeyFile  string

	MaxBodyBytes int64 // <= 0 = the generated 1 MiB default

	ReadHeaderTimeout time.Duration // zero = none, matching net/http.Server
	ReadTimeout       time.Duration
	WriteTimeout      time.Duration
	IdleTimeout       time.Duration

	Groups map[string]HTTPListener // one listener per declared group
}

type HTTPListener struct {
	IP   string
	Port uint16
	CertFile, KeyFile string
	MaxBodyBytes int64
	ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout time.Duration
}

The emitted servers’ configuration node: the flat fields are the default group’s listener, and each group declared with servo.Group("name") needs an entry in Groups — missing one fails New with an error naming the group. servo never constructs one — you write an ordinary provider (func NewHTTPConfig() (*servo.HTTPConfig, error)) and it resolves like any other dependency, which is also why it is a struct and not options: where the values come from is your module’s business.

Stop budget

DefaultStopBudget

var DefaultStopBudget = 5 * time.Second

Bounds every shutdown and rollback phase call when no other budget is given. A package variable rather than configuration, because parsing configuration is out of scope for a code generator — set it before New if 5 seconds is wrong for your service.

Note that it applies per phase call, not per node: a component implementing Drain, Flush and Stop can consume three budgets in the worst case. servotest.Timeout shrinks it for a single test and restores it via t.Cleanup.

RunStop

func RunStop(ctx context.Context, budget time.Duration, name string, fn func(context.Context) error) NodeResult

Runs fn in its own goroutine under a context derived from ctx with budget as its deadline, and returns:

  • StatusOK if fn returned nil in time
  • StatusFailed, with the error, if it returned an error in time
  • StatusFailed, with the panic value and the stack, if it panicked
  • StatusAbandoned, with the context’s error, if it didn’t return

The result channel is buffered, so a goroutine that outlives its budget can still send without leaking — but it does keep running. That is the honest trade: servo reports an abandoned node rather than blocking shutdown forever on a component that won’t stop.

A panic in fn is recovered, and reported as one failed node:

*api.Server: failed: servo: *api.Server panicked during stop: send on closed channel
goroutine 42 [running]:
…

It has to be. The goroutine is servo’s, not the caller’s, so no recover in your main can reach a panic here: unrecovered, one panicking Stop kills the process mid-unwind, leaving every node behind it running and no Report to say which. Turning it into a StatusFailed result lets the rest of the teardown finish and names the culprit.

Generated code calls this once per phase per node. You’d call it directly only when writing your own teardown around a graph.

Graph data

Graph and GraphNode

type Graph struct {
	Nodes  []GraphNode  `json:"nodes"`
	Scopes []GraphScope `json:"scopes,omitempty"`
	HTTP   *GraphHTTP   `json:"http,omitempty"`
}

type GraphNode struct {
	Type         string   `json:"type"`
	Level        int      `json:"level"`
	Deps         []string `json:"deps"`
	Capabilities []string `json:"capabilities"`
	Binding      string   `json:"binding"`
	Pos          string   `json:"pos"`
	Scope        string   `json:"scope,omitempty"`
	Wraps        string   `json:"wraps,omitempty"`
}

The resolved graph as plain data, returned by the generated App.Graph() and produced by servo graph --format=json — the same struct populated by both paths, down to nil versus [] and the form of every position, so the build-time and run-time views can’t drift.

Field Contents
Type Full type string, the node’s identity
Level 1 + max(level of deps); the unit of Init concurrency. 0 for a supplied value, which the app has before it builds anything
Deps Direct dependencies, as type strings. nil, not [], when there are none — the generated App.Graph() emits a Go literal, and servo graph --format=json matches it
Capabilities Detected capability names, in a fixed order: Initializer, Runner, Drainer, Flusher, Finalizer, Healther, Readier
Binding explicit bind, sole candidate, sole implementation, supplied for a servo.Value, or traced for a servo.Trace wrapper
Pos The provider’s declaration site — or, for a supplied value, the servo.Value[…]() call site, and for a trace wrapper the servo.Trace[…]() call site. Relative to the module root in both producers, so two checkouts at different absolute paths agree; a provider in another module is named by its package’s import path plus the file name, and one in the standard library as $GOROOT/src/…
Scope The key type of the scope this node belongs to, or empty for a singleton
Wraps For a trace wrapper, the node it decorates — also its first dependency; empty for every other node

Display-only: type strings are labels, never lookup keys, and there is no path from a GraphNode back to the instance it describes.

GraphScope

type GraphScope struct {
	Key       string   `json:"key"`
	Linger    string   `json:"linger"`
	Max       int      `json:"max"`
	Accessors []string `json:"accessors"`
	Members   []string `json:"members"`
	Borrows   []string `json:"borrows"`
}

One per declared scope, in Graph.Scopes. Members is what one instance holds; Borrows is the singletons it shares with the rest of the app. A scoped node’s GraphNode carries the same Key string in its Scope field, and its Level counts from the scope’s own floor.

Both GraphNode.Scope and Graph.Scopes are omitempty, so a graph with nothing scoped serialises exactly as it did before scopes existed.

GraphHTTP

type GraphHTTP struct {
	Groups     []string         `json:"groups,omitempty"`
	Routes     []GraphRoute     `json:"routes"`
	Uses       []GraphUse       `json:"uses,omitempty"`
	Extractors []GraphExtractor `json:"extractors,omitempty"`
}

type GraphRoute struct {
	Method  string   `json:"method"`
	Pattern string   `json:"pattern"`
	Group   string   `json:"group,omitempty"` // "" = the default group
	Handler string   `json:"handler"`
	Args    []string `json:"args,omitempty"` // extracted parameters marked "(extracted)"
	Pos     string   `json:"pos,omitempty"`
}

type GraphUse struct {
	Type  string `json:"type"`
	Scope string `json:"scope"` // "server", "group <name>", or "route <METHOD /pattern>"
}

type GraphExtractor struct {
	Type     string `json:"type"`
	Produces string `json:"produces"`
}

The emitted HTTP plan as data, in Graph.HTTP: the declared groups (the default group is implicit), every served route with its handler and arguments, each servo.Use attachment with what it wraps, and each extractor with the parameter type it produces. Graph.HTTP is omitted entirely when the spec declares no servo.HTTP(), so a graph without it serialises exactly as before.

StartupReport and StartupNode

type StartupReport struct {
	Nodes []StartupNode `json:"nodes"`
}

type StartupNode struct {
	Type     string        `json:"type"`
	Duration time.Duration `json:"duration"`
}

Per-node Init timing, recorded by the generated New and returned by App.Report(). Only nodes implementing Initializer appear. Entries are ordered by level, and within a level that ran concurrently, by completion.