2026-09-23·by Sijie Wang#standmeet#architecture#design

display-error-surfacing

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. The cause goes to logs via Unwrap(), 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:

  1. First, errors.As on DisplayError → render Envelope{Status, Code, Message} directly.
  2. Else, first-match errors.Is over declared cases (a slice of Case{Match error, Envelope Envelope}classify.go:19).
  3. 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.ts redirects 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.ts each 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_listDisplay(400, …).
  • endpoint_requiredDisplay(400, …).
  • provider_unreachableDisplayWrap(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.

read next
about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →