Error handling

The layers

┌─────────────────────────────────────────────────────────────────────┐
│ window.onerror (main.ts)                                            │
│   uncaught JS errors → Segment 'WINDOW ERROR' track, returns false  │
├─────────────────────────────────────────────────────────────────────┤
│ ErrorHandler → AppErrorHandler                                      │
│   Angular's global handler. Dev: log + rethrow. Prod: log only.     │
├─────────────────────────────────────────────────────────────────────┤
│ PendingInterceptorService                                            │
│   toasts any response with status >= 500, then rethrows              │
├─────────────────────────────────────────────────────────────────────┤
│ AuthInterceptor                                                      │
│   401 with a token → dispatch Logout with a message, then rethrow    │
├─────────────────────────────────────────────────────────────────────┤
│ Service-level catchError                                             │
│   toast + rethrow, or map to an _ERROR store action                  │
├─────────────────────────────────────────────────────────────────────┤
│ ErrorParserService → ErrorVisualizationService                        │
│   turn an error object into a message, show it as a toast            │
├─────────────────────────────────────────────────────────────────────┤
│ Logger / ErrorLogger                                                 │
│   dev-only console output; Segment events with a redacted store dump │
├─────────────────────────────────────────────────────────────────────┤
│ FetchedRequestsInterceptor                                           │
│   ring buffer of the last 50 requests in the `errors` state slice    │
└─────────────────────────────────────────────────────────────────────┘

A 5xx response typically produces two toasts: one from PendingInterceptorService ("<status>: <message>") and one from whatever the calling code does with the error. Some services add a third. This is expected behaviour in this codebase, not a bug to chase.

AppErrorHandler

src/app/shared/error-handler.ts, bound to Angular’s ErrorHandler token:

handleError(error: any) {
  if (isDevMode()) {
    this.displayConsoleMessage(error, true);   // grouped console.error
    throw error;                               // rethrow in dev
  } else {
    const displayErrors  = this.appConfig.env.displayErrorsInConsole;
    const showErrorPopup = this.appConfig.env.displayErrorsPopup;
    this.handleErrorProd(error, displayErrors, showErrorPopup);
  }
}

Two environment switches govern production behaviour:

Key Effect

displayErrorsInConsole

true in the committed environments — errors are logged to the console. When false the handler prints "Check sentry logs or run in dev mode." instead.

displayErrorsPopup

false in the committed environments. It is read but never used — handleErrorProd ignores its displayErrorPopup argument entirely.

AppErrorHandler in production only logs. It does not report anywhere, and the message it prints when console logging is disabled tells the user to "check sentry logs" — but no Sentry SDK is installed and nothing reads sentryUrl. Uncaught errors in production are therefore visible only through the window.onerror → Segment path in main.ts.

ErrorParserService

shared/services/error-parser/error-parser.service.ts turns an ErrorInterface ({ name, errorObject }) into a display string. The precedence, in order:

  1. Empty name and empty error object → 'Unknown Error.'.

  2. errorObject is a string → 'Error:' + name + ' : ' + errorObject.

  3. errorObject.error.meta.errors[] exists → the internal API’s multi-error envelope; concatenates every message separated by ', '.

  4. errorObject.error.message exists → returns just that message, without the prefix.

  5. errorObject.message exists → 'Error:' + name + ' : ' + message.

  6. Otherwise → 'Error:' + name + ' : Error unrecognized.'.

Constants live in StandardErrorMessages:

Constant Value

UNKNOWN_ERROR

'Unknown Error.'

ERROR_NOT_RECOGNIZED

'Error unrecognized.'

ERROR_PREFIX

'Error:'

SECTION_SEPARATOR

' : '

SEPARATOR

', '

GENERAL_ERROR_MESSAGE (shared/consts/general-error-message.ts) — 'Sorry, something went wrong…​' — is the user-facing fallback used by components that do not parse.

Branch 4 returns the bare message with no Error: prefix, while branches 2, 5 and 6 add it. Toast wording is therefore inconsistent depending on the error’s shape. isEmpty treats {} and [] (by JSON.stringify comparison) as empty.

The multi-error envelope this understands is the internal API’s:

{ "error": { "meta": { "errors": [ { "message": "…" }, { "message": "…" } ] } } }

See data modelling for the other response envelopes.

ErrorVisualizationService

The single presentation path:

readonly ERROR_SHOW_TIMEOUT = 9000;

showError(errorData: ErrorInterface): void {
  this.toastrService.error(this.errorParserService.parse(errorData), null,
                           { timeOut: this.ERROR_SHOW_TIMEOUT });
}

Errors show for 9 seconds. Both this service and ErrorParserService have .mock.service siblings, and shared/services/toastr-mock/toastr.mock.service.ts stubs Toastr for specs.

For a user-facing error, inject ErrorVisualizationService and call showError({ name, errorObject }). Do not call ToastrService.error directly — that bypasses the parser and produces inconsistent wording.

Service-level patterns

Three coexist, oldest first:

Pattern Where

catchError(err ⇒ ErrorObservable.create(err))

Older services (SupplierService, LookupRepository, ReportsRepositoryService). A legacy deep RxJS import equivalent to throwError.

catchError(err ⇒ observableThrowError(err)) preceded by a toast

Repository services. PaymentBatchRepositoryService is the extreme case — eight distinct toast messages ("Cannot fetch Payment Batches", "Cannot store Payment Batch", …), each followed by a rethrow. See processes.

catchError(…​) returning an _ERROR action

