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

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.tsmockDataInterceptor, 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 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, severitysrc/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

{
  "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)

{
  "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-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

{
  "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

{
  "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 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.