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 throughmessage). requestIdis 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 TSWith 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 rejectedfetch(offline, VPS down) falls into theelsebranch and shows the generic fallback — or nothing. For a PWA that's the most frequent case on mobile. Needsnetwork.offline/network.timeoutpseudo-codes. - 429 /
Retry-Afterhandled once, not per screen.
4. Guardrails
A CLAUDE.md convention won't hold over time. Three mechanical checks instead:
no-restricted-syntaxESLint rule (zero dependency) banningnew BadRequestException(& co inapps/api/src, with a message pointing atAppException.- A test asserting every code has an
frandentranslation, 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 *Exceptionacross 54 files, 74 distinct messages - 77
NotFound, 59BadRequest, 22Forbidden, 20Unauthorized, 9Conflict, 7BadGateway, 4ServiceUnavailable, 2InternalServerError - 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


https://github.com/Logan2234/loomkeep/pull/142