Store-backed flows. The component reads the error out of state (supplierError, toggleHideError, addOptInError, …).

CardManagementService uses the modern throwError(() ⇒ err) factory plus a console.error with structured context — the pattern to copy for new services.

Logging

Logger

src/app/shared/logger.ts — static, dev-only:

Logger.log(msg, ...params)     // grouped, collapsed: '------- SILENT LOG DEBUG --------'
Logger.error(msg, ...params)   // grouped:            '!!!!!!! SILENT LOG ERROR !!!!!!!'
Logger.ex(exception, ...params) // same as error

All three are gated on isDevMode(), so they are silent in production builds. Used throughout AuthEffects, SupplierEffects and SseService.

It is declared @Injectable() with an AppConfigService constructor parameter but every method is static, so the injection is never used. It is imported both as Logger (named) and as the default export.

ErrorLogger

shared/services/error-logger/error-logger.service.ts sends structured events to Segment with a redacted store dump:

log(title: string, data: any, withStoreDump = true): void {
  if (withStoreDump) {
    this.store$.pipe(take(1)).subscribe((dump) => {
      const store = this.prepareDump(dump);
      this.fullstoryEvent(title, { data });
      this.fullstoryEvent(title, { store });
    });
  } else {
    this.fullstoryEvent(title, { data });
  }
}

private prepareDump(storeData: any): any {
  const data = { ...storeData };
  delete data['auth'];               // ← token and user
  delete data['backgroundReports'];
  delete data['calc'];
  delete data['crm'];
  delete data['layout'];
  delete data['utils'];
  return data;
}

private fullstoryEvent(title, data) {
  try { (<any>window).analytics.track(title, data); } catch (err) {}
}

prepareDump removes six slices, so what remains is globalSettings, supplier, marketplace, onboarding, errors and underwriting. The supplier slice contains supplier PII (tax id, owner SSN, addresses, ownerSSN), and the errors slice contains the last 50 serialised HTTP requests. Both are sent to Segment.

Before adding an ErrorLogger.log(…​, withStoreDump = true) call, consider passing false.

The method is named fullstoryEvent but calls window.analytics.track — Segment, not FullStory. Historical naming.

Request ring buffer

FetchedRequestsInterceptor dispatches FetchRequest(req) for every request; the errors reducer keeps the last 50, JSON.stringify-ed, in state (REQUESTS_AMOUNT = 50).

function cachedRequestArrayHandler(array, incomingRequest) {
  let newArr = array;
  if (array.length === REQUESTS_AMOUNT) { newArr = array.slice(0, -1); }
  newArr.unshift(JSON.stringify(incomingRequest, null, 1));
  return newArr;
}

Note newArr is the same array when under the cap, so unshift mutates the existing state array. It is a debugging aid, visible in Redux DevTools and included in `ErrorLogger’s store dump.

The error page

shared/error-handling/tetris/ — a playable Tetris implementation (game.service.ts, constants.ts, board/, piece/, with specs) shown on the error page. shared/page-not-found/ handles 404s, and FeatureFlagGuard navigates to /404 when a flag is off.

Toast conventions

Toasts are the only notification channel — errors never use dialogs.

Source Wording

ErrorVisualizationService

Parsed message, 9 s.

PendingInterceptorService

"<status>: <message>" for any 5xx.

SubPermissionsGuard

'Access denied.'

SelectorFormComponent

'Elasticsearch is disabled. No events will be shown.' (info)

LoginComponent

'Invalid login or password' (401), 'Access denied' (403), 'Cannot connect with server' (other), 3 s; plus any ?message= query parameter as info for 10 s.

Repository services

Feature-specific "Cannot …" messages.

Store error state

Errors that must survive a render are kept in state rather than toasted:

Slice field Set by

auth.authError

LOGIN_FAILED. LoginComponent maps the status to a message and then dispatches ClearAuthState.

supplier.supplierError

FETCH_SUPPLIER_DETAILS_ERROR.

supplier.toggleHideError, toggleSuspendError, addOptInError, deleteOptInError

The corresponding _ERROR actions.

supplier.supplierModelPartErrors[formGroupKey]

UPDATE_MODEL_PART_ERROR. Cleared explicitly with UPDATE_MODEL_PART_MAPS. See partial editing.

supplier.paymentConfigsPending[actionId]

PC_RESPONSE_ERROR, cleared with PC_CLEAR_STATUS.

crm.crmPendingItems[actionId]

CRM_ITEM_RESPONSE_ERROR, cleared with CRM_ITEM_CLEAR_STATUS.

The pattern is consistent: an error written into state must be cleared explicitly by the component that displayed it.

Guidance

  • User-facing failure → ErrorVisualizationService.showError({ name, errorObject }).

  • Failure that must persist across renders → an _ERROR action, and remember to clear it.

  • Diagnostic detail → Logger.log / Logger.error (dev-only).

  • Never catchError into a silent of(null) for anything the user initiated.

  • Do not add a second toast for an error the interceptor already toasts.

  • Do not send store dumps to analytics without checking what the redaction leaves behind.

Gotchas summary

Issue Detail

Duplicate toasts on 5xx

Interceptor plus caller.

displayErrorsPopup is dead

Read from config, never acted on.

Sentry is not wired up

Configured everywhere, used nowhere.

Parser prefix is inconsistent

One branch omits Error:.

ErrorLogger sends supplier PII

Redaction keeps supplier and errors.

Logger is static despite @Injectable

Constructor injection unused.

Ring buffer mutates state

unshift on the existing array when under the cap.