Huma Integration — Error Handling & Middleware Design
Status: Accepted (design) — implementation pending Date: 2026-06-15 Scope: How we wire Huma v2 onto the existing Gin backend — specifically the error-handling contract (RFC 9457) and the middleware/context architecture. This is the reference for the migration PRs; it is a design, not final code.
1. Decisions at a glance
- Adopt RFC 9457 (
application/problem+json) as the single error contract for the whole API. The frontend adapts to it. detailis never written by backend developers and is purged before it reaches the client. Free-text error context stays in logs/Sentry only. The client gets the stable, machine-readabletypeURI (the switch-key) plustitle/status/instanceand schema-derived validationerrors[].- Go Huma-native for gatekeeper middleware (Option 1). Auth, RBAC, and the future Stripe middleware become Huma middleware. This requires decoupling
token.Managerfrom*gin.Contextfirst (the enabling step). - Transport middleware stays at the Gin/router layer — permanently:
sentrygin(hub lifecycle + outermost panic recovery), the access logger, request-id/trace-id stamping. This matches Huma's own guidance and is the end state, not a transition compromise. - No bridge adapter. Because auth becomes a Huma middleware that injects identity directly via
huma.WithValue, there is no gin-Keys→huma seam to bridge. Identity flows throughcontext.Contextend to end.
Why Option 1 over a "keep gin auth + bridge" scaffold: we are committed to Huma across the board and are adding Stripe as a gatekeeper. Option 1 lands directly on the clean end state — one error path, no bridge, gatekeepers first-class in the OpenAPI spec — instead of building scaffolding we would later tear out. The price is one isolated upfront refactor (token.Manager), which we take deliberately.
2. Error handling — RFC 9457
2.1 The contract
Content-Type: application/problem+json. Huma's default huma.ErrorModel is already RFC 9457, so the wire shape is largely free:
// Domain / sentinel error (detail purged)
{
"type": "https://qubital.app/errors/permission-denied", // stable problem CLASS (URI) — the frontend switches on THIS
"title": "Forbidden", // generic, from http.StatusText
"status": 403, // advisory HTTP status (coarse; two types may share it)
"instance": "/realtime/conference-token" // which occurrence (request path / trace id)
// NO "code" — `type` IS the discriminator (RFC 9457 §3.1.1); NO "detail" — logged, never echoed
}
// Validation error — emitted natively by Huma, schema-derived (safe to expose)
{
"type": "https://qubital.app/errors/validation-failed",
"title": "Unprocessable Entity",
"status": 422,
"errors": [
{ "location": "body.email", "message": "expected a valid email", "value": "foo@" }
],
"instance": "/realtime/conference-token"
}
2.2 Who fills each field
| Field | Source | Developer action |
|---|---|---|
status | sentinel → status table (central) | none |
title | http.StatusText(status) (central) | none |
type | sentinel → type URI (base + slug, central) — the discriminator | none |
instance | request path / trace id, set in the global error hook | none |
detail | purged — omitted for domain errors, generic for 5xx | none (forbidden) |
errors[] | Huma's native JSON-Schema validation, or a Resolver | struct tags only |
The generic sentinels fill the generic fields. There is no per-error free text on the wire by policy.
2.3 How developers write errors — unchanged
The repo/service sentinel-wrapping pattern stays exactly as today. The wrapped message is for logs/Sentry and is never surfaced:
// repository — unchanged
return nil, fmt.Errorf("room %s not found for org %s: %w", roomId, orgId, apperr.ErrNotFound)
// service — unchanged
return "", fmt.Errorf("room does not belong to caller's organization: %w", apperr.ErrPermissionDenied)
The only change is at the Huma handler boundary: a raw domain error returned from a Huma handler becomes a 500 (Huma treats any non-StatusError as 500). So handlers return the raw sentinel and a central register-wrapper converts it once (the maintainer-endorsed pattern — see migration plan §4.4 / walkthrough §6.2), never a per-handler errors.Is switch or ToHumaError call:
func (h *RoomHandler) RoomAuth(ctx context.Context, in *RoomAuthInput) (*RoomAuthOutput, error) {
tok, err := auth.IdentityFromContext(ctx)
if err != nil {
return nil, err // raw sentinel — the register-wrapper converts it (no per-handler mapping)
}
token, err := h.service.GenerateToken(ctx, in.Body.Username, in.Body.RoomID, tok.UserID, tok.OrgID, tok.Email)
if err != nil {
return nil, err // ditto — return the raw sentinel; conversion is centralized
}
return &RoomAuthOutput{Body: RoomAuthBody{Token: token}}, nil
}
2.4 Single source of truth: the sentinel table
Extend the existing domainToHTTPCode (pkg/middleware/errormap.go) into one table mapping each apperr sentinel to {status, slug} (the slug forms the type URI — no separate code field). This table feeds both error paths so they stay identical:
var domainTable = map[error]struct{ Status int; Slug string }{ // Slug → type = "https://qubital.app/errors/" + Slug
apperr.ErrInvalidInput: {400, "invalid-input"},
apperr.ErrUnauthenticated: {401, "unauthenticated"},
apperr.ErrQuotaExceeded: {402, "quota-exceeded"},
apperr.ErrPermissionDenied: {403, "permission-denied"},
apperr.ErrNotFound: {404, "not-found"},
apperr.ErrConflict: {409, "conflict"},
apperr.ErrAlreadyExists: {409, "already-exists"},
// … the rest of the 13 sentinels
}
apperr.ToHumaError(err)walks this table witherrors.Isand returns a populated*huma.ErrorModel(which already implementshuma.StatusError) carryingtype/title/status, no detail — no custom error struct needed. Unmatched → 500 with generic detail; the cause is logged/captured, not echoed.typeis the discriminator (two 409s differ bytype), so no separatecodemember is added.- The legacy gin
ErrorMiddlewareis retrofitted to emit the same RFC 9457 shape from the same table, so Gin routes and Huma routes return identical bodies during the transition.
Granularity lever (optional, future): today ErrConflict covers both "room full" and "already exists". If the frontend must distinguish them, add a finer sentinel (e.g. apperr.ErrRoomFull → {409, "room-full"}). This keeps the contract "wrap the right sentinel, write nothing client-facing" — no free-text detail, mapping stays central.
2.5 Global Huma error hook — RFC 9457 polish + Sentry 5xx capture
Huma exposes the reassignable package var huma.NewErrorWithContext. We override it once at startup. It runs for every Huma error (native validation + the ones ToHumaError builds), giving a single place to (a) stamp instance, (b) enforce the detail-purge policy, and (c) capture server errors to Sentry — which is necessary because Huma writes its own response and never calls c.Error(), so the gin error middleware's Sentry capture cannot see Huma errors.
prev := huma.NewErrorWithContext
huma.NewErrorWithContext = func(ctx huma.Context, status int, msg string, errs ...error) huma.StatusError {
if status >= 500 {
if hub := sentry.GetHubFromContext(ctx.Context()); hub != nil { // std-context hub, works in huma
hub.CaptureException(fmt.Errorf("huma %d: %s", status, msg))
}
msg = "" // purge: never echo internal 5xx detail to the client
}
return prev(ctx, status, msg, errs...)
}
Note: the default
huma.ErrorModelis alreadyapplication/problem+jsonand already hasType— so type-only needs no custom struct:ToHumaErrorreturns a populated*huma.ErrorModel, and this hook only stampsinstance(and purges 5xx detail). We deliberately do not add acodeextension:typeis the RFC 9457 discriminator (§3.1.1), and acodeslug would just duplicate thetypeURI's last segment. (Decision: type-only.)
3. Middleware & context (Option 1)
3.1 Principle — transport vs gatekeeper
- Transport (stays Gin, router-level, forever):
sentrygin, access logger, request-id/trace-id. They need nohuma.Context, andsentryginmust be the outermost wrapper for panic recovery + hub lifecycle. Rewriting it as Huma middleware would reimplement a maintained SDK and lose outermost recovery. - Gatekeepers (become Huma middleware): Auth, Subscription (Stripe), RBAC — they run as a chain on protected requests: auth → subscription → rbac. Auth and Subscription are global
api.UseMiddleware(both apply to every protected op and self-skip public ops viaOperation.Security); auth maps to an OpenAPI security scheme. RBAC is a per-operation guard viaOperation.Middlewares, because the required permission differs per endpoint (§3.6.1). All read identity fromcontext.Contextand short-circuit withhuma.WriteErr. (Guests won't need a subscription — a futureGuestMiddlewarecarve-out, out of scope here.)
3.2 The enabling refactor — decouple token.Manager from *gin.Context
Today token.Manager (cookie get/set/delete via securecookie) takes *gin.Context (tm.SessionAccessToken.Get(c), .Set(c, …), .Delete(c)). A Huma middleware receives huma.Context, not *gin.Context. Before auth can be Huma-native, token.Manager must operate on a transport-neutral surface:
- Reads: from
context.Context/ request headers (huma.Context.Header(...), cookie params). - Writes: against an
http.ResponseWriter/ header writer (huma.Context.SetHeader/AppendHeaderforSet-Cookie).
This is an isolated, well-tested PR and is the single prerequisite that removes the need for any gin→huma bridge.
3.3 Auth as a Huma middleware
After 3.2, auth validates the cookie/token, performs the WorkOS refresh + license check, rotates cookies via the header writer, and injects identity directly into context:
api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) {
tok, err := authenticate(ctx) // cookie read, validate, refresh, license — all transport-neutral now
if err != nil {
huma.WriteErr(api, ctx, http.StatusUnauthorized, "", err) // RFC 9457 via the global hook
return // do NOT call next → chain halts
}
ctx = huma.WithValue(ctx, identityKey, tok)
next(ctx)
})
3.4 Typed, adapter-agnostic identity accessor
Handlers and downstream middleware never touch gin. They read identity from context.Context:
type ctxKey struct{ name string }
var identityKey = ctxKey{"workos-token-info"}
func IdentityFromContext(ctx context.Context) (*token.AccessTokenData, error) {
t, ok := ctx.Value(identityKey).(*token.AccessTokenData)
if !ok {
return nil, apperr.ErrUnauthenticated // → 401 via ToHumaError
}
return t, nil
}
3.5 RBAC as a per-operation Huma middleware (Operation.Middlewares)
Replace per-route gin RequirePermission(rbac, perm) with a per-operation Huma guard attached via
Operation.Middlewares (the maintainer-endorsed mechanism for per-route guards — §3.6.1 explains why
this over Metadata or Security scopes). RequirePermission returns a Huma middleware:
func RequirePermission(api huma.API, rbac Authorizer, perm permissions.Permission) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
tok, _ := IdentityFromContext(ctx.Context())
if tok == nil || !rbac.Can(tok.Role, perm) {
huma.WriteErr(api, ctx, http.StatusForbidden, "", apperr.ErrPermissionDenied)
return
}
next(ctx)
}
}
// attached per operation (the register wrapper appends it after the global auth mw):
huma.Register(api, huma.Operation{
OperationID: "start-recording",
Method: http.MethodPost,
Path: "/recordings/start",
Middlewares: huma.Middlewares{RequirePermission(api, rbac, permissions.StartRecording)},
}, h.StartRecording)
Order: the global auth middleware runs before any operation middleware, so identity is present when RBAC runs (confirm global-before-per-op ordering against the pinned Huma version at Phase 0).
3.6 Subscription (Stripe) as a global Huma middleware
Unlike RBAC, the subscription gate is uniform: every protected request requires the caller's org
to have an active subscription. So it is a global api.UseMiddleware(subscriptionMiddleware),
registered after auth (it needs the identity auth injected) and before RBAC. It self-skips
public ops the same way auth does (no cookieAuth in Operation.Security → next), and short-circuits
with huma.WriteErr(api, ctx, http.StatusPaymentRequired, "", apperr.ErrQuotaExceeded):
api.UseMiddleware(authMiddleware) // 1. authn — injects identity
api.UseMiddleware(subscriptionMiddleware) // 2. requireSubscription — all protected ops
// 3. RBAC is per-op (Operation.Middlewares), runs after both globals
Carve-out (open, mostly out of scope here). A few protected ops must NOT require a subscription — guests (a future
GuestMiddleware, explicitly out of scope for this PR) and likely the billing/account bootstrap ops (you must reach "manage subscription" without a subscription). Mechanism to decide with the team: a per-op opt-out the global mw reads (mirroringregisterPublicfor auth) — e.g. aregisterNoSubvariant or anOperation.Extensionsflag. Not built in this PR.
3.6.1 Mechanism choice — global vs per-operation gatekeepers (decision + the Extensions option, to discuss)
Decision. A gatekeeper is global when its requirement is uniform across all protected ops, and per-operation when the requirement differs per endpoint:
| Gatekeeper | Mechanism | Why |
|---|---|---|
| Auth | global api.UseMiddleware reading Operation.Security | Uniform (every protected op needs a valid session). Auth is an OpenAPI security scheme → self-documents in the spec; self-skips public ops. |
| Subscription (Stripe) | global api.UseMiddleware | Uniform (every protected op needs an active subscription). Self-skips public ops via Operation.Security. Carve-out for guests/billing-bootstrap = future (§3.6). |
| RBAC | per-op Operation.Middlewares | The required permission differs per endpoint → it must be declared on the route. |
Execution order: auth (global) → subscription (global) → rbac (per-op Operation.Middlewares) →
handler. Globals run in registration order, before any per-op middleware.
The mechanism debate below is specifically about RBAC (the only per-op gatekeeper):
Why not the alternatives (settled by research):
Operation.Metadata+ a global middleware (this doc's earlier draft): rejected.Metadatahasyaml:"-"and is excluded fromMarshalJSON, so it is never serialized into the OpenAPI spec — the "it self-documents" rationale was false. It is also unattested for authz (no official example, no third-party adoption; documented purpose is codegen tooling).- Permission encoded as a scope in
Operation.Security: rejected. OpenAPI scopes are meaningful only foroauth2/openIdConnectschemes; for ourapiKey(cookie) scheme it's a semantic overload.Securityis for authentication, not authorization — keep RBAC out of it. Operation.Middlewares: chosen. The maintainer-endorsed mechanism for per-route guards (Discussion #389); type-safe (real functions + permission constants), composable. Its only limitation: it is internal-only (not in the spec) — addressed next.
Open question for the team — Operation.Extensions for spec-visibility. Because
Operation.Middlewares doesn't appear in the published spec, a client/reader can't see "this op
needs permission=StartRecording" from the contract. If we want that, the spec-faithful way (Huma
serializes Operation.Extensions as x- properties) is a documentation-only annotation
alongside the guard:
register(api, huma.Operation{
OperationID: "start-recording",
// ...
Extensions: map[string]any{"x-required-permission": "StartRecording"}, // documentation only
}, h.StartRecording, RequirePermission(api, rbac, permissions.StartRecording)) // the per-op guard enforces
Trade-off: the Extensions values are documentation only — visible in the spec/generated client
but not what enforces the rule (the Operation.Middlewares guards do). That's a second place to
keep in sync, and generic OpenAPI tooling won't act on a custom x- field anyway. To discuss with
the team: do we want spec-visible authz/billing requirements enough to accept the duplication, or
keep enforcement-only guards and document requirements out-of-band? Purely additive — no code depends
on the choice.
3.7 Context propagation — what flows for free
The Huma handler's ctx is c.Request.Context() (verified in the humagin adapter). Therefore:
- Logger correlation (trace_id/request_id/user_id/org_id), written via
c.Request = c.Request.WithContext(...)at the transport layer, flows into Huma handlers unchanged.logger.*Ctx(ctx, …)just works. - Sentry hub is on the request context (sentrygin sets it there too) →
sentry.GetHubFromContext(ctx)works inside Huma handlers; use this std-context variant rather thansentrygin.GetHubFromContext(c). Panic recovery still wraps Huma handlers (they run asgin.HandlerFuncs in the same goroutine). - Identity flows because the auth Huma middleware injects it via
huma.WithValue(§3.3) — no gin-Keys involved, no bridge.
4. End-state wiring (sketch)
// Transport — Gin, router-level (outermost), permanent
base := engine.Group("/")
base.Use(sentrygin.New(sentrygin.Options{Repanic: true}), LogContextMiddleware(), DefaultLogger())
// Huma mounted on the protected group so it inherits the transport chain
protected := base.Group("/api/v1")
api := humagin.NewWithGroup(engine, protected, humaConfig)
// Gatekeepers — auth + subscription are GLOBAL (both self-skip public ops); RBAC is per-op.
api.UseMiddleware(authMiddleware) // 1. authn — injects identity via huma.WithValue
api.UseMiddleware(subscriptionMiddleware) // 2. requireSubscription — all protected ops (§3.6)
// RBAC is a PER-OPERATION guard via Operation.Middlewares, attached by the register wrapper
// next to each route. It runs after both globals. See §3.6.1.
// Global error hook: RFC 9457 + Sentry 5xx capture (§2.5)
installHumaErrorHook(api)
// Operations
huma.Register(api, op, handler)
No bridge. One error contract. sentrygin + access log are the only Gin-layer middleware, by design.
5. Sequencing
- Error contract first (transport-independent): extend the sentinel table to
{status, slug}; addapperr.ToHumaError; retrofit the existing ginErrorMiddleware+HttpErrorto emit RFC 9457 from that table. After this, all current Gin routes already speak RFC 9457 — decoupled from Huma. Frontend adapts here. - Decouple
token.Managerfrom*gin.Context(§3.2). Isolated PR, no behavior change. - Stand up Huma on the protected group; install the global error hook (§2.5); port auth (§3.3) and RBAC (§3.5) to Huma middleware.
- Pilot one feature end-to-end (recommended:
room-auth) to validate the full path (input struct + validation + identity + RBAC + RFC 9457 errors + Sentry capture). - Migrate features incrementally. Each migrated feature is fully Huma. When the last Gin route is gone, delete the gin
ErrorMiddlewareand the legacyGetWorkOSTokenInfo(c)accessor.
The gin ErrorMiddleware coexists safely throughout: it no-ops on Huma routes (empty c.Errors + the c.Writer.Written() guard) and serves the not-yet-migrated Gin routes.
6. Source-verified facts behind this design
- Huma handler
ctx==c.Request.Context()(humaginginCtx.Context();Registercallshandler(ctx.Context(), &input)). c.Set()values (gin Keys) are not visible viactx.Value()— disjoint fromcontext.Context.- Returning a non-
StatusErrorfrom a Huma handler ⇒ unconditional 500; Huma detects status errors witherrors.As, so%w-wrapping a Huma error preserves status. huma.NewError/huma.NewErrorWithContextare reassignable package vars; overriding them customizes the error body, not domain→status mapping (that stays in our table).- Default
huma.ErrorModel⇒application/problem+json(RFC 9457) withType/Title/Status/Detail/Instance/Errors[]. huma.WithValue(ctx, key, value)(since v2.8.0) injects into the underlyingcontext.Context; handlers read viactx.Value(key). Standard unexported-key-type pattern applies.api.UseMiddleware,huma.WriteErr(api, ctx, status, msg, errs...)(return withoutnextto halt), andctx.Operation()(matched operation available inside middleware) are the gatekeeper primitives.- sentrygin stores the hub on the request context (and gin) →
sentry.GetHubFromContext(ctx)works in Huma handlers; panic recovery still wraps them. No official Huma↔Sentry integration exists; theNewErrorWithContextoverride is the capture hook for handled 5xx.