From 3f959e223544e539022cd7356c6726ac39244e90 Mon Sep 17 00:00:00 2001 From: sunghyunkang1111 <114709653+sunghyunkang1111@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:34:40 -0600 Subject: [PATCH] Added expected errort handling (#2368) --- src/Common/ErrorHandlingUtils.ts | 8 + src/Main.tsx | 4 +- src/Metrics/ErrorClassification.test.ts | 182 +++++++++++++++++++ src/Metrics/ErrorClassification.ts | 109 +++++++++++ src/Metrics/MetricEvents.ts | 5 + src/Metrics/ScenarioMonitor.test.ts | 231 ++++++++++++++++++++++++ src/Metrics/ScenarioMonitor.ts | 42 ++++- src/Utils/AuthorizationUtils.ts | 16 +- 8 files changed, 592 insertions(+), 5 deletions(-) create mode 100644 src/Metrics/ErrorClassification.test.ts create mode 100644 src/Metrics/ErrorClassification.ts create mode 100644 src/Metrics/ScenarioMonitor.test.ts diff --git a/src/Common/ErrorHandlingUtils.ts b/src/Common/ErrorHandlingUtils.ts index ea25d8e34..f96780065 100644 --- a/src/Common/ErrorHandlingUtils.ts +++ b/src/Common/ErrorHandlingUtils.ts @@ -1,5 +1,7 @@ import { MessageTypes } from "../Contracts/ExplorerContracts"; import { SubscriptionType } from "../Contracts/SubscriptionType"; +import { isExpectedError } from "../Metrics/ErrorClassification"; +import { scenarioMonitor } from "../Metrics/ScenarioMonitor"; import { userContext } from "../UserContext"; import { ARMError } from "../Utils/arm/request"; import { logConsoleError } from "../Utils/NotificationConsoleUtils"; @@ -31,6 +33,12 @@ export const handleError = ( // checks for errors caused by firewall and sends them to portal to handle sendNotificationForError(errorMessage, errorCode); + + // Mark expected failures for health metrics (auth, firewall, permissions, etc.) + // This ensures timeouts with expected failures emit healthy instead of unhealthy + if (isExpectedError(error)) { + scenarioMonitor.markExpectedFailure(); + } }; export const getErrorMessage = (error: string | Error = ""): string => { diff --git a/src/Main.tsx b/src/Main.tsx index c90f016ac..c279d6e0d 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -119,6 +119,9 @@ const App = (): JSX.Element => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [explorer]); + // Track interactive phase for both ContainerCopyPanel and DivExplorer paths + useInteractive(MetricScenario.ApplicationLoad); + if (!explorer) { return ; } @@ -145,7 +148,6 @@ const App = (): JSX.Element => { const DivExplorer: React.FC<{ explorer: Explorer }> = ({ explorer }) => { const isCarouselOpen = useCarousel((state) => state.shouldOpen); const isCopilotCarouselOpen = useCarousel((state) => state.showCopilotCarousel); - useInteractive(MetricScenario.ApplicationLoad); return (
{ + describe("isExpectedError", () => { + describe("ARMError with expected codes", () => { + it("returns true for AuthorizationFailed code", () => { + const error = new ARMError("Authorization failed"); + error.code = "AuthorizationFailed"; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for Forbidden code", () => { + const error = new ARMError("Forbidden"); + error.code = "Forbidden"; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for Unauthorized code", () => { + const error = new ARMError("Unauthorized"); + error.code = "Unauthorized"; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for InvalidAuthenticationToken code", () => { + const error = new ARMError("Invalid token"); + error.code = "InvalidAuthenticationToken"; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for ExpiredAuthenticationToken code", () => { + const error = new ARMError("Token expired"); + error.code = "ExpiredAuthenticationToken"; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for numeric 401 code", () => { + const error = new ARMError("Unauthorized"); + error.code = 401; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for numeric 403 code", () => { + const error = new ARMError("Forbidden"); + error.code = 403; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns false for unexpected ARM error code", () => { + const error = new ARMError("Internal error"); + error.code = "InternalServerError"; + expect(isExpectedError(error)).toBe(false); + }); + + it("returns false for numeric 500 code", () => { + const error = new ARMError("Server error"); + error.code = 500; + expect(isExpectedError(error)).toBe(false); + }); + }); + + describe("MSAL AuthError with expected errorCodes", () => { + it("returns true for popup_window_error", () => { + const error = { errorCode: "popup_window_error", message: "Popup blocked" }; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for interaction_required", () => { + const error = { errorCode: "interaction_required", message: "User interaction required" }; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for user_cancelled", () => { + const error = { errorCode: "user_cancelled", message: "User cancelled" }; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for consent_required", () => { + const error = { errorCode: "consent_required", message: "Consent required" }; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for login_required", () => { + const error = { errorCode: "login_required", message: "Login required" }; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for no_account_error", () => { + const error = { errorCode: "no_account_error", message: "No account" }; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns false for unexpected MSAL error code", () => { + const error = { errorCode: "unknown_error", message: "Unknown" }; + expect(isExpectedError(error)).toBe(false); + }); + }); + + describe("HTTP status codes", () => { + it("returns true for error with status 401", () => { + const error = { status: 401, message: "Unauthorized" }; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for error with status 403", () => { + const error = { status: 403, message: "Forbidden" }; + expect(isExpectedError(error)).toBe(true); + }); + + it("returns false for error with status 500", () => { + const error = { status: 500, message: "Internal Server Error" }; + expect(isExpectedError(error)).toBe(false); + }); + + it("returns false for error with status 404", () => { + const error = { status: 404, message: "Not Found" }; + expect(isExpectedError(error)).toBe(false); + }); + }); + + describe("Firewall error message pattern", () => { + it("returns true for firewall error in Error message", () => { + const error = new Error("Request blocked by firewall"); + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for IP not allowed error", () => { + const error = new Error("Client IP address is not allowed"); + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for ip not allowed (no 'address')", () => { + const error = new Error("Your ip not allowed to access this resource"); + expect(isExpectedError(error)).toBe(true); + }); + + it("returns true for string error with firewall", () => { + expect(isExpectedError("firewall rules prevent access")).toBe(true); + }); + + it("returns true for case-insensitive firewall match", () => { + const error = new Error("FIREWALL blocked request"); + expect(isExpectedError(error)).toBe(true); + }); + + it("returns false for unrelated error message", () => { + const error = new Error("Database connection failed"); + expect(isExpectedError(error)).toBe(false); + }); + }); + + describe("Edge cases", () => { + it("returns false for null", () => { + expect(isExpectedError(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isExpectedError(undefined)).toBe(false); + }); + + it("returns false for empty object", () => { + expect(isExpectedError({})).toBe(false); + }); + + it("returns false for plain Error without expected patterns", () => { + const error = new Error("Something went wrong"); + expect(isExpectedError(error)).toBe(false); + }); + + it("returns false for string without firewall pattern", () => { + expect(isExpectedError("Generic error occurred")).toBe(false); + }); + + it("handles error with multiple matching criteria", () => { + // ARMError with both code and firewall message + const error = new ARMError("Request blocked by firewall"); + error.code = "Forbidden"; + expect(isExpectedError(error)).toBe(true); + }); + }); + }); +}); diff --git a/src/Metrics/ErrorClassification.ts b/src/Metrics/ErrorClassification.ts new file mode 100644 index 000000000..93d1a8ae3 --- /dev/null +++ b/src/Metrics/ErrorClassification.ts @@ -0,0 +1,109 @@ +import { ARMError } from "../Utils/arm/request"; + +/** + * Expected error codes that should not mark scenarios as unhealthy. + * These represent expected failures like auth issues, permission errors, and user actions. + */ + +// ARM error codes (string) +const EXPECTED_ARM_ERROR_CODES: Set = new Set([ + "AuthorizationFailed", + "Forbidden", + "Unauthorized", + "AuthenticationFailed", + "InvalidAuthenticationToken", + "ExpiredAuthenticationToken", + "AuthorizationPermissionMismatch", +]); + +// HTTP status codes that indicate expected failures +const EXPECTED_HTTP_STATUS_CODES: Set = new Set([ + 401, // Unauthorized + 403, // Forbidden +]); + +// MSAL error codes (string) +const EXPECTED_MSAL_ERROR_CODES: Set = new Set([ + "popup_window_error", + "interaction_required", + "user_cancelled", + "consent_required", + "login_required", + "no_account_error", + "monitor_window_timeout", + "empty_window_error", +]); + +// Firewall error message pattern (only case where we check message content) +const FIREWALL_ERROR_PATTERN = /firewall|ip\s*(address)?\s*(is\s*)?not\s*allowed/i; + +/** + * Interface for MSAL AuthError-like objects + */ +interface MsalAuthError { + errorCode?: string; +} + +/** + * Interface for errors with HTTP status + */ +interface HttpError { + status?: number; +} + +/** + * Determines if an error is an expected failure that should not mark the scenario as unhealthy. + * + * Expected failures include: + * - Authentication/authorization errors (user not logged in, permissions) + * - Firewall blocking errors + * - User-cancelled operations + * + * @param error - The error to classify + * @returns true if the error is expected and should not affect health metrics + */ +export function isExpectedError(error: unknown): boolean { + if (!error) { + return false; + } + + // Check ARMError code + if (error instanceof ARMError && error.code !== undefined) { + if (typeof error.code === "string" && EXPECTED_ARM_ERROR_CODES.has(error.code)) { + return true; + } + if (typeof error.code === "number" && EXPECTED_HTTP_STATUS_CODES.has(error.code)) { + return true; + } + } + + // Check for MSAL AuthError (has errorCode property) + const msalError = error as MsalAuthError; + if (msalError.errorCode && typeof msalError.errorCode === "string") { + if (EXPECTED_MSAL_ERROR_CODES.has(msalError.errorCode)) { + return true; + } + } + + // Check HTTP status on generic errors + const httpError = error as HttpError; + if (httpError.status && typeof httpError.status === "number") { + if (EXPECTED_HTTP_STATUS_CODES.has(httpError.status)) { + return true; + } + } + + // Check for firewall error in message (the only message-based check) + if (error instanceof Error && error.message) { + if (FIREWALL_ERROR_PATTERN.test(error.message)) { + return true; + } + } + + // Check for string errors with firewall pattern + if (typeof error === "string" && FIREWALL_ERROR_PATTERN.test(error)) { + return true; + } + + return false; +} diff --git a/src/Metrics/MetricEvents.ts b/src/Metrics/MetricEvents.ts index bfd59363b..84faad1e8 100644 --- a/src/Metrics/MetricEvents.ts +++ b/src/Metrics/MetricEvents.ts @@ -15,6 +15,11 @@ export const reportUnhealthy = (scenario: MetricScenario, platform: Platform, ap send({ platform, api, scenario, healthy: false }); const send = async (event: MetricEvent): Promise => { + // Skip metrics emission during local development + if (process.env.NODE_ENV === "development") { + return Promise.resolve(new Response(null, { status: 200 })); + } + const url = createUri(configContext?.PORTAL_BACKEND_ENDPOINT, RELATIVE_PATH); const authHeader = getAuthorizationHeader(); diff --git a/src/Metrics/ScenarioMonitor.test.ts b/src/Metrics/ScenarioMonitor.test.ts new file mode 100644 index 000000000..dfd0db17e --- /dev/null +++ b/src/Metrics/ScenarioMonitor.test.ts @@ -0,0 +1,231 @@ +/** + * @jest-environment jsdom + */ + +import { configContext } from "../ConfigContext"; +import { updateUserContext } from "../UserContext"; +import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents"; +import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig"; +import { scenarioMonitor } from "./ScenarioMonitor"; + +// Mock the MetricEvents module +jest.mock("./MetricEvents", () => ({ + __esModule: true, + default: { + ApplicationLoad: "ApplicationLoad", + DatabaseLoad: "DatabaseLoad", + }, + reportHealthy: jest.fn().mockResolvedValue({ ok: true }), + reportUnhealthy: jest.fn().mockResolvedValue({ ok: true }), +})); + +// Mock configContext +jest.mock("../ConfigContext", () => ({ + configContext: { + platform: "Portal", + PORTAL_BACKEND_ENDPOINT: "https://test.portal.azure.com", + }, + Platform: { + Portal: "Portal", + Hosted: "Hosted", + Emulator: "Emulator", + Fabric: "Fabric", + }, +})); + +describe("ScenarioMonitor", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Use legacy fake timers to avoid conflicts with performance API + jest.useFakeTimers({ legacyFakeTimers: true }); + + // Ensure performance mock is available (setupTests.ts sets this but fake timers may override) + if (typeof performance.mark !== "function") { + Object.defineProperty(global, "performance", { + writable: true, + configurable: true, + value: { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn(), + getEntriesByName: jest.fn().mockReturnValue([{ startTime: 0 }]), + getEntriesByType: jest.fn().mockReturnValue([]), + now: jest.fn(() => Date.now()), + timeOrigin: Date.now(), + }, + }); + } + + // Reset userContext + updateUserContext({ + apiType: "SQL", + }); + + // Reset the scenario monitor to clear any previous state + scenarioMonitor.reset(); + }); + + afterEach(() => { + // Reset scenarios before switching to real timers + scenarioMonitor.reset(); + jest.useRealTimers(); + }); + + describe("markExpectedFailure", () => { + it("sets hasExpectedFailure flag on active scenarios", () => { + // Start a scenario + scenarioMonitor.start(MetricScenario.ApplicationLoad); + + // Mark expected failure + scenarioMonitor.markExpectedFailure(); + + // Let timeout fire - should emit healthy because of expected failure + jest.advanceTimersByTime(10000); + + expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL"); + expect(reportUnhealthy).not.toHaveBeenCalled(); + }); + + it("sets flag on multiple active scenarios", () => { + // Start two scenarios + scenarioMonitor.start(MetricScenario.ApplicationLoad); + scenarioMonitor.start(MetricScenario.DatabaseLoad); + + // Mark expected failure - should affect both + scenarioMonitor.markExpectedFailure(); + + // Let timeouts fire + jest.advanceTimersByTime(10000); + + expect(reportHealthy).toHaveBeenCalledTimes(2); + expect(reportUnhealthy).not.toHaveBeenCalled(); + }); + + it("does not affect already emitted scenarios", () => { + // Start scenario + scenarioMonitor.start(MetricScenario.ApplicationLoad); + + // Complete all phases to emit + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive); + + // Now mark expected failure - should not change anything + scenarioMonitor.markExpectedFailure(); + + // Healthy was called when phases completed + expect(reportHealthy).toHaveBeenCalledTimes(1); + }); + }); + + describe("timeout behavior", () => { + it("emits unhealthy on timeout without expected failure", () => { + scenarioMonitor.start(MetricScenario.ApplicationLoad); + + // Let timeout fire without marking expected failure + jest.advanceTimersByTime(10000); + + expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL"); + expect(reportHealthy).not.toHaveBeenCalled(); + }); + + it("emits healthy on timeout with expected failure", () => { + scenarioMonitor.start(MetricScenario.ApplicationLoad); + + // Mark expected failure + scenarioMonitor.markExpectedFailure(); + + // Let timeout fire + jest.advanceTimersByTime(10000); + + expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL"); + expect(reportUnhealthy).not.toHaveBeenCalled(); + }); + + it("emits healthy even with partial phase completion and expected failure", () => { + scenarioMonitor.start(MetricScenario.ApplicationLoad); + + // Complete one phase + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); + + // Mark expected failure + scenarioMonitor.markExpectedFailure(); + + // Let timeout fire (Interactive phase not completed) + jest.advanceTimersByTime(10000); + + expect(reportHealthy).toHaveBeenCalled(); + expect(reportUnhealthy).not.toHaveBeenCalled(); + }); + }); + + describe("failPhase behavior", () => { + it("emits unhealthy immediately on unexpected failure", () => { + scenarioMonitor.start(MetricScenario.DatabaseLoad); + + // Fail a phase (simulating unexpected error) + scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched); + + // Should emit unhealthy immediately, not wait for timeout + expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.DatabaseLoad, configContext.platform, "SQL"); + }); + + it("does not emit twice after failPhase and timeout", () => { + scenarioMonitor.start(MetricScenario.DatabaseLoad); + + // Fail a phase + scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched); + + // Let timeout fire + jest.advanceTimersByTime(10000); + + // Should only have emitted once (from failPhase) + expect(reportUnhealthy).toHaveBeenCalledTimes(1); + }); + }); + + describe("completePhase behavior", () => { + it("emits healthy when all phases complete", () => { + scenarioMonitor.start(MetricScenario.ApplicationLoad); + + // Complete all required phases + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive); + + expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL"); + }); + + it("does not emit until all phases complete", () => { + scenarioMonitor.start(MetricScenario.ApplicationLoad); + + // Complete only one phase + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); + + expect(reportHealthy).not.toHaveBeenCalled(); + expect(reportUnhealthy).not.toHaveBeenCalled(); + }); + }); + + describe("scenario isolation", () => { + it("expected failure on one scenario does not affect others after completion", () => { + // Start both scenarios + scenarioMonitor.start(MetricScenario.ApplicationLoad); + scenarioMonitor.start(MetricScenario.DatabaseLoad); + + // Complete ApplicationLoad + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive); + + // Now mark expected failure - should only affect DatabaseLoad + scenarioMonitor.markExpectedFailure(); + + // Let DatabaseLoad timeout + jest.advanceTimersByTime(10000); + + // ApplicationLoad emitted healthy on completion + // DatabaseLoad emits healthy on timeout (expected failure) + expect(reportHealthy).toHaveBeenCalledTimes(2); + expect(reportUnhealthy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Metrics/ScenarioMonitor.ts b/src/Metrics/ScenarioMonitor.ts index 73e3fbcf7..4c4fa88c2 100644 --- a/src/Metrics/ScenarioMonitor.ts +++ b/src/Metrics/ScenarioMonitor.ts @@ -21,6 +21,7 @@ interface InternalScenarioContext { phases: Map; // Track start/end for each phase timeoutId?: number; emitted: boolean; + hasExpectedFailure: boolean; // Flag for expected failures (auth, firewall, etc.) } class ScenarioMonitor { @@ -75,6 +76,7 @@ class ScenarioMonitor { failed: new Set(), phases: new Map(), emitted: false, + hasExpectedFailure: false, }; // Start all required phases at scenario start time @@ -91,7 +93,11 @@ class ScenarioMonitor { timeoutMs: config.timeoutMs, }); - ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs); + ctx.timeoutId = window.setTimeout(() => { + // If an expected failure occurred (auth, firewall, etc.), emit healthy instead of unhealthy + const healthy = ctx.hasExpectedFailure; + this.emit(ctx, healthy, true); + }, config.timeoutMs); this.contexts.set(scenario, ctx); } @@ -175,6 +181,24 @@ class ScenarioMonitor { this.emit(ctx, false, false, failureSnapshot); } + /** + * Marks that an expected failure occurred (auth, firewall, permissions, etc.). + * When the scenario times out with this flag set, it will emit healthy instead of unhealthy. + * This is called automatically from handleError when an expected error is detected. + */ + markExpectedFailure() { + // Set the flag on all active (non-emitted) scenarios + this.contexts.forEach((ctx) => { + if (!ctx.emitted) { + ctx.hasExpectedFailure = true; + traceMark(Action.MetricsScenario, { + event: "expected_failure_marked", + scenario: ctx.scenario, + }); + } + }); + } + private tryEmitIfReady(ctx: InternalScenarioContext) { const allDone = ctx.config.requiredPhases.every((p) => ctx.completed.has(p)); if (!allDone) { @@ -247,7 +271,8 @@ class ScenarioMonitor { }); // Call portal backend health metrics endpoint - if (healthy && !timedOut) { + // If healthy is true (either completed successfully or timeout with expected failure), report healthy + if (healthy) { reportHealthy(ctx.scenario, platform, api); } else { reportUnhealthy(ctx.scenario, platform, api); @@ -302,6 +327,19 @@ class ScenarioMonitor { phaseTimings, }; } + + /** + * Reset all scenarios (for testing purposes only). + * Clears all active contexts and their timeouts. + */ + reset() { + this.contexts.forEach((ctx) => { + if (ctx.timeoutId) { + clearTimeout(ctx.timeoutId); + } + }); + this.contexts.clear(); + } } export const scenarioMonitor = new ScenarioMonitor(); diff --git a/src/Utils/AuthorizationUtils.ts b/src/Utils/AuthorizationUtils.ts index 84d4cfcc4..26dcd5d5d 100644 --- a/src/Utils/AuthorizationUtils.ts +++ b/src/Utils/AuthorizationUtils.ts @@ -8,6 +8,8 @@ import * as Logger from "../Common/Logger"; import { configContext } from "../ConfigContext"; import { DatabaseAccount } from "../Contracts/DataModels"; import * as ViewModels from "../Contracts/ViewModels"; +import { isExpectedError } from "../Metrics/ErrorClassification"; +import { scenarioMonitor } from "../Metrics/ScenarioMonitor"; import { trace, traceFailure } from "../Shared/Telemetry/TelemetryProcessor"; import { UserContext, userContext } from "../UserContext"; @@ -127,6 +129,10 @@ export async function acquireMsalTokenForAccount( acquireTokenType: silent ? "silent" : "interactive", errorMessage: JSON.stringify(error), }); + // Mark expected failure for health metrics so timeout emits healthy + if (isExpectedError(error)) { + scenarioMonitor.markExpectedFailure(); + } throw error; } } else { @@ -169,7 +175,10 @@ export async function acquireTokenWithMsal( acquireTokenType: "interactive", errorMessage: JSON.stringify(interactiveError), }); - + // Mark expected failure for health metrics so timeout emits healthy + if (isExpectedError(interactiveError)) { + scenarioMonitor.markExpectedFailure(); + } throw interactiveError; } } else { @@ -178,7 +187,10 @@ export async function acquireTokenWithMsal( acquireTokenType: "silent", errorMessage: JSON.stringify(silentError), }); - + // Mark expected failure for health metrics so timeout emits healthy + if (isExpectedError(silentError)) { + scenarioMonitor.markExpectedFailure(); + } throw silentError; } }