Framework

Server

The chi-based HTTP chassis: Config (timeouts, CORS, body limit), the shared lifecycle, and how server.Run wires a router.

server is the framework's HTTP chassis: a chi router over the standard net/http, with the framework's error handling, validation and telemetry wired in, run under the shared lifecycle. Its positioning is stdlib-first: chi is not a framework, it is routing over net/http. Every middleware is a plain func(http.Handler) http.Handler, every handler is http.HandlerFunc-compatible, so any net/http tool in the Go ecosystem (middleware libraries, httptest, profilers) works unmodified.

import "github.com/gp-system/framework/server"

It is the chassis every generated project sits on; application code never talks to net/http directly, it calls server.Run once, in main.

Config

In a generated project, server.Config is embedded without a prefix in the application config and loaded from environment variables by envconf.MustLoad:

internal/platform/config/config.go
type Config struct {
    Server server.Config
    DB     dbx.Config `envPrefix:"DB_"`
    // ...
}

The fields and their environment variables:

FieldEnvDefaultWhat it controls
ListenAddrLISTEN_ADDR:3000the address the server listens on
ShutdownTimeoutSHUTDOWN_TIMEOUT10show long in-flight requests get after SIGINT/SIGTERM
ReadHeaderTimeoutREAD_HEADER_TIMEOUT5show long a client may take to send request headers (Slowloris defense)
ReadTimeoutREAD_TIMEOUT30sbudget for reading the whole request (headers + body)
WriteTimeoutWRITE_TIMEOUT0s (disabled)budget for writing the response
IdleTimeoutIDLE_TIMEOUT120show long an idle keep-alive connection stays open
CORSOriginsCORS_ORIGINS*allowed origins, comma-separated
CORSMethodsCORS_METHODSGET,POST,PUT,PATCH,DELETE,OPTIONSthe methods CORS preflights allow
CORSHeadersCORS_HEADERSContent-Type,Authorizationthe request headers CORS preflights allow
CORSAllowCredentialsCORS_ALLOW_CREDENTIALSfalsecookies/credentials on cross-origin requests (invalid together with wildcard origins)
TrustProxyHTTP_TRUST_PROXYfalsethe realip middleware: RemoteAddr rewritten from X-Real-Ip/X-Forwarded-For; enable only behind a proxy that overwrites those headers
AccessLogHTTP_ACCESS_LOGfalsethe structured access log (one record per request); telemetry dev mode always logs regardless
RequestTimeoutHTTP_REQUEST_TIMEOUT0s (disabled)the timeout middleware: a per-request context deadline; off by default because it also caps streaming responses
BodyLimitBODY_LIMIT4194304 (4 MiB)max request body size in bytes
ExposeInternalErrorsHTTP_EXPOSE_INTERNAL_ERRORSfalseinternal error details in 5xx responses (dev only!)
Telemetry(embedded)nonetelemetry.Config (OTel, logger, Sentry)

The full env reference (including the DB, worker and telemetry variables): Configuration reference.

Timeouts

The defaults are tuned for production traffic, not demos:

  • ReadHeaderTimeout is the first line of Slowloris defense: a client that drips headers byte by byte gets disconnected after 5 seconds.
  • ReadTimeout covers reading the whole request.
  • WriteTimeout is deliberately 0 (disabled): it would also cut off slow or streaming responses (large downloads, SSE). Enable it when all your endpoints are short-lived.

Body limit: never accidentally disabled

const DefaultBodyLimit = 4 << 20 // 4 MiB

func (c Config) EffectiveBodyLimit() int

server never reads the raw BodyLimit field; it calls EffectiveBodyLimit(): a 0 or negative value does not mean "unlimited", it falls back to the 4 MiB default. A typo'd or empty BODY_LIMIT therefore cannot silently remove the cap. Exceeding the limit produces a 413 problem response.

HTTP_EXPOSE_INTERNAL_ERRORS

With true, 5xx responses carry the underlying error message in detail, and, when the chain holds an errs error, the response also gains stack and chain extension members. It is deliberately a separate switch from OTEL_DEV_MODE: turning on dev logging can never accidentally start leaking internals to clients. Keep it false in production: details go to the log and Sentry there, not into the response.

The embedded telemetry config

The Telemetry field is a telemetry.Config: settings for the OTel bootstrap, the default slog logger and the optional Sentry integration (OTEL_SERVICE_NAME, OTEL_DEV_MODE, LOG_LEVEL, SENTRY_DSN, ..., all standard names, no prefix). server.Run passes it, alongside cfg.Log, to app.Telemetry before anything else starts. Details: Observability.

The shared lifecycle

