Configuration
maniflex.Config is the single struct passed to maniflex.New. Every field has a
sensible default; populate only the ones that differ from those defaults.
server := maniflex.New(maniflex.Config{
Port: 8080,
PathPrefix: "/api",
})
Reading the boolean flags
Config mixes positive and negative flag names — Strict and Public alongside
DisableAutoMigrate, StaticDisabled, ProbeConfig.Disabled. That is one rule
rather than two conventions:
Every boolean is named so its zero value is the behaviour you want if you say nothing.
A feature that is off until asked for gets a positive name, so leaving it unset
leaves it off — Strict, TrustProxyHeaders, Documentation.Public. A feature
that is on by default gets a negative one, so leaving it unset leaves it
working — DisableAutoMigrate, ProbeConfig.Disabled. Naming those positively
would mean AutoMigrate: false silently turning migration off for anyone who
never set it, which is the failure the negative spelling exists to prevent.
StaticDisabled spells the same way for a third shape. Static serving is opt-in
— it needs StaticDir — so the flag turns off something you already asked for,
letting an app that sets StaticDir unconditionally still disable serving from
an env var without clearing the field.
So an empty Config{} is always the intended default, and every field you set is
a deliberate departure from it. Read a negative name as evidence that leaving the
field unset never turns anything off — not that the feature is on by default.
Server
| Field | Default | Purpose |
|---|---|---|
Port | 8080 | TCP port the HTTP server binds to; outside 1–65535 is refused at startup |
PathPrefix | /api | URL prefix prepended to generated model and documentation routes; normalised |
Documentation | zero value (unmounted) | explicitly publish generated OpenAPI/AsyncAPI documents or protect both with shared middleware |
ServiceName | "" | service identifier added to logs, audit records, and the X-Service-Name response header |
StaticDir | "" | filesystem directory served as static files; empty serves nothing (opt-in). Relative paths resolve against cwd |
StaticPrefix | /static | URL prefix the static directory is mounted under, at the router root |
StaticDisabled | false | turn static file serving off even when StaticDir is set |
StaticDirectoryListing | false | serve a listing for a static directory with no index.html; 404 otherwise |
HTTPAccessControlled | false | assert that non-empty HTTPMiddlewares protects every route for ValidateProduction; does not install auth |
Both prefixes are normalised to one leading slash and no trailing one, so
"api", "/api/" and "//api" all mean /api, and "/" mounts the API at the
router root — chi panics on a pattern with no leading slash, and //api would
otherwise mount every route a doubled slash deep, where no client would find it.
Read the value back from the Config for the canonical form. A StaticPrefix
containing {, } or * cannot be guessed at and is refused at startup
instead: the static mount serves a literal path and takes no URL parameters.
PathPrefix does not affect /static or /files; those are mounted at the
router root. The probe endpoints — /live, /ready, and /health — do sit
under it, so the default prefix serves them at /api/live, /api/ready, and
/api/health. See Static Files for the
static serving options.
HTTP timeouts
The framework owns the http.Server, and net/http gives that struct no
deadlines by default. These are the ones it sets on your behalf:
| Field | Default | Purpose |
|---|---|---|
ReadHeaderTimeout | 10s | how long a connection may take to send its request headers |
IdleTimeout | 120s | how long a keep-alive connection may sit idle between requests |
BodyReadTimeout | 30s | time to wait for the next chunk of a request body, refreshed on every read |
ReadTimeout | 0 (unbounded) | time to read an entire request, headers and body |
WriteTimeout | 0 (unbounded) | time to write a response |
ReadHeaderTimeout is the slowloris defence. Without it a client can hold a
connection open forever by dribbling one header byte at a time, and enough such
connections exhaust the server’s file descriptors without a single request ever
reaching the pipeline. Set a negative value to disable a timeout (which is
what net/http reads as “no deadline”) — only sensible behind a proxy that
already bounds header reads.
ReadTimeout and WriteTimeout are deliberately left unset, because both are
whole-request deadlines rather than idle deadlines:
- A
ReadTimeoutcaps how long a client may take to upload, so a large file over a slow link is severed mid-transfer. - A
WriteTimeoutcovers the entire response, so any value at all would cut a long-lived stream —realtime.SSEHandler, a large download — off at that mark.
Set them when you know your request sizes and have no streaming endpoints. The
header phase stays bounded by ReadHeaderTimeout either way.
That left the body phase unbounded, which neither of the other defences
covers: ReadHeaderTimeout is satisfied the moment the headers are complete, and
the body size cap counts bytes rather than seconds. A client could announce a
Content-Length well inside the limit, send none of it, and hold a connection, a
goroutine and a file descriptor for as long as it liked.
BodyReadTimeout closes that. It is an idle bound — the wait for the next
chunk, refreshed on every read — which is what makes it safe on by default where
ReadTimeout is not: an upload that keeps making progress never trips it,
however slow the link or however large the file. These are the semantics of
nginx’s client_body_timeout. No deadline outlives the body, so a handler that
consumes it and then streams for minutes is unaffected.
The bound stands from the moment the request arrives, not from the first read, so
it covers a body no handler ever touches. It has to: Go’s HTTP server drains what
a client announced and never sent so the connection can be reused, and that drain
is one more read from the same silent client. Without it, any route that answers
without reading — a probe, a 401, a 404, an ordinary GET — could be held
open indefinitely.
It does not bound total upload time. A client that sends one byte just inside
the timeout, forever, is still holding a connection; bounding that means
ReadTimeout, with the trade-off above. Setting ReadTimeout disengages
BodyReadTimeout entirely, since the whole-request deadline is the stricter of
the two. A negative value disables it, as with the other timeouts.
Client address behind a proxy
| Field | Default | Purpose |
|---|---|---|
TrustedProxies | none | CIDRs or bare IPs whose forwarding headers may be believed; a non-empty list enables resolution on its own |
TrustProxyHeaders | false | legacy allowlist-free mode — believes the leftmost X-Forwarded-For from any peer |
Off by default, the client address is the direct TCP peer, which a caller cannot
forge. db.RateLimit, idempotency scoping, and read-audit records all key on it.
Config{TrustedProxies: []string{"10.0.0.0/8"}}
Headers are believed only from those peers, and the X-Forwarded-For chain is
walked right-to-left past them: a proxy appends the address it saw, so the
rightmost entries come from infrastructure and the leftmost is whatever the
client chose to send. An entry in Config.TrustedProxies that does not parse is
a startup error.
The walk stops at the first address no trusted proxy vouched for, and reads only that far. Entries further left are the client’s to write and are never consulted, so junk among them changes nothing. An entry that is the stopping point and cannot be read fails the chain closed — the request keeps its TCP peer — rather than being skipped past, which would walk the search onto the client’s own value.
An entry may carry a source port (203.0.113.7:54321, or [2001:db8::1]:443);
several gateways append one. When a chain from a trusted proxy yields nothing
usable, a throttled warning says so — the symptom otherwise is silent, since
every client behind that proxy collapses into one address for rate limiting and
audit.
The chain is every X-Forwarded-For line joined in the order received, not just
the first. Proxies differ here: nginx and AWS ALB extend the client’s line, while
HAProxy’s option forwardfor adds one of its own, leaving the client’s value on
the line above. Both are one chain per RFC 9110 §5.3, and both are walked whole.
X-Real-IP names a single address, so more than one line of it is believed from
nobody — the request keeps its TCP peer.
TrustProxyHeaders: true with no list keeps the old behaviour for compatibility.
It warns at startup and fails under Strict; prefer the allowlist.
Limits
| Field | Default | Purpose |
|---|---|---|
MaxConcurrentRequests | 0 (unlimited) | how many requests may be in flight at once, server-wide; over the limit is refused, not queued |
MaxConcurrentExports | 4 | how many GET /:model/export requests may run at once, server-wide; negative disables the limit |
QueryLimits | see below | bounds client-controlled URL query and aggregate complexity; ModelConfig.QueryLimits can override individual fields for one model |
An export holds its entire result set in memory until the last byte reaches the
client, so concurrency multiplies the largest allocation the server makes. The
per-model MaxExportRows bounds one export’s rows but not the row width nor the
number in flight; this bounds the product. Requests over the limit are refused
immediately with 503 EXPORT_BUSY and a Retry-After, not queued. See
CSV / XLSX Export.
MaxConcurrentRequests does the same for every request, answering
503 SERVER_BUSY with a Retry-After rather than queueing. Without it the
database pool is the concurrency limit by accident, and it is one that fails by
queueing: a burst is not refused, it waits for a connection while holding a
goroutine, a parsed body and a context, until QueryTimeout fires. Latency then
climbs for every request instead of being paid by the ones that are shed.
The probe endpoints are exempt. A shed /live tells Kubernetes to restart the
container and a shed /ready tells it to take the pod out of the Service, so a
spike the limit was absorbing becomes a restart or an empty endpoint list — and
because replicas saturate at the same moment, it happens to all of them at once.
The limit exists to shed individual requests, not the process. /live answers a
constant and the other two collapse onto one dependency check, so answering them
under load is cheap; use Probes.Middleware if you want a probe bounded anyway.
Size it against the pool rather than against the traffic you hope to serve — a
value far above the pool’s MaxOpenConns only moves the queue. It is off by
default because the right number depends on your pool and hardware, and a wrong
one refuses traffic the server could have served;
ValidateProduction requires it to be
set.
QueryLimits uses these safe defaults:
| Field | Default |
|---|---|
MaxURLBytes | 8 KiB |
MaxFilterClauses / MaxFilterGroups / MaxFiltersPerGroup | 32 / 8 / 8 |
MaxSortFields / MaxSelectFields / MaxIncludes | 8 / 64 / 8 |
MaxAggregateSelectFields / MaxAggregateGroupFields | 16 / 8 |
MaxAggregateFilters / MaxAggregateHaving / MaxAggregateSortFields | 32 / 16 / 8 |
DefaultAggregateRows / MaxAggregateRows | 100 / 200 |
A zero field inherits the default (or the global value in a per-model
override); a negative field disables that individual limit. The global
MaxURLBytes remains a router-level hard ceiling for every route. Oversized
URIs return 414 URI_TOO_LONG; invalid list-query shapes return
400 INVALID_QUERY.
Database
| Field | Default | Purpose |
|---|---|---|
DB | nil | the default DBAdapter. Usually set via server.SetDB(db) after MustRegister. Optional when every model has its own ModelConfig.Adapter — see Per-model adapter routing |
DisableAutoMigrate | false | skip the schema migration Start runs on boot (migration runs by default). Does not affect MigrateOnly — see Migrations in production |
DBWriteURL | "" | DSN for the primary database (informational; populated by ConfigFromEnv) |
DBReadURL | "" | DSN for the read replica (informational) |
QueryTimeout | 0 (unlimited) | per-request deadline applied to all DB calls; exceeding it produces 504 TIMEOUT |
See Database Backends for adapter construction.
SetDB, SetStorage, and SetKeyProvider support two-step initialization, but
they are configuration methods rather than runtime rotation APIs. Call them
before Handler, Start, StartServices, or MigrateOnly; those entry points
seal the server, and a later setter call panics without changing either the
configured or active backend. Construct a new server when these dependencies
must change.
File storage and encryption
| Field | Purpose |
|---|---|
FilesConfig.Storage | maniflex.FileStorage implementation for mfx:"file" fields and the /files endpoints. Required if any model uses file uploads. See File Fields & Uploads. |
FilesConfig.BeforeMiddlewares | []maniflex.MiddlewareFunc wrapping the standalone /files endpoints. Empty = no auth (backward-compatible default); production deployments should populate this with at least an auth middleware. See File Fields & Uploads. |
FilesConfig.AllowPublic | explicit declaration that mounted standalone /files routes are intentionally public; used by ValidateProduction and strict startup validation |
KeyProvider | maniflex.KeyProvider for mfx:"encrypted" fields. Without one, encrypted fields refuse writes with 500 ENCRYPTION_NOT_CONFIGURED. |
Logging
| Field | Default | Purpose |
|---|---|---|
Logger | slog.Default() | logger used for lifecycle, per-request, and adapter messages |
PanicLogger | falls back to Logger | sink for the panic recoverer’s structured panic records |
OnBackgroundPanic | nil | called after a recovered background-goroutine panic — see Graceful Shutdown |
Trace | zero (off) | pipeline tracing — see below |
Logger is used by ctx.Logger(), which adds request_id, trace_id, and
service attributes per request. Route it to a JSON handler in production.
PanicLogger receives panics from both the request path and the background
goroutines the framework runs on your behalf, so one sink sees all of them.
Pipeline tracing
Config.Trace enables verbose debug output of the request pipeline. All trace
output is at DEBUG level through Logger, so the handler must accept
DEBUG records to see anything.
| Sub-flag | Effect |
|---|---|
Enabled | shorthand for Steps + Timings + Aborts |
Steps | enter/exit record per middleware |
Timings | per-middleware elapsed time on exit records |
Aborts | the source file:line of every ctx.Abort call |
Bodies | log field names present in ctx.ParsedBody (opt-in; may expose sensitive field names) |
Skips | log middleware skipped by ForModel/ForOperation filters |
cfg.Trace = maniflex.PipelineTrace{Enabled: true, Skips: true}
Enabled expands into the three standard flags unless one of those three is
already set, in which case it stays out of the way and you get exactly what you
named. Bodies and Skips are additive: setting one does not suppress the
expansion, so the example above gives all four.
cfg.Trace = maniflex.PipelineTrace{Enabled: true, Steps: true} // Steps only
cfg.Trace = maniflex.PipelineTrace{Bodies: true} // Bodies only
Leave Bodies off in production.
Lifecycle
| Field | Default | Purpose |
|---|---|---|
ShutdownTimeout | 30s | maximum time Start() waits for in-flight requests to finish on SIGINT / SIGTERM before forcing the listener closed |
See Graceful Shutdown.
Probes
Three endpoints are mounted under PathPrefix. The first two have fixed
meanings and need no configuration:
| Endpoint | Answers | Behaviour |
|---|---|---|
GET {prefix}/live | is this process alive? | always 200 {"status":"ok"}. No I/O, no dependency, no lifecycle coupling — including throughout the drain |
GET {prefix}/ready | should this process receive traffic? | 503 while starting or stopping, otherwise the result of every dependency check |
GET {prefix}/health | (legacy) | meaning follows HealthCheckDB; kept as a compatibility alias |
Point livenessProbe at /live and readinessProbe at /ready. Pointing both
at /health is what the split exists to end: with HealthCheckDB on, an
unreachable database answers 503 to both, so Kubernetes restarts a process
whose only problem is a dependency it cannot fix by dying.
/ready reports the lifecycle first, without touching a dependency:
503 {"status":"starting"}
503 {"status":"stopping"}
stopping appears the moment shutdown begins, which is what deregisters the pod
from its load balancer while in-flight requests drain. Otherwise every
dependency is checked concurrently:
200 {"status":"ok"}
503 {"status":"not_ready"}
The per-dependency results are withheld by default, because the map names every
ReadinessChecks entry and says which of them are failing — telling anyone who
can reach the probe what your application is built on, and when it is degraded.
Set Probes.PublishReadinessChecks where the probe is reachable only from
inside the cluster, or alongside a Probes.Ready.Middleware that says who may
read it:
200 {"status":"ok", "checks":{"db":"ok","broker":"ok"}}
503 {"status":"not_ready", "checks":{"db":"ok","broker":"error"}}
Withholding them costs the orchestrator nothing — the status code is the whole
of its contract — and a failing check is logged through Config.Logger either
way, so a 503 is never undiagnosable. GET {prefix}/health is unaffected: its
db key is a name the framework owns rather than one you chose, so it describes
no topology.
In full, that endpoint answers:
HealthCheckDB | Database | Response |
|---|---|---|
| off | not checked | 200 {"status":"ok"} |
| on | reachable | 200 {"status":"ok","db":"ok"} |
| on | unreachable | 503 {"status":"degraded","db":"error"} |
db also reads unknown — not a failure — when the adapter implements no
Ping, or none is configured yet.
What that discloses to an unauthenticated caller is one bit: whether this
service’s database is reachable. The 503 carries the same bit on its own,
since with HealthCheckDB on the database is the only thing /health checks,
so the key adds no disclosure over the status line. The raw driver error is
logged rather than written, so no DSN fragment reaches the wire. Where even that
bit should not be public, Probes.Health.Middleware puts the endpoint behind
your own check and Probes.Health.Disabled takes it off the router.
A check reads unknown — which is not a failure — when the framework has no way
to test it: an adapter that does not implement Ping, or no adapter configured
at all.
Concurrent probe requests share one run of the checks, so a flood of probes cannot be amplified into a flood against the dependencies they report on. It coalesces rather than caches: a request arriving after the run finished starts a new one, since readiness that is even slightly stale keeps a pod in the load balancer after its database has gone away.
| Field | Default | Purpose |
|---|---|---|
ReadinessChecks | none | the application’s own dependency probes, reported by /ready beside the built-in db check |
HealthTimeout | 3s | budget shared by all dependency checks on /ready, and by /health when HealthCheckDB is on |
HealthCheckDB | false | when true, GET /health pings every distinct registered adapter (Config.DB plus any per-model overrides) and returns 503 on failure. Governs /health alone — /ready always checks the database |
Set HealthTimeout shorter than your probe’s timeoutSeconds so the handler
can return 503 cleanly before the probe times out. It must be positive —
unlike the connection timeouts there is no negative “disable” spelling, and a
negative value is refused at startup.
Add a dependency of your own with ReadinessChecks:
cfg := maniflex.Config{
ReadinessChecks: []maniflex.ReadinessCheck{{
Name: "broker",
Check: func(ctx context.Context) error { return broker.Ping(ctx) },
}},
}
Checks run on every readiness request, so keep them to a pool or connection
probe rather than a full round-trip, and honour the ctx. A check that returns
an error — or panics, which is recovered — makes /ready answer 503. The
error text is logged through Config.Logger and never written to the response:
a probe body is the one place an unauthenticated client reads straight from a
dependency, and connection strings live in those messages.
Names must be non-empty, unique, and not db, which the framework reserves;
anything else panics when the router is built rather than on a probe request.
A mounted probe owns its path. An action or a model whose routes would answer
GET {prefix}/live, /ready or /health is refused when the router is built,
naming the probe it would have replaced — chi overwrites rather than collides, so
without that check the endpoint an orchestrator polls quietly starts running
application code.
The reservation follows what is actually mounted, so Disabled is how you serve
one of these paths yourself:
Config{Probes: maniflex.ProbesConfig{Health: maniflex.ProbeConfig{Disabled: true}}}
// GET /api/health is now free for your own action.
Only the methods the framework serves are reserved. The probes mount GET, so a
POST {prefix}/live action is fine. The same rule covers the other built-in
routes under PathPrefix — /openapi.json, /asyncapi.json, the global search
path, and the /files endpoints — each reserved only while the feature that
mounts it is switched on.
Gating and unmounting the probes
The probes are mounted straight onto the router and never enter the model
pipeline, so no Pipeline.Auth middleware runs for them and
authx.AllowPublic has nothing to exempt them from. That is deliberate: an
orchestrator’s probe is the canonical unauthenticated request.
Config.Probes is the override — the only lever scoped to the probes alone.
(Config.HTTPMiddlewares reaches them too, but it reaches every other route at
the same time.) Its zero value mounts all three publicly.
cfg.Probes = maniflex.ProbesConfig{
// Readiness names your dependencies and reports which are down.
Ready: maniflex.ProbeConfig{
Middleware: []maniflex.HTTPMiddleware{probeToken},
},
// The legacy endpoint, retired in favour of /live and /ready.
Health: maniflex.ProbeConfig{Disabled: true},
}
| Field | Purpose |
|---|---|
Probes.Middleware | wraps every mounted probe, in order |
Probes.{Live,Ready,Health}.Middleware | wraps that one probe, after the shared chain — appended, not instead of it |
Probes.{Live,Ready,Health}.Disabled | leaves that probe off the router entirely |
Probes.PublishReadinessChecks | writes the per-dependency results into the /ready body; off by default |
A disabled probe is not mounted, so the request gets the router’s plain 404
and neither middleware chain runs. That is a more honest answer than a handler
that refuses: a 401 says the endpoint is there.
AdaptAuth reuses pipeline auth middleware here, the same way it does for
Documentation.Middleware:
cfg.Probes.Ready.Middleware = []maniflex.HTTPMiddleware{
maniflex.AdaptAuth(auth.JWTAuth(opts), auth.RequireRole("ops")),
}
Think before gating
/live. A liveness probe that receives a401is a liveness probe that fails, and Kubernetes answers a failing liveness probe by killing the container — during the graceful drain, taking its in-flight requests with it. Gating/readyalone is usually what you want.If you do gate
/live, the probe has to carry the credential, and a kubelethttpGetvaries a path far more easily than it rotates a header:livenessProbe: httpGet: path: /api/live?token=... port: 8080Which means the middleware must accept the query parameter, not only a header.
A public /ready is a defensible default, and the two things that used to make
it uncomfortable are handled without gating: the dependency names are withheld
unless you ask for them, and concurrent requests share one run of the checks.
Gate it when the endpoint is reachable from outside the cluster, or when even
{"status":"not_ready"} is more than you want to publish.
Reading from environment
maniflex.ConfigFromEnv(prefix) populates a Config from a conventional set of
environment variables. Use it for twelve-factor deployments, then override
individual fields in code where needed.
cfg, err := maniflex.ConfigFromEnv("") // or "ORDERS" → ORDERS_PORT, ORDERS_DB_WRITE_URL, …
if err != nil {
log.Fatal(err)
}
cfg.DisableAutoMigrate = true // disable for production
server := maniflex.New(cfg)
These are the variables it reads, and the only ones — anything else on Config
is set in code:
| Variable | Field | Value |
|---|---|---|
PORT | Port | integer, 1–65535 |
DB_WRITE_URL | DBWriteURL | string |
DB_READ_URL | DBReadURL | string |
QUERY_TIMEOUT_MS | QueryTimeout | positive integer, milliseconds |
SHUTDOWN_TIMEOUT_S | ShutdownTimeout | positive integer, seconds |
SERVICE_NAME | ServiceName | string |
HEALTH_CHECK_DB | HealthCheckDB | true/false, 1/0, yes/no, on/off |
A variable that is unset leaves its field at the zero value, for
ApplyDefaults to fill in. A variable that is set but unreadable is an
error — PORT=808O, QUERY_TIMEOUT_MS=abc, HEALTH_CHECK_DB=ture — naming the
variable and the value it could not read. Every bad variable is reported at once,
so two typos take one deploy to find rather than two. Don’t discard this error: a
mistyped PORT that is quietly ignored gives you a healthy-looking server
listening on 8080, and nothing anywhere says why.