Two complementary layers handle failures from API-backed work:
| Layer | Responsibility | Entry point |
|---|---|---|
| HTTP interceptor | Global UX: toast, navigation, auth, maintenance, loader | error.interceptor.ts |
| NGXS state | Section bookkeeping: clear loading flags, store error, Sentry tag |
handleSectionError |
Both rethrow. For a store action that calls HTTP: interceptor runs first → rethrow → state’s catchError → handleSectionError.
Central HTTP UX lives in src/app/core/interceptors/error.interceptor.ts. Prefer the interceptor for shared UX; handle locally only when the UI needs a special message or flow.
Order before the status switch matters:
- SSR (
!isBrowser) — no toast, navigation, loader, auth, or Sentry. For non-bypassed401/403/404/410/5xx, setRESPONSE_INIT.statuson the requested URL; then rethrow. Network/timeout errors (status: 0) map to503. First non-OK wins, except a later401/403/404/410may replace an earlier5xx. Silent requests (X-No-Auth-Redirector request-access URL) do not set status for any code; pending-moderation403s do. GUID guards (isFileGuard,isProjectGuard,isRegistryGuard) catch errors and returnfalse, so matching falls through to**(not-found page HTML with the HTTP status fromRESPONSE_INIT). - Bypass — Sentry capture + rethrow; skip all UX.
- Maintenance —
503withmeta.maintenance_mode: true→ hide loader, activate maintenance mode, rethrow. - Status
switch—401/403/404/409/410/ default.
After the switch (except early 409 return), the interceptor hides the loader and rethrows. 409 does not hide the loader; the caller owns that.
flowchart TD
err[HttpErrorResponse] --> ssr{Browser?}
ssr -->|no| ssrStatus[Set RESPONSE_INIT status + rethrow]
ssr -->|yes| bypass{BYPASS?}
bypass -->|yes| sentry[Sentry + rethrow]
bypass -->|no| maint{Maintenance 503?}
maint -->|yes| activate[Hide loader + activate maintenance + rethrow]
maint -->|no| status{Status}
status -->|409| conflict[Rethrow only]
status -->|other| rethrow2[Hide loader + rethrow]
rethrow2 --> state[NGXS catchError / handleSectionError]
conflict --> state
ssrStatus --> state
sentry --> state
activate --> state
| Status | Interceptor behavior |
|---|---|
| 401 | Skip auth redirect on view-only (?view_only=). Else logout if authenticated, or navigate to sign-in. Hide loader. |
| 403 | Navigate unless silent / pending-moderation (see below). Node URL → /request-access/:id (skipped on draft registry routes). Other → /forbidden. Hide loader. No toast. |
| 404 | Navigate to /not-found. Hide loader. No toast. |
| 409 | Rethrow only (no toast, no loader hide). Caller handles conflict UX. |
| 410 | Navigate to /content-deleted. Hide loader. No toast. |
| Other | Toast via getErrorMessage, hide loader. |
/not-found and /content-deleted share PageNotFoundComponent with different route data (PAGE_NOT_FOUND_ROUTE_DATA / CONTENT_DELETED_ROUTE_DATA). Browser navigates to those routes (and /forbidden) for UX; SSR does not — it only sets RESPONSE_INIT.status on the requested URL via ssr-response-status.helper.ts.
Use when the caller owns all error UX (or the failure must not navigate/toast).
import { bypassErrorInterceptorContext } from '@core/interceptors/error-interceptor.tokens';
this.http.get(url, { context: bypassErrorInterceptorContext() });bypassErrorInterceptorContext() returns a new HttpContext each call (do not reuse one mutable instance).
Bypass still reports to Sentry and rethrows. It skips toast, navigation, loader, auth, and maintenance activation.
Current examples: maintenance status polling, parent project fetch, Datacite tracker, addon OAuth token refresh, external identity delete.
These skip /forbidden / request-access navigation (loader still hides; error still rethrows):
| Case | How |
|---|---|
| Metadata record fetch | X-No-Auth-Redirect on MetadataRecordsService.getMetadataRecord (optional schema.org; prefer migrating to HttpContext later) |
| Node access-request API | URL matches /v2/nodes/:id/requests |
| Preprint pending moderation | Detail equals PREPRINT_PENDING_MODERATION_DETAIL — component routes to pending-moderation |
getErrorMessage resolution:
- Client
ErrorEvent.message - JSON:API
error.errors[0].detail - For 5xx:
error.messageorcommon.errorMessages.serverError - Else
ERROR_MESSAGES[status]orcommon.errorMessages.unknown
ERROR_MESSAGES maps statuses to i18n keys under common.errorMessages.*. Toasts run through the translate pipe.
- Default — do not toast in the subscriber; the interceptor already does for unhandled statuses.
- Custom toast / flow — either
bypassErrorInterceptorContext()or remove the local toast so users do not see two messages. - 409 — handle in the caller (interceptor is silent).
- Expected 404/410 on secondary requests — use bypass; otherwise the interceptor navigates away from the current page.
- Pending-moderation preprint — leave interceptor special-case + component navigation; do not send that request with full bypass unless you also handle 404/410/5xx locally.
- Loader — interceptor hides for most statuses; for
409(and bypass) hide in the caller (finalizeis a good pattern).
Store actions that hit HTTP should end failed sections with handleSectionError from src/app/shared/helpers/state-error.handler.ts.
It does not replace the interceptor. It:
- Reports to Sentry with
state.section/ feature tags (a second report if the interceptor already captured on bypass) - Patches the named section:
isLoading: false,isSubmitting: false,error: error.message - Rethrows so components /
dispatchsubscribers can still react
.catchError((error) => handleSectionError(ctx, 'registry', error))section must be a key on the state model whose value follows AsyncStateModel (see NGXS docs):
export interface AsyncStateModel<T> {
data: T;
isLoading: boolean;
isSubmitting?: boolean;
error: string | null;
}Guidelines:
- Use
handleSectionErrorin actioncatchErrorfor async sections — do not only clear loading in an ad-hoc patch unless the section is notAsyncStateModel-shaped. - Do not toast inside the state handler; leave shared toasts to the interceptor (or the component when bypassed /
409). - UI that needs an inline error can read
section.errorfrom the store; many screens rely on interceptor toasts instead. - Components that
dispatch(...).subscribe({ error })still receive the error after both layers.
| File | Role |
|---|---|
src/app/core/interceptors/error.interceptor.ts |
HTTP UX handler |
src/app/core/interceptors/error-interceptor.tokens.ts |
Bypass token + factory |
src/app/core/helpers/ssr-response-status.helper.ts |
SSR RESPONSE_INIT.status helper |
src/app/shared/helpers/state-error.handler.ts |
NGXS section error helper |
src/app/shared/models/store/async-state.model.ts |
Async section shape (error field) |
src/app/core/constants/error-messages.ts |
Status → i18n keys |
src/app/core/constants/http-error-details.const.ts |
Shared API detail strings |
src/app/core/constants/page-not-found.constants.ts |
Route data for 404 / 410 pages |
src/app/core/services/maintenance-mode.service.ts |
Maintenance detection + activation |
src/app/core/components/page-not-found/ |
Shared not-found / content-deleted UI |
docs/ngxs.md |
Store / AsyncStateModel conventions |
docs/sentry.md |
Which HTTP errors are filtered from Sentry |