server does not implement graceful shutdown itself: it delegates to the engine-agnostic app package, the same one worker and realtime sit on. The flow is identical in every gpsystem process:

  1. app.Telemetry: OTel, logger, Sentry.
  2. Route registration (your register function).
  3. Listen: the process blocks from here.
  4. On SIGINT/SIGTERM: the server stops accepting requests and drains in-flight ones within ShutdownTimeout. A second signal kills the process immediately.
  5. Cleanups registered with WithCloser run in LIFO order (with their own grace period, even when the drain consumed the budget).
  6. Telemetry flushes last: spans emitted by closers still get exported.

The exact ordering and signal-handling details live on the Application lifecycle page; this page covers what server.Run itself wires on top of that shared skeleton.

server.Config carries the full web-server configuration: every field is loaded from an environment variable, typed, and graceful shutdown is the binary's job, not an external process manager's.

What server.Run wires

func Run(ctx context.Context, cfg Config, register RegisterFunc, opts ...Option) error
func New(cfg Config, opts ...Option) *chi.Mux // for tests and special cases

type RegisterFunc func(r chi.Router) error

Run blocks until exit: telemetry bootstrap (app.Telemetry) → router construction → register(r)http.Server + ListenAndServe → graceful shutdown on SIGINT/SIGTERM. It returns nil on clean shutdown. New returns the fully wired router without running it or bootstrapping telemetry: you pair it with httptest (see Testing).

The middleware chain is a named, ordered stack (server.Stack), pre-populated with the framework defaults. Each entry has a name; the server.MW* constants spell them, so a typo in a stack mutation is a compile error. The default order, outermost first:

