Display errors — one envelope, log/client separation
Parent: structure
Errors that reach the user are wrapped in a single envelope shape; the cause is audited separately in logs, never exposed to the client.
Backend envelope structure
backend/internal/infra/apierr/ (moved under infra/ with 292ce86d2, 2026-07-26): The DisplayError interface is purely structural, requiring no dependency on apierr to implement:
type DisplayError interface {
error
HTTPStatus() int
DisplayCode() string
DisplayMessage() string
}
Any package can satisfy it without importing apierr (no dependency inversion). Two constructors:
Display(status, code, message): wrap a user-facing message.DisplayWrap(status, code, message, cause): wrap both. Thecausegoes to logs viaUnwrap(), never to the client;Error()includes the cause for structured logs;DisplayMessage()stays friendly.
Handler classification: Classify()
Classify(err, cases) in a handler keeps complexity bounded:
- First,
errors.AsonDisplayError→ renderEnvelope{Status, Code, Message}directly. - Else, first-match
errors.Isover declaredcases(a slice ofCase{Match error, Envelope Envelope}—classify.go:19). - Else, fallback to 500
server_error.
Keeps handler cyclomatic complexity ≤ 2; no branching logic in handlers.
Frontend mirror: APIError & branching
app/src/lib/api/api-error.ts defines class APIError reading the envelope:
interface Envelope {
error: {code: string, message: string}
}
class APIError {
status: number
code: string
message: string
}
Frontend branching by status:
- 401 Unauthorized:
use-report-error.tsredirects to login (a toast the user can only stare at is pointless). - 409 Conflict: handled at the call site, not centrally — the mutation hook keeps the envelope's message next to the form (
lib/admin/use-providers.ts,use-microsites.ts,use-assets.tseach branch on 409), never a toast. - Everything else: toast via
lib/ui/toast.tsx+use-report-error.ts.
The per-case branching keeps errors usable: a 409 doesn't get lost in a toast; it lands in the form field it belongs to.
Production example
routes/public/inference_models.go is the first caller; the codes themselves now live in internal/infra/providermodels/list.go (shared by the admin and the public side). Error cases (all 400 — verified in code):
no_model_list—Display(400, …).endpoint_required—Display(400, …).provider_unreachable—DisplayWrap(400, …, err): friendly message out, wrapped cause to logs.
Class view
The structural interface is the whole trick: any package satisfies DisplayError without importing apierr — the arrow points at the interface, never at the package.
Principle
One wire shape, two audiences:
- Operators get causes in logs (structured, debuggable).
- Users get friendly, actionable messages (no leaks, no jargon).
The separation is enforced at the envelope boundary, not left to each handler.