# 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`) 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()) { {{ 'common.retry' | translate }} } ``` 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.