NamePresentWhat it does
requestid (MWRequestID)alwaysassigns every request an id: a well-formed inbound X-Request-Id is kept, otherwise a UUID is generated; echoed on the response header, readable via httpmw.RequestIDFromContext
realip (MWRealIP)only with HTTP_TRUST_PROXY=truerewrites RemoteAddr from X-Real-Ip/X-Forwarded-For (first hop), so logs and Sentry see the client address instead of the proxy's
otel (MWOtel)unless WithoutTelemetryper-request server span + HTTP metrics (OpenTelemetry); the span wraps recovery and logging, so a recovered 500 is recorded on it and access-log records carry trace context
sentryhub (MWSentryHub)alwaysan isolated Sentry hub per request, so breadcrumbs never mix across requests; no-op when Sentry is off
recover (MWRecover)alwaysa panic becomes an errs error whose stack points at the panic site, rendered as a 500 problem response (it re-raises http.ErrAbortHandler, net/http's flow-control signal)
validator (MWValidator)only with WithValidatorputs the validator given via WithValidator into the request context, where StrictValidator(nil) finds it (see below)
accesslog (MWAccessLog)with HTTP_ACCESS_LOG=true or OTEL_DEV_MODE=trueone structured slog record per request: method, path, route (the chi route pattern), status, bytes, duration, remote, request_id; 5xx at error level; sits inside recover, so a panicking request still logs (with panic=true)
cors (MWCORS)alwaysfrom CORS_ORIGINS, CORS_METHODS, CORS_HEADERS and CORS_ALLOW_CREDENTIALS
bodylimit (MWBodyLimit)alwayswraps the body in http.MaxBytesReader with cfg.EffectiveBodyLimit(): exceeding it yields a 413 problem
timeout (MWTimeout)only with HTTP_REQUEST_TIMEOUT > 0cancels the request context after the configured duration, nearest the handler

WithMiddleware and WithStack extras apply on top of this. The name auth (MWAuth) is reserved for the identity middleware when a project mounts it on the whole stack; the framework installs no default entry under it (add auth wires identity at the api router instead). Below the stack, the router's NotFound/MethodNotAllowed handlers render 404/405 as problem+json instead of chi's stock plain-text errors; mounted subrouters inherit them.

The built-in entries themselves are exported building blocks in the gpsystem/httpmw package (RequestID, RealIP, Otel, RequestHub, Recoverer, AccessLog, CORSAPI, ...), shared with the realtime gateway, so a custom stack can reuse or rewrap them.

Two behavior notes. Every response carries an X-Request-Id header, so a support ticket, a log record and a trace can reference the same request. And the dev console request log is no longer chi's stock Logger but the structured access log above (telemetry dev mode always enables it): with the OTel slog bridge active its records are trace-correlated.

The server.Config timeouts translate into the http.Server (ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout).

Error rendering: net/http has no central error handler

Unlike frameworks with a built-in error hook, net/http gives a handler no way to "return" an error to the framework: a handler writes the response itself. The framework bridges this at three points so the on-the-wire behavior is uniform everywhere:

  • the generated server code's error hooks are wired to the httperr net/http writers (WriteError, WriteBadRequest): an error returned from your handler goes through the full mapping;
  • the router-level cases (panic, 404, 405, body limit) are rendered as problems per the table above;
  • request validation does not run at bind time (the strict server decodes with json.Decoder) but in the server.StrictValidator strict middleware (see below).

The shop entry point

The sample app's HTTP binary, every line of which was written by the generator:

cmd/shop/main.go
package main

import (
    "context"
    "log"

    "github.com/go-chi/chi/v5"

    "github.com/gp-system/dbx/pg"
    "github.com/gp-system/envconf"
    "github.com/gp-system/events/outbox"
    "github.com/gp-system/framework/server"

    "github.com/acme/shop/internal/modules/shop"
    "github.com/acme/shop/internal/platform/config"
)

func main() {
    cfg := envconf.MustLoad[config.Config]()
    ctx := context.Background()
    pool := pg.MustNewPool(ctx, cfg.DB)

    err := server.Run(ctx, cfg.Server, func(r chi.Router) error {
        api := chi.NewRouter()
        api.Use(server.IdentityMiddleware(cfg.JWT))
        // gpsystem:api-middleware
        deps := shop.Dependencies{
            DB:         pg.NewDB(pool),
            Transactor: pg.NewTransactor(pool),
            Dispatcher: outbox.NewDispatcher(outbox.NewStore(pg.NewDB(pool))),
        }
        shop.RegisterApi(api, deps)
        shop.RegisterAdmin(api, deps)
        r.Mount("/api/v1", api)
        return nil
    },
        // gpsystem:middleware
        server.WithCloser("pgxpool", func(context.Context) error {
            pool.Close()
            return nil
        }),
    )
    if err != nil {
        log.Fatal(err)
    }
}

Prefixing happens with Mount: each module's surfaces register on a fresh subrouter, which gets mounted under /api/v1. The two anchor comments are middleware seams: the api.Use(server.IdentityMiddleware(cfg.JWT)) line above them is what add auth inserted at gpsystem:api-middleware, and gpsystem:middleware marks where WithStack/WithMiddleware options go in the option list.

Options

server.WithCloser("pgxpool", func(ctx context.Context) error { pool.Close(); return nil })
server.WithMiddleware(myRateLimiter)                       // ...func(http.Handler) http.Handler
server.WithStack(func(s *server.Stack) {                   // surgery on the named chain
    s.InsertBefore(server.MWCORS, "tenant", tenantMW)
    s.Remove(server.MWSentryHub)
})
server.WithHTTPServer(func(s *http.Server) { s.MaxHeaderBytes = 1 << 16 })
server.WithValidator(validate.New(validate.WithRegister(registerCustomTags)))
server.WithoutTelemetry()                                  // tests / external OTel setup
  • WithCloser(name, fn): cleanup during graceful shutdown, after the server has stopped accepting requests. Closers run in LIFO order; telemetry flushes last, so a span emitted in a closer still gets exported.
  • WithMiddleware(...func(http.Handler) http.Handler): router-level middleware appended after the framework defaults. The standard net/http middleware shape: any ecosystem middleware fits here. Anything needed by a single surface only should go on that surface's Route instead (see below).
  • WithStack(func(*server.Stack)): everything WithMiddleware cannot do: insert before or after a named default (s.InsertBefore(server.MWCORS, "tenant", tenantMW)), swap an implementation (Replace), or drop one (s.Remove(server.MWSentryHub); removing an absent name is a no-op, so conditional defaults can be removed unconditionally). Mutations panic on an unknown target or a duplicate name: a wiring bug fails at boot, not at request time. Names() returns the chain order for tests.
  • WithHTTPServer(func(*http.Server)): escape hatch for http.Server fields the framework does not expose (TLS, MaxHeaderBytes, ...). You may also override the framework-set fields: your mutation runs last.
  • WithValidator(*validate.Validator): the application-wide request validator, typically one carrying custom validation tags; StrictValidator(nil) middlewares resolve it at request time.
  • WithoutTelemetry(): skips app.Telemetry and the OTel middleware; for tests, or when the process configures telemetry itself.

RegisterFunc and StrictValidator

add surface splices one Register<Surface> function per surface into the module's register.go. For the shop's api surface this is generated verbatim:

internal/modules/shop/register.go
func RegisterApi(router chi.Router, deps Dependencies) {
    apiSvc := apiservice.New()
    apiHandler := apihttp.New(apiSvc)
    apiPolicies := apipolicy.New()
    router.Route("/shop", func(r chi.Router) {
        // gpsystem:surface-middleware
        shopapigen.HandlerWithOptions(shopapigen.NewStrictHandlerWithOptions(
            apiHandler,
            []shopapigen.StrictMiddlewareFunc{
                server.StrictValidator[shopapigen.StrictHandlerFunc](nil),
                // Last = outermost: authorization runs before validation.
                kitpolicy.Enforcer[shopapigen.StrictHandlerFunc](apiPolicies, shopapigen.PermissionByOperation, shopapigen.PolicyByOperation),
            },
            shopapigen.StrictHTTPServerOptions{
                RequestErrorHandlerFunc:  httperr.WriteBadRequest,
                ResponseErrorHandlerFunc: server.WriteError,
            },
        ), shopapigen.ChiServerOptions{
            BaseRouter:       r,
            ErrorHandlerFunc: httperr.WriteBadRequest,
        })
    })
}

Line by line:

  1. Service → handler → policy registry: an explicit construction, no container involved.
  2. router.Route("/shop", ...): the api surface sits at the module root (/api/v1/shop/...); every other surface under its own prefix (/admin/shop).
  3. NewStrictHandlerWithOptions: the generated strict server, plus the error hooks: request-decoding failures (RequestErrorHandlerFunc, ErrorHandlerFunc) render as 400 via httperr.WriteBadRequest, your handler's errors (ResponseErrorHandlerFunc) via server.WriteError, which maps the auth/rbac/policy sentinels onto Problems and delegates everything else to httperr.WriteError, through the full mapping.
  4. server.StrictValidator + kitpolicy.Enforcer: validation and policy enforcement as strict middleware; the last element of the slice wraps outermost, so the enforcer runs before the validator.

StrictValidator: validation before the handler

func StrictValidator[H ~func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error)](
    v *validate.Validator,
) func(f H, operationID string) H

