apistockdocs
v0.4 GitHub apistock.dev
Technical/Architecture

Life of a request

What happens between a client sending a request to a Full app and receiving the response, in order, with the file and function responsible for each step. Read it next to examples/full-single/internal/app/routes.go.

output
client
  │  POST /v1/projects   Authorization: Bearer …   {"name": "First"}
  ▼
http.Server (httpx.NewServer)          timeouts, APP_ADDR
  │
  ▼  middleware, outermost first (routes.go, buildHTTP)
Recover ─ RequestID ─ telemetry ─ AccessLog ─ SecureHeaders ─ CORS ─ CrossOrigin ─ BodyLimit ─ auth.Middleware ─ ratelimit
  │
  ▼
http.ServeMux                         method + path pattern
  │
  ▼
Huma operation (delivery/)            decode, validate → 422, call use case
  │
  ▼
use case (usecase/)                   actor.Require(permission), domain rules, transaction, audit event
  │
  ▼
repository (repository/)              hand-written SQL through pgx
  │
  ▼
PostgreSQL
  │
  ▼  back up the stack
use case returns a value or a domain error
delivery returns the response, or the error
httpx.Mapper turns a domain error into problem+json
AccessLog writes one line; the span ends

1. The server#

cmd/api/main.go loads configuration (app.LoadConfig), builds the app (app.New) and calls Run. Run starts httpx.NewServer(cfg.Addr, handler), next to the background workers (settings listener, job client, job definitions manager, release tracker), under apistock.dev/app's lifecycle: on SIGINT or SIGTERM, /readyz starts failing, the server waits the drain delay (5 s in production, 0 in development) so load balancers stop sending traffic, then gives the server and workers up to 25 s to finish, then the cleanup stack closes what New registered (the database pool, then telemetry) in reverse order of creation.

2. Middleware#

buildHTTP wraps the mux with httpx.Chain(mux, middlewares...); the first in the list runs first.

# Middleware Package What it does Can answer
1 httpx.Recover core httpx Catches a panic further down, logs panic recovered with the stack and request ID, and answers 500 internal_error if no response was started. http.ErrAbortHandler is re-panicked 500
2 httpx.RequestID core httpx, requestid Accepts a valid incoming X-Request-ID or generates req_…, puts it in the context and the response header
3 tel.HTTPMiddleware modules/telemetry (otelhttp) Starts the server span and records HTTP metrics; later log lines carry its trace_id and span_id
4 httpx.AccessLog core httpx After the response, one http request log line with method, route, status, duration_ms, bytes, request_id, trace_id, span_id
5 httpx.SecureHeaders core httpx Security headers on every response; HSTS for 365 days in production
6 httpx.CORS core httpx Answers preflight OPTIONS and sets CORS headers for APP_CORS_ORIGINS. Allowed request headers default to Authorization, Content-Type, X-Request-ID; exposed: X-Request-ID, Retry-After 204 preflight
7 exceptCrossSitePosts(httpx.CrossOrigin) core httpx, app routes.go http.CrossOriginProtection: refuses state-changing browser requests from other origins, using Sec-Fetch-Site and Origin, unless the origin is in APP_CORS_ORIGINS. Non-browser clients send neither header and pass. Skipped for Apple's two cross-site POSTs (callback and notifications), which carry their own proof 403 cross_origin_request_denied
8 httpx.BodyLimit core httpx Refuses a declared Content-Length above APP_MAX_BODY_BYTES; cuts off undeclared bodies at the limit 413 request_too_large
9 auth.Middleware internal/modules/auth, modules/auth Resolves the session: see below 503 auth_unavailable
10 ratelimit.Middleware core ratelimit 60 requests a minute (burst 60) per client IP for non-GET requests under /v1/auth/, and Google and Apple start and callback redirects. Other requests have no key and pass 429 rate_limited

Order matters: a panic anywhere is caught; the request ID and span exist before anything logs; CORS answers preflights before the cross-origin check; the body limit applies before anything reads the body. The auth middleware and rate limiter are added only when the auth module exists, which is always in a Full app.

