Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

FieldDefaultPurpose
Port8080TCP port the HTTP server binds to; outside 1–65535 is refused at startup
PathPrefix/apiURL prefix prepended to generated model and documentation routes; normalised
Documentationzero 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/staticURL prefix the static directory is mounted under, at the router root
StaticDisabledfalseturn static file serving off even when StaticDir is set
StaticDirectoryListingfalseserve a listing for a static directory with no index.html; 404 otherwise
HTTPAccessControlledfalseassert 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:

FieldDefaultPurpose
ReadHeaderTimeout10show long a connection may take to send its request headers
IdleTimeout120show long a keep-alive connection may sit idle between requests
BodyReadTimeout30stime to wait for the next chunk of a request body, refreshed on every read
ReadTimeout0 (unbounded)time to read an entire request, headers and body
WriteTimeout0 (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 ReadTimeout caps how long a client may take to upload, so a large file over a slow link is severed mid-transfer.
  • A WriteTimeout covers 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

FieldDefaultPurpose
TrustedProxiesnoneCIDRs or bare IPs whose forwarding headers may be believed; a non-empty list enables resolution on its own
TrustProxyHeadersfalselegacy 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

FieldDefaultPurpose
MaxConcurrentRequests0 (unlimited)how many requests may be in flight at once, server-wide; over the limit is refused, not queued
MaxConcurrentExports4how many GET /:model/export requests may run at once, server-wide; negative disables the limit
QueryLimitssee belowbounds 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:

FieldDefault
MaxURLBytes8 KiB
MaxFilterClauses / MaxFilterGroups / MaxFiltersPerGroup32 / 8 / 8
MaxSortFields / MaxSelectFields / MaxIncludes8 / 64 / 8
MaxAggregateSelectFields / MaxAggregateGroupFields16 / 8
MaxAggregateFilters / MaxAggregateHaving / MaxAggregateSortFields32 / 16 / 8
DefaultAggregateRows / MaxAggregateRows100 / 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

FieldDefaultPurpose
DBnilthe default DBAdapter. Usually set via server.SetDB(db) after MustRegister. Optional when every model has its own ModelConfig.Adapter — see Per-model adapter routing
DisableAutoMigratefalseskip 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)
QueryTimeout0 (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

FieldPurpose
FilesConfig.Storagemaniflex.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.AllowPublicexplicit declaration that mounted standalone /files routes are intentionally public; used by ValidateProduction and strict startup validation
KeyProvidermaniflex.KeyProvider for mfx:"encrypted" fields. Without one, encrypted fields refuse writes with 500 ENCRYPTION_NOT_CONFIGURED.

Logging

FieldDefaultPurpose
Loggerslog.Default()logger used for lifecycle, per-request, and adapter messages
PanicLoggerfalls back to Loggersink for the panic recoverer’s structured panic records
OnBackgroundPanicnilcalled after a recovered background-goroutine panic — see Graceful Shutdown
Tracezero (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-flagEffect
Enabledshorthand for Steps + Timings + Aborts
Stepsenter/exit record per middleware
Timingsper-middleware elapsed time on exit records
Abortsthe source file:line of every ctx.Abort call
Bodieslog field names present in ctx.ParsedBody (opt-in; may expose sensitive field names)
Skipslog 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

FieldDefaultPurpose
ShutdownTimeout30smaximum 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:

EndpointAnswersBehaviour
GET {prefix}/liveis this process alive?always 200 {"status":"ok"}. No I/O, no dependency, no lifecycle coupling — including throughout the drain
GET {prefix}/readyshould 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:

HealthCheckDBDatabaseResponse
offnot checked200 {"status":"ok"}
onreachable200 {"status":"ok","db":"ok"}
onunreachable503 {"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.

FieldDefaultPurpose
ReadinessChecksnonethe application’s own dependency probes, reported by /ready beside the built-in db check
HealthTimeout3sbudget shared by all dependency checks on /ready, and by /health when HealthCheckDB is on
HealthCheckDBfalsewhen 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},
}
FieldPurpose
Probes.Middlewarewraps every mounted probe, in order
Probes.{Live,Ready,Health}.Middlewarewraps that one probe, after the shared chain — appended, not instead of it
Probes.{Live,Ready,Health}.Disabledleaves that probe off the router entirely
Probes.PublishReadinessCheckswrites 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 a 401 is 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 /ready alone is usually what you want.

If you do gate /live, the probe has to carry the credential, and a kubelet httpGet varies a path far more easily than it rotates a header:

livenessProbe:
  httpGet:
    path: /api/live?token=...
    port: 8080

Which 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:

VariableFieldValue
PORTPortinteger, 1–65535
DB_WRITE_URLDBWriteURLstring
DB_READ_URLDBReadURLstring
QUERY_TIMEOUT_MSQueryTimeoutpositive integer, milliseconds
SHUTDOWN_TIMEOUT_SShutdownTimeoutpositive integer, seconds
SERVICE_NAMEServiceNamestring
HEALTH_CHECK_DBHealthCheckDBtrue/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.