The strict server decodes with json.Decoder, without validation. StrictValidator closes that gap: it validates the fully bound request object (parameters + body) with the framework validator before your handler runs; a failure surfaces as a 422 problem through ResponseErrorHandlerFunc. The type parameter exists because oapi-codegen defines StrictHandlerFunc per generated package.

The validator is resolved per request, in this order: the explicit v argument → the one set via server.WithValidator (from the context) → validate.New(). That's why the generated code passes nil: a single WithValidator option in main.go applies application-wide, and the generated files need no edits.

Surface middleware

Router-level middleware comes from WithMiddleware/WithStack; anything scoped to one surface (like the role protection of the shop's admin surface) you wire inside the surface's Route block, at the // gpsystem:surface-middleware anchor the generator leaves on its first line:

internal/modules/shop/register.go
func RegisterAdmin(router chi.Router, deps Dependencies) {
    adminSvc := adminservice.New()
    adminHandler := adminhttp.New(adminSvc)
    adminPolicies := adminpolicy.New()
    router.Route("/admin/shop", func(r chi.Router) {
        // gpsystem:surface-middleware
        r.Use(server.RequireRole("admin"))
        shopadmingen.HandlerWithOptions(shopadmingen.NewStrictHandlerWithOptions(
            adminHandler,
            []shopadmingen.StrictMiddlewareFunc{
                server.StrictValidator[shopadmingen.StrictHandlerFunc](nil),
                kitpolicy.Enforcer[shopadmingen.StrictHandlerFunc](adminPolicies, shopadmingen.PermissionByOperation, shopadmingen.PolicyByOperation),
            },
            shopadmingen.StrictHTTPServerOptions{
                RequestErrorHandlerFunc:  httperr.WriteBadRequest,
                ResponseErrorHandlerFunc: server.WriteError,
            },
        ), shopadmingen.ChiServerOptions{
            BaseRouter:       r,
            ErrorHandlerFunc: httperr.WriteBadRequest,
        })
    })
}

The rbac.Identity is already in the context: add auth wired server.IdentityMiddleware on the api router, so server.RequireRole("admin") alone protects every /api/v1/admin/shop/... route (401 without an identity, 403 without the role). The api surface's public routes are untouched. In a project without add auth, wire the hard-require server.AuthMiddleware in front of it, passing the auth.Config through the module's Dependencies.

Rule of thumb for middleware levels: process-level (rate limit, tenant resolution) → server.WithMiddleware/server.WithStack; surface-level (auth, role) → r.Use at the gpsystem:surface-middleware anchor in the surface's Route block; operation-level (permission, ownership check) → @permission/@policy in the spec, enforced by the enforcer.

Testing

New returns the fully wired router; you test with plain net/http/httptest:

r := server.New(server.Config{}, server.WithoutTelemetry())
r.Post("/things", createThing)

srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Post(srv.URL+"/things", "application/json", body)

The panic recoverer, the 404/405 problem handlers and the body limit are active here too, so the test sees exactly the problem+json response the client will.

Patterns used

  • Context-injected validator (injectValidator/validatorFromContext, with an unexported context key): Design patterns.
  • Generic middleware adapter (StrictValidator[H ~func(...)], fitting every generated StrictHandlerFunc): Design patterns.
Copyright © 2026