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.
22 KiB
Maintenance Mode
Frontend contract for backend maintenance/availability signals: global, per-tenant,
per-module, read-only, scheduled, and single-feature-disable scenarios. Written from
the current source tree (branch B2B) — see docs/context/BACKEND-AUDIT.md for the
full backend surface this builds on.
Existing frontend handling today: none. There is no maintenance concept anywhere in the frontend — no model field, no interceptor branch, no route, no component. This document proposes a contract and marks every open question explicitly as either "Requires backend decision" (the backend hasn't decided the signal shape) or "No frontend UI currently exists for this - requires a future frontend task" (the signal is plausible but no UI has been built to react to it).
The one adjacent, already-built pattern worth reusing is AuthErrorPageComponent
(src/app/core/auth/pages/auth-error-page.component.ts): a single component keyed by
an error-code route param, rendering EmptyStateComponent +
ButtonComponent, with a Record<Code, {title, description, actionLabel}> copy table
and a retry() handler. Section 7 proposes the maintenance screens follow this exact
shape rather than inventing a new one.
1. Global maintenance
Whole platform down for all tenants.
What the backend should send: 503 Service Unavailable on every endpoint
(including GET /bootstrap), with a Retry-After header (seconds) and a structured
JSON body (see §7 for exact shape). GET /bootstrap is the critical path — it is the
first call the frontend makes (ApiBootstrapProvider.loadBootstrap(),
src/app/core/bootstrap/providers/api-bootstrap.provider.ts, GET /bootstrap) and
every facade that renders anything (UiRuntimeFacade, WebsiteRuntimeFacade,
ProjectEditorFacade, ContentManagementFacade, DiagnosticsFacade) depends on it
resolving.
What the frontend currently does: nothing maintenance-specific. Tracing the call
chain in src/app/core/config/config.service.ts:
this.bootstrap$ = this.provider.loadBootstrap().pipe(
tap(config => { this.bootstrapSnapshot = config; ... }),
shareReplay(1),
catchError(error => {
this.bootstrap$ = undefined;
this.bootstrapSnapshot = null;
return throwError(() => error);
})
);
Any bootstrap failure (503 or otherwise) just rethrows. Every one of the ~14 call
sites of configService.loadBootstrap() (footer, theme engine, branding engine,
platform-runtime, page-resolver, static-page-resolver, footer-resolver, diagnostics,
etc. — see Grep results for loadBootstrap() across src/app) either does not
subscribe to the error channel at all, or handles it locally and inconsistently.
There is no global "the whole app is down" screen.
No frontend UI currently exists for this — requires a future frontend task. A
clean contract would intercept a 503 on the bootstrap call specifically (distinct
from a 503 on a leaf endpoint, which should degrade that one section instead — see
§3) and route to a full-page takeover, structurally identical to
AuthErrorPageComponent: a maintenance-page.component.ts using
EmptyStateComponent + ButtonComponent, keyed off the response body's reason
(§7), with a retry button that calls configService.loadBootstrap(true).
Requires backend decision: whether maintenance state is signaled by response
status alone (503 on /bootstrap) or also via a dedicated
GET /status / GET /maintenance probe the frontend could poll while showing the
takeover screen, to auto-recover without the user manually retrying.
2. Per-tenant maintenance
Single tenant disabled while others operate normally.
This ties directly into tenant resolution: TenantResolverService
(src/app/core/config/tenant-resolver.service.ts) determines the tenant key before
ApiConfigService.getBaseUrl() resolves which base URL to call (tenantApiBaseUrls
map, or tenantApiTemplate with {tenant} substituted — see
docs/context/BACKEND-AUDIT.md §2). Because tenant resolution happens client-side
before any network call, a per-tenant maintenance signal can only surface through the
response to that tenant's own GET /bootstrap call — there is no separate
"is this tenant up" check today.
What the backend should send: the same 503 + structured body as global
maintenance (§7) on that tenant's /bootstrap response. The frontend has no way to
distinguish "this tenant is down" from "the whole platform is down" except by the
response body's content — so the body must carry enough to tell (e.g. a scope field:
"global" | "tenant").
What the frontend currently does: nothing. ConfigService.loadBootstrap() is
tenant-agnostic from the frontend's point of view — it just calls whatever base URL
ApiConfigService resolved and doesn't know if a 503 means "this tenant" vs.
"everything."
Requires backend decision: the scope discriminator mentioned above, and whether
a disabled tenant's static/marketing content (branding, footer) should still resolve
from a cached/last-known bootstrap so the takeover page can show the tenant's own
logo, or whether it's a fully generic (unbranded) page. Given
BootstrapConfig.branding/theme are only available after a successful bootstrap
load, a tenant-branded maintenance page is not achievable without a design decision
here (e.g. serving branding via a separate lightweight endpoint that stays up even
when the tenant is otherwise disabled).
No frontend UI currently exists for this — requires a future frontend task. Same
takeover component as §1 can likely serve both scopes once the backend supplies
scope, but nothing renders differently for tenant-vs-global today because nothing
renders a maintenance screen at all yet.
3. Per-module maintenance
E.g. payments down but catalog still browsable.
Existing granularity concept: BootstrapConfig.featureFlags
(FeatureFlagsConfig, src/app/shared/models/config/feature-flags.model.ts) —
a flat Record<string, boolean> with known keys wishlist, compare, reviews, questions, comments, recommendations, blog, chat, analytics, notifications, coupons, loyalty, giftCards, invoices and an index signature for tenant-specific extras. This
is a static, bootstrap-time on/off switch per feature — not a live "is this
service currently degraded" signal, and it has no payments or catalog key today.
It's read once at bootstrap load and doesn't change until the next bootstrap refresh.
There is no separate "module health" concept distinct from featureFlags. The admin
dashboard's healthChecks() / homeHealthChecks() (AdminDashboardFacade,
src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts) are not
module-availability checks — they validate the local bootstrap document itself
(schema version present, no missing translations, no invalid colors/widget refs/
layouts, draft-exists, etc.), entirely client-side, with no backend health probe
behind any row except product/category counts (which reflect load success/failure of
ProductFacade/CategoryFacade, not an explicit "payments module is down" signal).
AdminMonitoringPageComponent reuses the same boolean-shaped healthChecks() — it is
not a live service-status board either.
What the backend should send: each domain-specific endpoint (e.g. POST /cart,
POST /orders, {qrApiUrl}/qr) should independently return 503 with the structured
body (§7) with scope: "module" and a module field (e.g. "payments") when that
subsystem specifically is down, while unrelated endpoints (GET /category,
GET /items/{id}) keep responding normally. This requires no new bootstrap field —
it's a per-request response behavior, consistent with REST conventions (the resource
itself is unavailable, not the whole API).
What the frontend currently does: nothing differentiates a per-module outage from
any other request failure. ApiService (src/app/services/api.service.ts) has no
per-endpoint error branching for 503; a failed createCartPayment()/createOrder()
call surfaces through whatever generic error handling the checkout components already
have for network failures (out of scope for this doc — see the sibling
ERROR_CONTRACT.md task for the general error-response shape).
No frontend UI currently exists for this — requires a future frontend task. The
checkout flow would need a "payments unavailable" inline state (banner or disabled
submit + tooltip, per §7) distinct from a generic error toast, and catalog browsing
would need to keep working untouched — which it structurally already would, since
ProductFacade/CategoryFacade and the payment calls are fully independent code
paths today (no shared failure state). That independence is a real asset: a payments
outage cannot accidentally break catalog browsing given the current facade
separation, but no UI exists yet to tell the user payments specifically are down
rather than "something went wrong."
4. Read-only mode
Writes disabled, reads still work.
Does the frontend already assume this is possible? Partially, structurally, but
not deliberately. Cart state is LOCAL-ONLY (CartService,
src/app/services/cart.service.ts, signal-based, persisted to localStorage key
marketplace_cart) — adding items to cart, changing quantities, and browsing the cart
UI works entirely client-side with no backend call at all until checkout. The
only writes that hit a backend are at the checkout boundary: POST /cart
(createCartPayment), POST /orders (createOrder), POST /purchase-email, and the
QR/card payment polling. So today, if the backend rejected writes only, catalog
browsing, search, wishlist/compare (also LOCAL-ONLY,
LocalUserExperienceRepository), and cart-building would all continue working simply
because they never touch the backend — but reviews (POST /items/{id}/callback) and
questions (POST /items/{id}/questiion) are also writes and would fail the same as
checkout, since both are LIVE endpoints via ProductDataProvider.
There is no code today that checks for a read-only flag and proactively disables write UI (e.g. graying out "Add to cart" or the checkout button ahead of time). A write attempt would only be discovered to be blocked when the write call itself fails.
What the backend should send: 503 (or 403, see note below) with the
structured body (§7), scope: "readonly", on write endpoints specifically —
POST /cart, POST /orders, POST /purchase-email, POST /items/{id}/callback,
POST /items/{id}/questiion, POST /websession/{sessionId} (cart sync) — while GET
endpoints keep working. 403 Forbidden is arguably more correct REST semantics for
"this resource forbids this method during a maintenance window" than 503, but 503
Retry-Aftercommunicates "temporary" more clearly to a client and is recommended so the frontend can offer a countdown/retry consistent with §5's pattern. Requires backend decision: which status code is authoritative — this should be pinned down jointly with whateverERROR_CONTRACT.mdsettles on for its 5xx conventions, since read-only is really "a subset of write endpoints return maintenance-503."
No frontend UI currently exists for this — requires a future frontend task. No
bootstrap flag exists to proactively disable checkout/review/question submission
ahead of a failed request (e.g. featureFlags.readOnly or a dedicated
platformStatus.readOnly field would need to be added to BootstrapConfig if the
product wants a proactive banner instead of a reactive failure). Reactive handling
(showing an error when the write call 503s) can reuse the same inline
error-state pattern as §3/§6 once ERROR_CONTRACT.md defines the generic error body
handling.
5. Scheduled maintenance
Advance notice pattern (banner / countdown) ahead of a maintenance window.
What exists in the frontend today: nothing. No banner component, no countdown component, no bootstrap field for an upcoming maintenance window.
Requires backend decision — proposed minimal contract: add an optional field to
BootstrapConfig (loaded once per session/on refresh via GET /bootstrap), e.g.:
interface ScheduledMaintenanceNotice {
startsAt: string; // ISO 8601
endsAt?: string; // ISO 8601, optional if duration is unknown
scope: 'global' | 'tenant' | 'module';
module?: string; // present when scope === 'module'
messageKey?: string; // optional i18n key/translated string for custom copy
}
surfaced as bootstrap.maintenanceNotice?: ScheduledMaintenanceNotice | null. This
keeps the mechanism consistent with how the platform already declares other
runtime-configured, backend-authored state (feature flags, tenant config, API
endpoint records all live in the bootstrap document per
docs/context/BACKEND-AUDIT.md §6) rather than inventing a new polling endpoint. A
polling GET /maintenance-notice endpoint is an alternative if the notice needs to
appear/change without a full bootstrap refresh — that tradeoff is the backend
decision.
No frontend UI currently exists for this — requires a future frontend task. A
dismissible banner component reading bootstrap.maintenanceNotice and showing a
localized "maintenance starts in Xh Ym" countdown would need to be built and mounted
at a layout level (header or a global banner slot) — no such banner or countdown
component exists in src/app/shared/ui/ today.
6. Temporary feature disable
Single feature toggled off without full maintenance — e.g. reviews temporarily disabled while the rest of the product page works.
This is the one scenario the frontend already has a real mechanism for, via
BootstrapConfig.featureFlags (§3). Setting featureFlags.reviews = false in the
bootstrap document is exactly the existing, live mechanism for "reviews are off right
now" — it's read by whatever consumes FeatureConfigService
(src/app/core/config/*) and gates the relevant UI. This is a deploy/config-time
toggle (changes on next bootstrap load), not a live incident-response toggle, but
structurally it is the same shape a backend team would use to kill a misbehaving
feature quickly: update the bootstrap document (or whatever backend-side config
drives it), and the next bootstrap fetch picks it up.
Recommendation: reuse featureFlags for this scenario rather than introducing a
parallel mechanism — it already exists, is already wired through to the UI in the
relevant places, and matches the "temporary, single-feature, not a full outage"
framing exactly. No backend decision needed for the mechanism; only for process
(how fast a flag flip propagates — depends on bootstrap cache/refresh cadence, which
is outside this doc's scope).
Gap: featureFlags has no payments or catalog key and is a boolean only — it
can't express "reviews disabled with reason X, back at time Y" the way §5's proposed
maintenanceNotice can. If product wants a "reviews are temporarily unavailable —
back tomorrow" message rather than the feature silently disappearing, that needs the
richer shape from §5, scoped to module, not a plain featureFlags boolean.
7. Recommended API responses
All maintenance-scenario responses use HTTP 503 Service Unavailable (except the
read-only debate in §4) with a Retry-After header (seconds, standard HTTP) and a
JSON body. This is written to be consistent with, not contradict, whatever
ERROR_CONTRACT.md (sibling task, in progress) settles on for its general
structured-error envelope — if that doc defines a different top-level error
shape (e.g. { error: { code, message, ... } } vs. a flatter shape), this body
should be nested under that envelope rather than duplicating a competing shape.
Pending that reconciliation, the fields below are what the frontend needs regardless
of the outer envelope:
{
"status": 503,
"code": "maintenance",
"scope": "global",
"module": null,
"reason": "scheduled",
"message": "The marketplace is temporarily unavailable for scheduled maintenance.",
"retryAfter": 1800,
"startedAt": "2026-07-26T02:00:00Z",
"expectedEndAt": "2026-07-26T03:00:00Z"
}
Field notes:
scope:"global" | "tenant" | "module" | "readonly"— lets the frontend pick the right UI (full takeover vs. inline banner vs. disabled control) without guessing from status code alone.module: present only whenscope === "module"(e.g."payments","reviews").reason:"scheduled" | "incident" | "disabled"— free-form enough for the frontend to choose copy tone (planned vs. unplanned) without needing new fields per scenario.retryAfter: mirrors theRetry-Afterheader in the body too, so a client that only reads JSON (not headers) still gets it — useful since some HttpClient error paths surface the body more readily than headers depending on interceptor structure.startedAt/expectedEndAt: optional, ISO 8601, for countdown/banner copy (§5).
Per-scenario summary:
| Scenario | Status | scope |
Notes |
|---|---|---|---|
| Global | 503 | "global" |
On every endpoint, especially /bootstrap |
| Per-tenant | 503 | "tenant" |
On that tenant's /bootstrap and all its endpoints |
| Per-module | 503 | "module" |
Only on that module's endpoints (e.g. /cart, /orders) |
| Read-only | 503 or 403 | "readonly" |
Only on write endpoints; GETs unaffected — pin down with ERROR_CONTRACT.md |
| Scheduled (advance notice) | 200, via bootstrap.maintenanceNotice |
n/a | Not an error response — a proactive field on the normal /bootstrap payload, see §5 |
| Temporary feature disable | 200, via bootstrap.featureFlags.<key> = false |
n/a | Not an error response — existing bootstrap mechanism, see §6 |
8. Frontend behavior
Grounded in the UI patterns that already exist (EmptyStateComponent
(src/app/shared/ui/empty-state/empty-state.component.ts), the errorTitle /
error / retry i18n-key convention used across catalog, product details, and
generic list widgets (src/app/i18n/en.ts), and AuthErrorPageComponent's
code-keyed full-page pattern). No new UI concepts are invented below beyond composing
these.
| Scenario | Recommended UI | Existing pattern reused | Status |
|---|---|---|---|
| Global maintenance | Full-page takeover, replaces the entire app shell (no header/footer, since branding may be unavailable — see §2) | AuthErrorPageComponent shape: EmptyStateComponent + ButtonComponent, code-keyed copy, retry() action |
No frontend UI currently exists for this — requires a future frontend task |
| Per-tenant maintenance | Same full-page takeover as global, ideally tenant-branded if the backend decision in §2 allows branding to still resolve | Same as above | No frontend UI currently exists for this — requires a future frontend task |
| Per-module maintenance | Inline empty-state/banner scoped to the affected section only (e.g. checkout step shows EmptyStateComponent with errorTitle/error/retry copy; catalog pages untouched) |
EmptyStateComponent + the errorTitle/error/retry i18n triple already used in catalog/productDetails/generic-list translations |
No frontend UI currently exists for this — requires a future frontend task |
| Read-only mode | Disabled write control (e.g. "Add to cart" / "Submit review" button) + tooltip explaining why, OR a reactive error state on submit if no proactive flag exists (§4) | Disabled-button-plus-tooltip is a common pattern in the design system but not wired to any maintenance signal today | No frontend UI currently exists for this — requires a future frontend task |
| Scheduled maintenance | Dismissible banner at layout/header level with countdown copy | No banner/countdown component exists in src/app/shared/ui/ today |
No frontend UI currently exists for this — requires a future frontend task |
| Temporary feature disable | Feature's own UI simply doesn't render (existing featureFlags gating), optionally with a short "temporarily unavailable" note if messageKey (§5) is present |
Existing featureFlags boolean gating (already live) |
Existing mechanism works; richer messaging is the only gap |
Summary: what's proposed/new vs. what already exists
Already exists and can be reused as-is:
BootstrapConfig.featureFlags— static per-feature kill switch (§3, §6).EmptyStateComponent+errorTitle/error/retryi18n convention — the inline error-state building block for any scenario.AuthErrorPageComponent— the full-page-takeover shape (code-keyed copy record,EmptyStateComponent+ButtonComponent,retry()handler) to model a maintenance page after.- Cart's
LOCAL-ONLYdesign already means most of "read-only browsing" works incidentally, since browsing/cart-building never call the backend.
Proposed/new (this document introduces):
- The
scope/module/reasonstructured 503 body (§7). bootstrap.maintenanceNotice(§5) for scheduled-maintenance advance notice.- A dedicated
maintenance-page.component.tsfull-page takeover (§1/§2). - Inline per-module/read-only error and disabled-control states wired to the new 503 shape (§3/§4).
Requires backend decision (full list)
- §1: whether a dedicated
GET /status/GET /maintenanceprobe should exist for auto-recovery polling, beyond a plain 503 on/bootstrap. - §2: the
scopediscriminator ("global"vs"tenant") so the frontend can tell the two apart from a single tenant's bootstrap response; and whether a disabled tenant's branding can still resolve for a branded takeover page. - §3: none beyond adopting the §7 response shape per-endpoint — this one is mostly frontend-gap, not backend-undecided.
- §4: which status code is authoritative for read-only (
503vs403) — to be pinned down jointly withERROR_CONTRACT.md. - §5: whether scheduled-maintenance notice ships via a
bootstrap.maintenanceNoticefield (proposed) or a separate polling endpoint. - §7: how this document's 503 body nests inside whatever outer envelope
ERROR_CONTRACT.mddefines.
No frontend UI currently exists for this — requires a future frontend task (full list)
- Global maintenance full-page takeover component.
- Per-tenant maintenance takeover (branded or not, pending §2's backend decision).
- Per-module inline maintenance banner/empty-state wiring on checkout/payment flows.
- Proactive read-only disabling of write controls (Add to cart / Submit review / Submit question / Checkout) ahead of a failed request.
- Scheduled-maintenance banner + countdown component at the layout/header level.
- Richer "temporarily unavailable, back at X" messaging for
featureFlags-gated features (today they just silently don't render — no explanatory copy).