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.
20 KiB
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
{
"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 existingAuthErrorinterface (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 astatusfallback removed, not a rewrite. details[]entries{ field, code, message }mirror the existing client-sideProjectValidationIssueconvention (code, message, section, fieldKey, severity—src/app/features/project-editor/services/ project-validator.service.ts:23-36, consumed viaProjectEditorFacade .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.requestIdis 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
{
"error": {
"code": "UNAUTHENTICATED",
"message": "Authentication is required to access this resource.",
"status": 401,
"requestId": "…"
}
}
Frontend reaction today:
- Admin Ed25519 flow (
AuthService.login()/refresh()insrc/app/core/auth/services/auth.service.ts): anyHttpErrorResponsewith status 401 is mapped viaauthErrorCodeFromStatus()→AuthErrorCode 'unauthorized', surfaced byAuthErrorPageComponent(src/app/core/auth/pages/auth-error-page.component.ts) with copy "Unauthorized… Sign in" and a button that callsrouter.navigateByUrl ('/admin-login'). - Customer Telegram session auth (
TelegramSessionApiService,AuthServicecustomer-facing,src/app/services/auth.service.ts): no code branches on a 401 status anywhere — session validity is instead polled viacheckSessionOnce()returningAuthSession | 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 genericcatchError/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 genericerrorboolean 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)
{
"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
{
"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
{
"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
{
"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
{
"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-AfterHTTP header, a body field (retryAfterSecondsabove), 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
{
"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 (
AdminUsersFacadeand siblings): genericerrorboolean signal set totruein the RxJSerror:callback, renderingcommon.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
{
"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:
{
"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
{
"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
{
"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
{
"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):
readonly error = signal(false);
// on load:
error: () => { this.items.set([]); this.loading.set(false); this.error.set(true); }
@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
isSlugTakenpre-check exists; no reactive 409 handler). - 422
details[]→ inline field-error adapter for admin forms (the client-sidefieldError()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
maintenanceUntilreliability — advisory only, or can the frontend promise an ETA. - Tenant-disabled status code and screen — reuse
forbiddencopy (wrong wording today) vs. add a dedicatedAuthErrorCode. - Expired-token / invalid-signature body-code fix — both are
pre-existing frontend bugs, not just missing decisions:
toAuthErrorShape()inauth.service.tscurrently derives the error code from HTTP status only and ignores thefallbackCodefor 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 bodyerror.codeand a frontend change to prefer it.