Display errors — one envelope, log/client separation

Updated · View the entry on sijie.xyz ↗

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_list — Display(400, …).
  • endpoint_required — Display(400, …).
  • provider_unreachable — DisplayWrap(400, …, err): friendly message out, wrapped cause to logs.

Class view

classDiagram
  class DisplayError {
    <<interface structural>>
    error
    +HTTPStatus() int
    +DisplayCode() string
    +DisplayMessage() string
  }
  class displayError {
    -cause error
    -status int
    -code string
    -message string
    +Error() includes cause - logs only
    +Unwrap() cause
  }
  class Envelope {
    Status int
    Code string
    Message string
  }
  class Case {
    Match error - sentinel via errors.Is
    Envelope Envelope
  }
  class Classify {
    <<func>>
    takes err, []Case - returns Envelope
    errors.As DisplayError first
    else first-match over cases
    else 500 server_error
  }
  class APIError {
    <<frontend TS>>
    status, code, message
    401 login, else toast; 409 inline at the call site
  }
  DisplayError <|.. displayError : Display / DisplayWrap
  Classify ..> DisplayError : detects
  Classify ..> Case : declared per handler
  Classify --> Envelope : renders
  Envelope ..> APIError : wire - error code+message

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.

Related notes