3. Authentication#

auth.Middleware never rejects a request for missing credentials: it only establishes who is calling. Endpoints decide whether that's required.

  1. authlib.TokenFrom reads Authorization: Bearer <token>, or else the __Host-session cookie.
  2. No token: continue anonymously.
  3. The token is hashed with SHA-256 and looked up in auth_sessions with its user and roles. If the store fails, answer 503 auth_unavailable rather than treating the caller as anonymous.
  4. Not found, expired (idle 14 days, absolute 90 days) or revoked: continue anonymously.
  5. Found: put the principal (user, session, roles, whether the second factor was used and when) and its actor into the context. If the session's last-seen time is at least a minute old (authlib.SessionTouchInterval), update it, which also extends the idle expiry.

Roles are read on every request, so granting or revoking a role applies at once.

4. Routing and decoding#

http.ServeMux matches method and path pattern. Unmatched requests reach the catch-all, which answers 404 not_found with no route matches <METHOD> <path>.

API routes are Huma operations registered in each module's delivery/ package through registerModules (internal/app/modules.go). Huma decodes path, query, header and body into the operation's Go input type and validates it against the struct tags (minLength, enum, format, …). Invalid input never reaches the handler: it answers 422 validation_failed with errors[] naming each field. Unknown JSON fields are ignored (ADR-0027).

5. The use case#

The handler calls one use case method with plain values. A use case, such as projects.Service.Create:

  1. Authorizes. actor.Require(ctx, permission) or the module's own ownership rule; unauthenticated callers get ErrUnauthenticated (401), missing permissions ErrForbidden (403), and roles that need a second factor without one mfa_required (403). Multi-tenant resources call orgs.RequireMember first and hide other organisations as 404.
  2. Applies domain rules from domain/: validation beyond the API schema, state transitions.
  3. Writes in a transaction through the TxManager port when more than one statement must commit together, such as the row and its audit event, or the row and a job.
  4. Records an audit event through audit.Recorder (modules/auditpg), with the actor, request ID and resource. Audit writes use context.WithoutCancel, so a client disconnecting doesn't lose the record.
  5. Returns a domain value or a domain error. Use cases never import net/http or pgx.

6. The repository#

Each operation is one Go file with one SQL constant, run through postgres.DBTX (the pool or a transaction). Expected database conditions become domain errors, such as a unique violation on the projects_owner_name index becoming ErrProjectNameTaken; anything else is wrapped with %v so driver types don't leak. Every query is a traced span with its SQL text and never its arguments.

7. The response or the error#

On success, Huma encodes the output type as JSON with the status the operation declares.

On error, the error travels up unchanged to the delivery layer, which hands it to the app's httpx.Mapper (built in buildHTTP):

  1. An *httpx.Problem anywhere in the chain is used as it is.
  2. Otherwise the first mapping whose error matches with errors.Is gives the status, code and detail. Mappings are registered by openapi.InstallErrors, by buildHTTP for pagination errors, and by each module_<name>.go.
  3. Otherwise: 500 internal_error with a generic detail, and the full error is logged once with the request ID. Internal messages never reach the client.

httpx.WriteProblem writes application/problem+json with Cache-Control: no-store and fills request_id. See error handling.

8. After the response#

AccessLog writes its line, the span ends and is exported if OTEL_EXPORTER_OTLP_ENDPOINT is set. Work queued during the request, such as an email, is a River job row that committed with the request's transaction or on its own; a worker in the same process picks it up, carrying the request ID, trace and actor in the job's metadata (background jobs).

Following one request#

Every response carries X-Request-ID, and every problem body its request_id. Search the logs for it to find the access log line, any error logged for it, and (with tracing on) the trace with every SQL query and job it caused:

output
level=INFO msg="http request" service=acme-api method=POST route="/v1/projects" status=201 duration_ms=4 bytes=180 request_id=req_… trace_id=… span_id=…
esc
↑↓ move↵ openesc close