Skip to main content

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

  1. Adopt RFC 9457 (application/problem+json) as the single error contract for the whole API. The frontend adapts to it.
  2. detail is 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-readable type URI (the switch-key) plus title/status/instance and schema-derived validation errors[].
  3. Go Huma-native for gatekeeper middleware (Option 1). Auth, RBAC, and the future Stripe middleware become Huma middleware. This requires decoupling token.Manager from *gin.Context first (the enabling step).
  4. 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.
  5. 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 through context.Context end 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

FieldSourceDeveloper action
statussentinel → status table (central)none
titlehttp.StatusText(status) (central)none
typesentinel → type URI (base + slug, central) — the discriminatornone
instancerequest path / trace id, set in the global error hooknone
detailpurged — omitted for domain errors, generic for 5xxnone (forbidden)
errors[]Huma's native JSON-Schema validation, or a Resolverstruct 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 with errors.Is and returns a populated *huma.ErrorModel (which already implements huma.StatusError) carrying type/title/status, no detail — no custom error struct needed. Unmatched → 500 with generic detail; the cause is logged/captured, not echoed. type is the discriminator (two 409s differ by type), so no separate code member is added.
  • The legacy gin ErrorMiddleware is 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.ErrorModel is already application/problem+json and already has Type — so type-only needs no custom struct: ToHumaError returns a populated *huma.ErrorModel, and this hook only stamps instance (and purges 5xx detail). We deliberately do not add a code extension: type is the RFC 9457 discriminator (§3.1.1), and a code slug would just duplicate the type URI'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 no huma.Context, and sentrygin must 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 via Operation.Security); auth maps to an OpenAPI security scheme. RBAC is a per-operation guard via Operation.Middlewares, because the required permission differs per endpoint (§3.6.1). All read identity from context.Context and short-circuit with huma.WriteErr. (Guests won't need a subscription — a future GuestMiddleware carve-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/AppendHeader for Set-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.Securitynext), 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 (mirroring registerPublic for auth) — e.g. a registerNoSub variant or an Operation.Extensions flag. 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:

GatekeeperMechanismWhy
Authglobal api.UseMiddleware reading Operation.SecurityUniform (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.UseMiddlewareUniform (every protected op needs an active subscription). Self-skips public ops via Operation.Security. Carve-out for guests/billing-bootstrap = future (§3.6).
RBACper-op Operation.MiddlewaresThe 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. Metadata has yaml:"-" and is excluded from MarshalJSON, 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 for oauth2/openIdConnect schemes; for our apiKey (cookie) scheme it's a semantic overload. Security is 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 than sentrygin.GetHubFromContext(c). Panic recovery still wraps Huma handlers (they run as gin.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

  1. Error contract first (transport-independent): extend the sentinel table to {status, slug}; add apperr.ToHumaError; retrofit the existing gin ErrorMiddleware + HttpError to emit RFC 9457 from that table. After this, all current Gin routes already speak RFC 9457 — decoupled from Huma. Frontend adapts here.
  2. Decouple token.Manager from *gin.Context (§3.2). Isolated PR, no behavior change.
  3. 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.
  4. Pilot one feature end-to-end (recommended: room-auth) to validate the full path (input struct + validation + identity + RBAC + RFC 9457 errors + Sentry capture).
  5. Migrate features incrementally. Each migrated feature is fully Huma. When the last Gin route is gone, delete the gin ErrorMiddleware and the legacy GetWorkOSTokenInfo(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() (humagin ginCtx.Context(); Register calls handler(ctx.Context(), &input)).
  • c.Set() values (gin Keys) are not visible via ctx.Value() — disjoint from context.Context.
  • Returning a non-StatusError from a Huma handler ⇒ unconditional 500; Huma detects status errors with errors.As, so %w-wrapping a Huma error preserves status.
  • huma.NewError / huma.NewErrorWithContext are reassignable package vars; overriding them customizes the error body, not domain→status mapping (that stays in our table).
  • Default huma.ErrorModelapplication/problem+json (RFC 9457) with Type/Title/Status/Detail/Instance/Errors[].
  • huma.WithValue(ctx, key, value) (since v2.8.0) injects into the underlying context.Context; handlers read via ctx.Value(key). Standard unexported-key-type pattern applies.
  • api.UseMiddleware, huma.WriteErr(api, ctx, status, msg, errs...) (return without next to halt), and ctx.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; the NewErrorWithContext override is the capture hook for handled 5xx.