feat: centralize API error localization
Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.
New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.
ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { ApiError } from "./apiError";
|
||||
|
||||
export interface ApiErrorInfo {
|
||||
title: string;
|
||||
explanation: string;
|
||||
nextStep?: string;
|
||||
technical: string;
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// Backend AppError codes this frontend knows how to present with a localized title,
|
||||
// explanation and (where useful) a next step -- see
|
||||
// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site.
|
||||
// Anything not in this list still gets a sensible HTTP-status-based fallback below, so
|
||||
// a newly-introduced backend code never regresses to raw English -- it just falls back
|
||||
// to a generic-but-localized message until this list is extended.
|
||||
export const KNOWN_CODES = new Set([
|
||||
"VEHICLE_NOT_FOUND",
|
||||
"BOOKING_NOT_FOUND",
|
||||
"CUSTOMER_NOT_FOUND",
|
||||
"ENTITY_NOT_FOUND",
|
||||
"EVENT_NOT_FOUND",
|
||||
"ISSUE_NOT_FOUND",
|
||||
"ISSUE_NOT_OPEN",
|
||||
"BOOKING_NOT_ACTIVE",
|
||||
"INVALID_BOOKING_STATE",
|
||||
"NOT_RETRYABLE",
|
||||
"CONFLICT_STILL_PRESENT",
|
||||
"OVERLAP_STILL_PRESENT",
|
||||
"EMPTY_VALUE",
|
||||
"NO_FIELDS_PROVIDED",
|
||||
"INVALID_FIELD",
|
||||
"INVALID_FIELD_OVERRIDE",
|
||||
"INVALID_SURVIVOR",
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"INVALID_EVENT_ID",
|
||||
"INVALID_IDEMPOTENCY_KEY",
|
||||
"IDEMPOTENCY_KEY_REUSED",
|
||||
"CORRECTED_VALUE_REQUIRED",
|
||||
"CORRECTION_BELOW_CANONICAL",
|
||||
"NOT_A_DUPLICATE_ISSUE",
|
||||
"NOT_A_MISSING_FIELD_ISSUE",
|
||||
"NOT_AN_ODOMETER_ISSUE",
|
||||
"NOT_AN_OVERLAP_ISSUE",
|
||||
"NOT_A_STATUS_CONFLICT_ISSUE",
|
||||
"UNSUPPORTED_ENTITY",
|
||||
"MANUAL_REVIEW_REQUIRED",
|
||||
"NO_CONFLICT_DETECTED",
|
||||
"RECOMMENDATION_STALE",
|
||||
"UNAUTHORIZED_SERVICE",
|
||||
]);
|
||||
|
||||
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
|
||||
|
||||
/**
|
||||
* Turns a caught error into a localized {title, explanation, nextStep?, technical}
|
||||
* for display. The raw backend/network text is only ever exposed as `technical`
|
||||
* (shown under "Technical details" by ApiErrorNotice) -- never as the primary message.
|
||||
*
|
||||
* `fallbackKey` is an existing, already-localized `t()` key used as the explanation
|
||||
* when the error isn't an ApiError at all (e.g. the fetch failed before a response
|
||||
* existed) and errors:generic doesn't fit the specific action being attempted.
|
||||
*/
|
||||
export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo {
|
||||
if (!(err instanceof ApiError)) {
|
||||
return {
|
||||
title: t("errors:generic.title"),
|
||||
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
|
||||
technical: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
if (KNOWN_CODES.has(err.code)) {
|
||||
const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" });
|
||||
return {
|
||||
title: t(`errors:codes.${err.code}.title`),
|
||||
explanation: t(`errors:codes.${err.code}.explanation`),
|
||||
nextStep: nextStep || undefined,
|
||||
technical: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status);
|
||||
if (KNOWN_HTTP_STATUSES.has(httpKey)) {
|
||||
const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" });
|
||||
return {
|
||||
title: t(`errors:http.${httpKey}.title`),
|
||||
explanation: t(`errors:http.${httpKey}.explanation`),
|
||||
nextStep: nextStep || undefined,
|
||||
technical: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t("errors:generic.title"),
|
||||
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
|
||||
technical: err.message,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user