Feature Requests
Complete

API error codes: envelope, status fallback and guardrails

Errors surfaced to the user are raw English strings coming straight out of NestJS exceptions. They're ugly and not user friendly:

This is the foundation ticket of a three-part rework (see also "Translate form validation errors" and "Migrate API errors to error codes, domain by domain").

The point

The important part is not translating the ~74 existing messages — it's making it impossible to display a raw API string. Today the default is the opposite: ~40 components do err instanceof ApiError ? err.message : fallback.

Once this ticket lands, the user-visible problem is solved even for the 193 not-yet-migrated throws, and the per-domain migration becomes incremental quality work rather than an emergency.

Scope

1. Stable error envelope

AllExceptionsFilter normalizes every error response to:

{
  "statusCode": 404,
  "code": "library.entry_not_found",   // the only thing the web app reads
  "params": { "title": "Dune" },       // optional, for interpolation
  "requestId": "req-01J…",             // pino-http already generates req.id
  "message": "Library entry not found" // dev-facing: logs, Swagger, never displayed
}
  • 5xx never expose anything but a generic message + requestId (no Prisma stack leaking through message).
  • requestId is shown discreetly under 5xx errors — it links a user report to a Loki line and a GlitchTip issue in one search. Cheap, high support value, especially for self-hosters.

2. Shared code registry

packages/shared/src/error-codes.ts, as const object (same convention as enums.ts). Naming domain.reason in snake_case, so the i18n key is derivable mechanically (error_library_entry_not_found).

API side, an AppException extends HttpException:

throw new AppException(404, ErrorCode.LibraryEntryNotFound, { title });

No code ↔ HTTP status mapping table: the same code can legitimately be a 403 or a 404 depending on private-resource masking.

The API stays locale-agnostic — the web app owns all translation. (Emails are the deliberate exception: they're generated API-side and must be translated server-side from the User's stored locale. Separate mechanism, separate ticket if needed.)

3. Single web-side resolver + HTTP status fallback

One resolveApiError(err): string replaces the ~40 duplicated lines:

const MESSAGES = {
  "library.entry_not_found": () => m.error_library_entry_not_found(),
  // …
} satisfies Record<ErrorCode, () => string>; // exhaustiveness checked by TS

With a fallback per HTTP status (400/401/403/404/409/429/5xx) for unknown codes. Non-negotiable: it keeps a deployed front compatible with a newer API (PWA, service worker cache), and it's what makes the un-migrated throws already render cleanly.

Also handled centrally in request():

  • Network errors are not ApiErrors today. A rejected fetch (offline, VPS down) falls into the else branch and shows the generic fallback — or nothing. For a PWA that's the most frequent case on mobile. Needs network.offline / network.timeout pseudo-codes.
  • 429 / Retry-After handled once, not per screen.

4. Guardrails

A CLAUDE.md convention won't hold over time. Three mechanical checks instead:

  • no-restricted-syntax ESLint rule (zero dependency) banning new BadRequestException( & co in apps/api/src, with a message pointing at AppException.
  • A test asserting every code has an fr and en translation, and no orphan keys — CI fails if a code ships untranslated.
  • The satisfies Record<ErrorCode, …> on the web side breaks the typecheck.

Current state (for sizing)

  • 193 throw new *Exception across 54 files, 74 distinct messages
  • 77 NotFound, 59 BadRequest, 22 Forbidden, 20 Unauthorized, 9 Conflict, 7 BadGateway, 4 ServiceUnavailable, 2 InternalServerError
  • 4 messages are already in French API-side — the boundary is already leaking
  • a proto-code already exists: ForbiddenException("MFA_REQUIRED"), string-sniffed by the web app

~1 day for §1–§3, ~1 day for §4.

1 Comment

Sign in to comment

Logan·1 day ago

Posting anonymously