DigitalConsumer.FixMyGlass/src/router/methods/before-each.js
Matt Sykes 76b4c35d59 Prevent error redirect loops with beforeEach circuit breaker
This adds a circuit breaker for uncaught failures in beforeEach. The first failure still goes through the normal ERROR/RESTART recovery path; a second consecutive failure redirects to the static error page instead of looping.
- New before-each-error-recovery module tracks recovery attempts in sessionStorage (with an in-memory fallback).
- ERROR/RESTART routes skip normal guard logic so the counter can accumulate across bounce-backs.
- The counter clears only after a fully successful navigation.
- redirectToStaticErrorPage centralizes hard-error bailout: logs, clears poisoned sessionStorage keys (submittedState, heritage redirect count, recovery count, externalParameterState), then navigates to /fmg/static/error.
- The static error page RESTART flow clears the same sessionStorage keys so bad state does not re-enter the funnel.
- Session storage key strings are centralized in session-storage.js.
- Unit tests cover recovery counting, bailout clearing, and navigation fallbacks.
Expected impact: Stops repeated ERROR → RESTART → ERROR cycles (including corrupt submittedState JSON.parse failures and recurring automation errors in us-east-1/us-east-2) by bailing out to the static error page after one failed recovery attempt.
2026-06-10 15:48:29 -04:00

197 lines
7.2 KiB
JavaScript

import { sessionStorageKeyConstants } from "@/constants/session-storage";
import {
getFunnelCookie,
updateOrCreateFunnelCookie,
} from "@/helpers/heritage-integration/cookie-helper";
import { isSavedSessionStillActive } from "@/helpers/heritage-integration/session-helper";
import analyticsMixin from "@/mixins/analytics-mixin";
import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
import { runExperiments } from "@/router/methods/helpers/run-experiments";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import {
handleSoftError,
handleHardError,
redirectToStaticErrorPage,
} from "@/router/methods/error";
import {
clearBeforeEachErrorRecoveryCount,
hasBeforeEachErrorRecoveryBeenAttempted,
incrementBeforeEachErrorRecoveryCount,
} from "@/router/methods/before-each-error-recovery";
import { checkPagePrerequisites } from "@/router/methods/page-prerequisites";
import { checkLogParam } from "@/helpers/debug-log-helper";
import { debugLog } from "@/helpers/debug-log-helper";
import { handleHeritageReturn } from "@/router/methods/helpers/handle-heritage-return";
import { isVirtualRoute } from "@/router/methods/helpers/is-virtual-route";
import router from "@/router";
const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5;
function isRecoveryRoute(routeName) {
return routeName === routeData.ERROR.name || routeName === routeData.RESTART.name;
}
export async function beforeEach(to, from) {
try {
if (isRecoveryRoute(to.name)) {
debugLog(`--- before-each.js recovery route ${to.name} ---`);
// Do NOT clear the recovery counter here. The counter must accumulate across
// consecutive failures that bounce through the ERROR/RESTART route so the
// circuit breaker can trip and fall back to the static error page. It is only
// cleared after a fully successful guard pass (end of try block).
return;
}
await analyticsMixin.methods.validateSession();
// prettier-ignore
{
debugLog(`--- before-each.js ${from?.name} start ---`);
debugLog(" funnel cookie init: ", getFunnelCookie());
}
if (getFunnelCookie()?.SuppressConceptFunnel) {
const redirectKey = sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT;
const redirectCount = Number(window.sessionStorage.getItem(redirectKey) ?? 0);
if (redirectCount < MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION) {
window.sessionStorage.setItem(redirectKey, String(redirectCount + 1));
navigateToHeritageFunnel({
shouldSaveSession: false,
pageNameToLog: to.name,
});
return false;
} else {
// If the redirect count is greater than the max allowed, we need to handle the error.
// Reset the redirect count to 0.
window.sessionStorage.setItem(redirectKey, "0");
debugLog(
"SuppressConceptFunnel: skipped heritage redirect after max attempts this session",
{ redirectCount, to: to.name }
);
const errorPayload = {
cause: "Handling heritage redirect after max attempts this session.",
currentPage: from?.name,
nextPage: to?.name,
};
// Eject user from Vue app in this scenario and clear localstorage.
await handleSoftError(errorPayload, true);
return false;
}
}
// Ensure session has not expired - not primary check for session expiration and uses FunnelSessionInfo-{environment} cookie.
if (getFunnelCookie() !== null && !isSavedSessionStillActive()) {
const errorPayload = {
cause: "expired session",
currentPage: from?.name,
nextPage: to?.name,
};
await handleSoftError(errorPayload, true);
return false;
}
// Block navigation if an order has been submitted.
const submittedState = window.sessionStorage.getItem(
sessionStorageKeyConstants.SUBMITTED_STATE
);
if (submittedState !== null) {
const isBailout = getIsBailout(submittedState);
const exceptionPages = isBailout
? [FUNNEL_START_PAGE.name, routeData.BAILOUT_SUCCESS.name]
: [FUNNEL_START_PAGE.name, routeData.CONFIRMATION.name];
if (!exceptionPages.some((name) => to.name === name) && !isVirtualRoute(to.name)) {
if (isBailout) {
router.push({
name: routeData.BAILOUT_SUCCESS.name,
});
return false;
} else {
router.push({
name: routeData.CONFIRMATION.name,
});
return false;
}
}
}
// Special logic when returning from insurance flow.
await handleHeritageReturn(to, from);
// prettier-ignore
{
debugLog(`--- before-each.js ${from?.name} mid ---`);
debugLog(" funnel cookie after load: ", getFunnelCookie());
}
// Process funnel cookie.
updateOrCreateFunnelCookie();
// prettier-ignore
{
debugLog(`--- before-each.js ${from?.name} end ---`);
debugLog(" funnel cookie after update: ", getFunnelCookie());
}
// Update if logging is enabled.
checkLogParam();
// Ensure page-prerequisites are fulfilled
const arePagePrerequisitesValid = await checkPagePrerequisites(to.name);
if (!arePagePrerequisitesValid) {
const errorPayload = {
cause: "invalid page prerequisites for page",
currentPage: from?.name,
nextPage: to?.name,
};
await handleSoftError(errorPayload);
return;
}
await runExperiments(to.name);
// prettier-ignore
{
debugLog("--- before-each.js end ---");
}
clearBeforeEachErrorRecoveryCount();
} catch (error) {
const errorPayload = {
cause: "Uncaught exception in `beforeEach`.",
currentPage: from?.name,
nextPage: to?.name,
errorMessage: error?.message,
errorStack: error?.stack,
};
console.log(error);
global.$logger?.logError("beforeEach uncaught exception", errorPayload);
if (to.name === routeData.ERROR.name || hasBeforeEachErrorRecoveryBeenAttempted()) {
clearBeforeEachErrorRecoveryCount();
await redirectToStaticErrorPage(errorPayload);
return false;
}
incrementBeforeEachErrorRecoveryCount();
// Eject user from Vue app in this scenario and clear localstorage.
await handleHardError(errorPayload);
return false;
}
}
function getIsBailout(submittedState) {
const submittedStateObj = JSON.parse(submittedState);
return !!submittedStateObj?.applicationUser?.bailoutCode;
}