Files
marketplaces/docs/ERROR_CONTRACT.md
sdarbinyan 3f5ab30a74 docs: create ERROR_CONTRACT.md and MAINTENANCE_MODE.md
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.
2026-07-26 12:01:27 +04:00

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.