Files
marketplaces/docs/MAINTENANCE_MODE.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

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-After communicates "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 whatever ERROR_CONTRACT.md settles 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.


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 when scope === "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 the Retry-After header 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/retry i18n 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-ONLY design 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/reason structured 503 body (§7).
  • bootstrap.maintenanceNotice (§5) for scheduled-maintenance advance notice.
  • A dedicated maintenance-page.component.ts full-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 /maintenance probe should exist for auto-recovery polling, beyond a plain 503 on /bootstrap.
  • §2: the scope discriminator ("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 (503 vs 403) — to be pinned down jointly with ERROR_CONTRACT.md.
  • §5: whether scheduled-maintenance notice ships via a bootstrap.maintenanceNotice field (proposed) or a separate polling endpoint.
  • §7: how this document's 503 body nests inside whatever outer envelope ERROR_CONTRACT.md defines.

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).