Unified API error envelope + full HTTP status catalogue (401/403/404/ 409/422/429/500/503, maintenance, validation, tenant-disabled, rate-limit, expired-token, invalid-signature) with JSON examples and current frontend reaction behavior, including two flagged pre-existing frontend bugs (expired-token/invalid-signature body-code handling is currently dead code - toAuthErrorShape() ignores fallbackCode for real HTTP errors). Maintenance-mode contract (global/per-tenant/per-module/read-only/ scheduled/feature-disable) with proposed 503 response shapes and an explicit split between "requires backend decision" and "no frontend UI exists yet, requires a future frontend task." These two agents wrote their files before hitting a session usage limit that killed the process before final report-back; content verified complete on disk before committing.
491 lines
20 KiB
Markdown
491 lines
20 KiB
Markdown
# Error Response Contract
|
|
|
|
Single unified error-response format the backend must return for every non-2xx
|
|
response across all API surfaces (marketplace API, payment/QR API, session
|
|
auth API, Ed25519 admin auth API, and any future admin/builder/backoffice
|
|
APIs). Derived by cross-referencing every place the Angular frontend
|
|
currently parses, catches, or reacts to an HTTP error — see
|
|
`docs/context/BACKEND-AUDIT.md` for the full backend-surface audit this is
|
|
based on.
|
|
|
|
**Finding: the frontend does not currently parse any backend error envelope.**
|
|
No `HttpInterceptor` in the pipeline (`src/app/app.config.ts` →
|
|
`mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor,
|
|
adminAuthHeadersInterceptor, cacheInterceptor`) inspects error responses —
|
|
all five only touch outgoing requests or successful GET caching. Every
|
|
consumer that reacts to failure does so on the RxJS/`HttpErrorResponse`
|
|
level (`error.status`, `error.message`), never on a parsed JSON error body.
|
|
The one exception is the Ed25519 admin-auth flow, which has a client-side
|
|
`AuthErrorCode` union but (see §"Known frontend gap" below) currently derives
|
|
it from **HTTP status only**, not from any body field. Because of this, the
|
|
envelope below is a **clean proposal, not a reverse-engineered contract** —
|
|
every shape decision is marked accordingly.
|
|
|
|
## The envelope
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "VALIDATION_FAILED",
|
|
"message": "One or more fields are invalid.",
|
|
"status": 422,
|
|
"requestId": "b3f1c2a0-4e21-4d3a-9e77-1e8f6a2d9c11",
|
|
"details": [
|
|
{ "field": "sku", "code": "REQUIRED", "message": "SKU is required." }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
**Requires backend decision: adopt this envelope.** The frontend has no
|
|
existing opinion to preserve (no code reads `error.error.code` today), so
|
|
this is a recommendation, chosen to be consistent with the shapes the
|
|
frontend *does* already have opinions about:
|
|
|
|
- Top-level `{ code, message, status }` mirrors the existing `AuthError`
|
|
interface (`src/app/core/auth/models/auth-error.model.ts:13-18`) almost
|
|
field-for-field — reusing that shape means the Ed25519 auth module can
|
|
parse the new envelope with only a `status` fallback removed, not a
|
|
rewrite.
|
|
- `details[]` entries `{ field, code, message }` mirror the existing
|
|
client-side `ProjectValidationIssue` convention (`code, message, section,
|
|
fieldKey, severity` — `src/app/features/project-editor/services/
|
|
project-validator.service.ts:23-36`, consumed via `ProjectEditorFacade
|
|
.fieldError(fieldKey)`). No backend field-error shape exists to preserve
|
|
today (admin CRUD is 100% local/mock — see BACKEND-AUDIT.md §14), so this
|
|
is the closest existing frontend convention to align a real one to.
|
|
- `requestId` is new (no frontend code reads it yet) — recommended so
|
|
support/ops can correlate a user-visible failure to server logs. If
|
|
adopted, the frontend would need a small addition to surface it in
|
|
error-state UI (not present today).
|
|
|
|
Field notes:
|
|
|
|
| Field | Required | Notes |
|
|
|---|---|---|
|
|
| `error.code` | yes | Stable, machine-readable, `UPPER_SNAKE_CASE`. Never localized. This is what the frontend should branch on, not `message`. |
|
|
| `error.message` | yes | Human-readable fallback (English), safe to show only when the frontend has no i18n mapping for `code`. Never the sole signal for UI branching. |
|
|
| `error.status` | yes | Must equal the HTTP status of the response (redundant with the transport layer, but the frontend's own `AuthError.status` already carries this, so keep parity). |
|
|
| `error.requestId` | recommended | Opaque correlation id, echoed in logs. |
|
|
| `error.details` | only for 422 | Array of field-level issues, see §422 below. |
|
|
|
|
---
|
|
|
|
## Status-by-status contract
|
|
|
|
### 401 — Unauthenticated / expired token
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "UNAUTHENTICATED",
|
|
"message": "Authentication is required to access this resource.",
|
|
"status": 401,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today:**
|
|
- **Admin Ed25519 flow** (`AuthService.login()`/`refresh()` in
|
|
`src/app/core/auth/services/auth.service.ts`): any `HttpErrorResponse` with
|
|
status 401 is mapped via `authErrorCodeFromStatus()` → `AuthErrorCode
|
|
'unauthorized'`, surfaced by `AuthErrorPageComponent`
|
|
(`src/app/core/auth/pages/auth-error-page.component.ts`) with copy
|
|
"Unauthorized… Sign in" and a button that calls `router.navigateByUrl
|
|
('/admin-login')`.
|
|
- **Customer Telegram session auth** (`TelegramSessionApiService`,
|
|
`AuthService` customer-facing, `src/app/services/auth.service.ts`): no
|
|
code branches on a 401 status anywhere — session validity is instead
|
|
polled via `checkSessionOnce()` returning `AuthSession | null`. **Requires
|
|
backend decision**: whether/how a mid-session 401 on a customer-facing
|
|
marketplace call (e.g. `POST /cart`, `POST /orders`) should be surfaced —
|
|
today it would fall through to each caller's generic `catchError`/`error:`
|
|
handler (if any) with no unified "session expired, please re-auth" UX.
|
|
- **Admin backoffice CRUD (products/orders/users/etc.)**: these facades
|
|
(`AdminUsersFacade`, `AdminOrdersFacade`, …) currently only ever talk to
|
|
local/mock gateways, so no real 401 has ever reached them. Their existing
|
|
generic `error` boolean signal + `common.errorTitle`/`common.errorDescription`
|
|
+ retry button (see "Generic list-page error UI" below) is the pattern a
|
|
real 401 would fall into **unless** the facades are updated to branch on
|
|
status — they don't today.
|
|
|
|
### 403 — Forbidden (wrong role or tenant)
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "FORBIDDEN",
|
|
"message": "Your account does not have permission to perform this action.",
|
|
"status": 403,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today:** Ed25519 admin flow only. `authErrorCodeFromStatus(403)`
|
|
→ `'forbidden'` → `AuthErrorPageComponent` copy "Forbidden… Back to
|
|
dashboard", button `router.navigateByUrl('/backoffice')`. No tenant-scoping
|
|
distinction exists in this code path — a 403 caused by wrong role and a 403
|
|
caused by wrong tenant render identical copy today. **Requires backend
|
|
decision**: if tenant-mismatch should be visually distinct from
|
|
role-mismatch, it needs its own `error.code` (e.g. `TENANT_FORBIDDEN` vs
|
|
`ROLE_FORBIDDEN`) since the frontend has no other signal to key off besides
|
|
status today.
|
|
|
|
### 404 — Not found
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "NOT_FOUND",
|
|
"message": "The requested item could not be found.",
|
|
"status": 404,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today:** No code path distinguishes 404 from any other
|
|
failure. `catalog-container.component.ts` and
|
|
`product-details-container.component.ts` both catch *any* load error and
|
|
render the same generic `catalog.errorTitle`/`productDetails.errorTitle`
|
|
empty-state (`en.ts:176,277`) — a real 404 (product deleted) and a 500
|
|
(server crash) look identical to the user today. **Requires backend
|
|
decision**: whether the frontend should be enhanced to show a distinct
|
|
"this product no longer exists" message for 404 specifically (would need a
|
|
status/code check added to those two containers — not present now).
|
|
|
|
### 409 — Conflict
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "CONFLICT",
|
|
"message": "A category with this slug already exists.",
|
|
"status": 409,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today:** no code catches or branches on 409 anywhere.
|
|
The one related concept in the codebase is `AdminCategoriesGateway
|
|
.isSlugTaken(slug, excludingId)` (BACKEND-AUDIT.md §14) — a **proactive**
|
|
pre-check call the frontend makes *before* submitting, not a reaction to a
|
|
409 conflict response. **Requires backend decision**: whether create/update
|
|
endpoints should also return 409 on the same slug/uniqueness conflict as a
|
|
race-condition backstop, and whether the frontend should add a 409 handler
|
|
that surfaces `error.details` inline (there is no such handler today —
|
|
`isSlugTaken` is the only existing conflict-avoidance mechanism, and it is
|
|
best-effort/TOCTOU-prone).
|
|
|
|
### 422 — Validation failure
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "VALIDATION_FAILED",
|
|
"message": "One or more fields are invalid.",
|
|
"status": 422,
|
|
"requestId": "…",
|
|
"details": [
|
|
{ "field": "sku", "code": "REQUIRED", "message": "SKU is required." },
|
|
{ "field": "price", "code": "OUT_OF_RANGE", "message": "Price must be greater than 0." }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today:** no admin form currently parses a backend
|
|
validation-error body — all admin CRUD is local/mock (BACKEND-AUDIT.md §14),
|
|
so there has never been a real 422 to react to. The frontend **does** have
|
|
an established field-error UI convention worth preserving: `ProjectEditorFacade
|
|
.fieldError(fieldKey): string | null`
|
|
(`src/app/features/project-editor/facade/project-editor.facade.ts:371-374`)
|
|
reads from `issuesByField` (a `Map<fieldKey, ProjectValidationIssue[]>`) and
|
|
returns the first issue's `message`, for inline per-field template binding.
|
|
That mechanism is entirely client-side validation today (`ProjectValidator`
|
|
service), not backend-driven. **Requires backend decision**: adopting
|
|
`details[].field` as the join key would let a future `fieldError()`-style
|
|
adapter merge backend 422 errors into the same inline-error UI pattern
|
|
without inventing a second one — but the adapter itself does not exist yet
|
|
and would need to be built.
|
|
|
|
### 429 — Rate limited
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "RATE_LIMITED",
|
|
"message": "Too many requests. Please slow down.",
|
|
"status": 429,
|
|
"requestId": "…",
|
|
"retryAfterSeconds": 30
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today: none whatsoever.** No interceptor, facade, or
|
|
component in the codebase references `429` or "rate limit" in any form (grepped
|
|
across `src/`). **Requires backend decision** on every aspect:
|
|
- Whether the backend sends a `Retry-After` HTTP header, a body field
|
|
(`retryAfterSeconds` above), or both.
|
|
- Whether the frontend should retry automatically (with backoff) or only
|
|
show the user a "please wait Ns" message. Recommend: since no retry
|
|
interceptor exists today, add one is a new build item, not a config
|
|
change.
|
|
|
|
### 500 — Server error
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "INTERNAL_ERROR",
|
|
"message": "An unexpected error occurred. Please try again.",
|
|
"status": 500,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today:** falls into whichever generic catch-all a given
|
|
caller has:
|
|
- Ed25519 admin flow: `authErrorCodeFromStatus()` default branch → `status
|
|
>= 500 ? 'backend-unavailable' : 'unauthorized'` → same
|
|
"Backend unavailable… Retry" screen as a network-down 503 (see below) —
|
|
the frontend does not distinguish "server is up but this request 500'd"
|
|
from "server is completely unreachable."
|
|
- Admin list pages (`AdminUsersFacade` and siblings): generic `error`
|
|
boolean signal set to `true` in the RxJS `error:` callback, rendering
|
|
`common.errorTitle`/`common.errorDescription` + a retry button that
|
|
re-invokes the same load call. No status differentiation.
|
|
- Storefront catalog/product pages: same generic empty-state pattern as 404
|
|
above.
|
|
- `LocationService.getRegions()`-equivalent: falls back silently to 6
|
|
hardcoded regions on *any* error (including 500), no user-visible error at
|
|
all (`src/app/services/location.service.ts`).
|
|
|
|
### 503 — Maintenance / unavailable
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "SERVICE_UNAVAILABLE",
|
|
"message": "The service is temporarily unavailable. Please try again shortly.",
|
|
"status": 503,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today:** Ed25519 admin flow only, via the same
|
|
`status >= 500` branch as 500 above → `'backend-unavailable'` →
|
|
`AuthErrorPageComponent` "Backend unavailable… Retry." No other code path
|
|
reacts to 503 specifically today (marketplace API calls that 503 would just
|
|
fall into each caller's generic error handling, same as 500 above).
|
|
|
|
**Distinguishing signal from Maintenance mode (see next section):** use
|
|
`error.code`, not the HTTP status. A plain infra 503 (database down,
|
|
overload) should send `"code": "SERVICE_UNAVAILABLE"`; a deliberate
|
|
maintenance window should send `"code": "MAINTENANCE_MODE"` (still with HTTP
|
|
status 503, since it's a byte-identical "the service is not accepting
|
|
requests" situation, but a different reason). This is the only way for the
|
|
frontend to build a distinct maintenance-mode UX later, since status alone
|
|
is not enough. `docs/MAINTENANCE_MODE.md` (sibling task, in progress) owns
|
|
the UX/copy for the maintenance case — this document only fixes the wire
|
|
signal it must key off (`error.code === "MAINTENANCE_MODE"`), so the two
|
|
docs stay consistent without duplicating UX detail here.
|
|
|
|
### Maintenance mode
|
|
|
|
Same HTTP status as above (503), distinguished purely by `error.code`:
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "MAINTENANCE_MODE",
|
|
"message": "This marketplace is temporarily down for maintenance.",
|
|
"status": 503,
|
|
"requestId": "…",
|
|
"maintenanceUntil": "2026-07-26T04:00:00Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
`maintenanceUntil` (ISO 8601, optional) lets the maintenance-mode UX (sibling
|
|
doc) show an ETA if the backend has one. **Requires backend decision:**
|
|
whether `maintenanceUntil` is populated reliably enough to promise in UI, or
|
|
should be treated as advisory-only.
|
|
|
|
**Frontend reaction today:** none — no maintenance-mode concept exists in
|
|
the frontend at all currently (confirmed: no matches for "maintenance" in
|
|
`src/`). This entire row is new; the sibling `docs/MAINTENANCE_MODE.md` task
|
|
should treat it as building from scratch, not preserving anything.
|
|
|
|
### Tenant disabled
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "TENANT_DISABLED",
|
|
"message": "This marketplace is not currently active.",
|
|
"status": 403,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today: none.** Tenant resolution
|
|
(`TenantResolverService`, `src/app/core/config/tenant-resolver.service.ts`)
|
|
only ever resolves *which* tenant a request targets (via host/subdomain); no
|
|
code path in the audited surface handles a backend telling the frontend
|
|
"this tenant exists but is disabled." **Requires backend decision** end to
|
|
end: status code (403 recommended, to reuse the existing `forbidden`
|
|
auth-error screen plumbing, vs. a dedicated status), and whether this should
|
|
route to a dedicated "tenant disabled" screen or reuse
|
|
`AuthErrorPageComponent`'s `forbidden` copy (which currently says "Your
|
|
account role does not have permission" — wrong wording for a
|
|
tenant-disabled scenario, would need a new `AuthErrorCode` entry and copy if
|
|
reused).
|
|
|
|
### Rate limit
|
|
|
|
See **429** above — same contract, called out separately here only because
|
|
the task list asked for it as its own row. No additional distinguishing
|
|
signal needed beyond the 429 status + `RATE_LIMITED` code.
|
|
|
|
### Expired token
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "TOKEN_EXPIRED",
|
|
"message": "Your session has expired. Please sign in again.",
|
|
"status": 401,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Frontend reaction today — known gap, read carefully:** `AuthService
|
|
.refresh()` (`src/app/core/auth/services/auth.service.ts:65-76`) has a
|
|
client-side `AuthErrorCode` value `'session-expired'` and passes it as
|
|
`fallbackCode` into `handleAuthError()`. **However**, `toAuthErrorShape()`
|
|
(lines 110-118) only uses `fallbackCode` when the caught error is **not** an
|
|
`HttpErrorResponse` — for an actual HTTP error it always calls
|
|
`authErrorCodeFromStatus(error.status)`, which maps 401 → `'unauthorized'`,
|
|
never `'session-expired'`, regardless of `fallbackCode`. So today, a real
|
|
backend 401 on `/refresh` renders the **generic "Unauthorized" screen**, not
|
|
"Session expired" — the "Session expired" screen is only ever reached via
|
|
the *no-refresh-token-present* client-side branch (line 67-70), never from a
|
|
real HTTP response. **Requires backend decision + frontend fix**: for a
|
|
distinct "your session expired, please sign in again" screen to actually
|
|
render on a real backend 401, either (a) the backend returns a body
|
|
`error.code: "TOKEN_EXPIRED"` and the frontend is updated to read it instead
|
|
of relying solely on `authErrorCodeFromStatus(status)`, or (b) this
|
|
distinction is accepted as unreachable today and left as future work. Flag
|
|
this gap explicitly to whoever picks up the fix — it is a pre-existing bug,
|
|
not something this contract can silently paper over.
|
|
|
|
### Invalid signature
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "INVALID_SIGNATURE",
|
|
"message": "The signed challenge could not be verified.",
|
|
"status": 401,
|
|
"requestId": "…"
|
|
}
|
|
}
|
|
```
|
|
|
|
Ties to the Ed25519 admin-auth flow documented in `AUTHENTICATION.md`
|
|
(sibling task, in progress) — keep the `code` value (`INVALID_SIGNATURE`)
|
|
consistent with whatever that doc names the failure mode, since this
|
|
contract only defines the wire shape and that doc owns the auth-flow
|
|
narrative.
|
|
|
|
**Frontend reaction today:** same gap as "Expired token" above.
|
|
`AuthService.login()` passes `fallbackCode: 'invalid-signature'` into
|
|
`handleAuthError()`, but `toAuthErrorShape()` discards it for any real
|
|
`HttpErrorResponse` and maps a 401 from `/verify` to the generic
|
|
`'unauthorized'` screen via `authErrorCodeFromStatus()`. The dedicated
|
|
"Invalid signature… Try again" screen
|
|
(`src/app/core/auth/pages/auth-error-page.component.ts:21-25`) exists in the
|
|
copy table but is **currently unreachable from a real backend response** for
|
|
the same reason as `session-expired` above. **Requires backend decision +
|
|
frontend fix**: backend must send a body-level `error.code:
|
|
"INVALID_SIGNATURE"` and the frontend's `toAuthErrorShape()` must be updated
|
|
to prefer a body code over the status-only mapping, or this screen stays
|
|
dead code reachable only via non-HTTP error paths.
|
|
|
|
---
|
|
|
|
## Generic list-page error UI (for reference)
|
|
|
|
Every admin backoffice list page (`AdminUsersFacade`, `AdminOrdersFacade`,
|
|
`AdminMonitoringFacade`, `AdminModerationFacade`, `AdminTransactionsFacade`,
|
|
`AdminProductsFacade`, `AdminCategoriesFacade`, `AdminAnalyticsFacade`,
|
|
`AdminCustomersFacade`, `AdminDashboardFacade`) follows the same shape,
|
|
added by RC-02 (`e153a67 fix(backoffice): add error+retry states to Users,
|
|
Monitoring, Analytics, Reports`):
|
|
|
|
```ts
|
|
readonly error = signal(false);
|
|
// on load:
|
|
error: () => { this.items.set([]); this.loading.set(false); this.error.set(true); }
|
|
```
|
|
|
|
```html
|
|
@else if (facade.error()) {
|
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
|
|
<span slot="actions">
|
|
<app-button variant="primary" (click)="facade.loadAll()">{{ 'common.retry' | translate }}</app-button>
|
|
</span>
|
|
</app-empty-state>
|
|
}
|
|
```
|
|
|
|
This is a **boolean** error flag — it does not branch on HTTP status or
|
|
`error.code` today. Every status in this contract (401/403/404/409/422/429/
|
|
500/503) would currently collapse into the same generic "Something went
|
|
wrong / retry" UI on these pages **unless** the facades are individually
|
|
updated to inspect `error.code`/`status` and branch — none do today. Wiring
|
|
that up is out of scope for this document (it defines the wire contract);
|
|
flagging it here so whoever wires real backends into these facades knows
|
|
the current ceiling of frontend error UX is "generic retry," not
|
|
per-status handling, except in the Ed25519 admin-auth module.
|
|
|
|
---
|
|
|
|
## Summary: "Requires backend decision" items
|
|
|
|
- **Envelope adoption** — the `{ error: { code, message, status, requestId,
|
|
details? } }` shape itself; no frontend code parses any envelope today.
|
|
- **401 on customer-facing marketplace calls** (`/cart`, `/orders`, etc.) —
|
|
no unified "session expired, please re-auth" UX exists for the customer
|
|
Telegram-session flow.
|
|
- **403 tenant-mismatch vs role-mismatch** distinct copy/code.
|
|
- **404 vs generic-error distinct UX** on catalog/product pages (currently
|
|
identical).
|
|
- **409 conflict handling on submit** (today only a proactive
|
|
`isSlugTaken` pre-check exists; no reactive 409 handler).
|
|
- **422 `details[]` → inline field-error adapter** for admin forms (the
|
|
client-side `fieldError()` convention exists but nothing feeds it from a
|
|
backend response yet).
|
|
- **429 rate-limit contract end to end** — header vs body, retry-after
|
|
value, and whether the frontend auto-retries (nothing exists today).
|
|
- **Maintenance-mode `maintenanceUntil` reliability** — advisory only, or
|
|
can the frontend promise an ETA.
|
|
- **Tenant-disabled status code and screen** — reuse `forbidden` copy (wrong
|
|
wording today) vs. add a dedicated `AuthErrorCode`.
|
|
- **Expired-token / invalid-signature body-code fix** — both are
|
|
**pre-existing frontend bugs**, not just missing decisions:
|
|
`toAuthErrorShape()` in `auth.service.ts` currently derives the error code
|
|
from HTTP status only and ignores the `fallbackCode` for real HTTP errors,
|
|
so the `'session-expired'` and `'invalid-signature'` screens are dead code
|
|
from any real backend response today. Fixing this requires both a backend
|
|
body `error.code` and a frontend change to prefer it.
|