web: send X-Correlation-ID on every API call, read the error envelope, show the reference

authenticatedFetch mints one UUID per logical call (kept across the 401 retry,
where caller headers now survive too), sends it as X-Correlation-ID — the id
the backend echoes, stamps on its request row and writes into every error body
— and tracks api_request_failed {path, method, status, correlationId} for any
final status >= 400. App Insights gets enableCorsCorrelation + W3C
distributedTracingMode scoped to the API host, so portal dependency rows and
backend request rows share an operation_Id.

fetchJson/ApiError read the envelope (error, code, correlationId, hint; the
pre-envelope `message` as fallback), describeApiError renders message +
short reference, addNotification takes an optional reference the bell renders
as "Ref …"; the error boundaries show and track Next's digest. Consumers of
renamed fields follow: delete-session parser (code ?? hint, PascalCase codes),
app-homing (code ?? reason). API_ERROR_CODES comes from the manifest, and
HOME_TENANT_UNRESOLVED plus the cascade codes are `satisfies ApiErrorCode`.
InstallsTab is the first site showing the reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-05 01:29:54 +02:00
parent 6e54b52376
commit 2abc78ab6d
18 changed files with 365 additions and 29 deletions
@@ -380,9 +380,11 @@ function TenantManagementSectionInner({
tenantId: tenant.tenantId,
target,
force,
reason: data.reason ?? `http-${response.status}`,
reason: data.code ?? data.reason ?? `http-${response.status}`,
});
throw new Error(appHomingErrorMessage(data.reason, response.statusText, data.probe?.missingRoles));
// `code` is the error-envelope field (AppHomingDeniedResponse); `reason` is the pre-envelope
// name, read until the backend deploy that precedes this web build is everywhere.
throw new Error(appHomingErrorMessage(data.code ?? data.reason, response.statusText, data.probe?.missingRoles));
}
trackEvent("app_homing_manual_flip", {
tenantId: tenant.tenantId,
@@ -8,6 +8,7 @@ import { useAuth } from "../../../contexts/AuthContext";
import { useNotifications } from "../../../contexts/NotificationContext";
import { scopedApi } from "@/lib/scopedApi";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { apiErrorFromResponse, describeApiError } from "@/lib/scopedFetch";
import { formatBytes, formatDuration } from "@/lib/formatting";
import DoBreakdownBar from "@/components/DoBreakdownBar";
import { CalculatingInline } from "@/components/CalculatingCard";
@@ -119,7 +120,9 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
setData((await listRes.json()) as AppsListResponse);
succeeded = true;
} else {
addNotification("error", "Backend Error", `Failed to load apps: ${listRes.statusText}`, "apps-list-error");
// Error envelope: the backend's message plus the correlation id as a quotable reference.
const { message, reference } = describeApiError(await apiErrorFromResponse(listRes));
addNotification("error", "Backend Error", `Failed to load apps: ${message}`, "apps-list-error", undefined, reference ?? undefined);
}
if (metricsRes.ok) {
const m = (await metricsRes.json()) as AppMetricsResponse;
@@ -6,6 +6,7 @@
* The hook itself runs the side-effects (toast, SignalR group join, row removal); these
* helpers describe *what* should happen for a given backend response.
*/
import type { ApiErrorCode } from "@/lib/apiErrorCodes";
/**
* Backend body shape for the V2 cascade 202 response.
@@ -18,18 +19,29 @@ export interface DeleteQueuedBody {
message?: string;
}
/** Backend body shape for 4xx/5xx responses — same producer outcome contract. */
/**
* Backend body shape for 4xx/5xx responses: the error envelope (`SessionDeletionRejectedResponse`
* — error, code, correlationId + the lock diagnostics). `message`/`hint` are the pre-envelope
* names, still read during the backend → web deploy window.
*/
export interface DeleteErrorBody {
success?: boolean;
message?: string;
error?: string;
code?: string;
correlationId?: string;
deletionState?: string;
manifestId?: string;
/** @deprecated pre-envelope name of `error` */
message?: string;
/** @deprecated pre-envelope name of `code` */
hint?: string;
}
/** Envelope codes of the 409 arms (Constants.ApiErrorCodes) plus their pre-envelope `hint` spellings. */
const POISONED_CODES: ReadonlySet<string> = new Set(["CascadePoisonedUseRestore" satisfies ApiErrorCode, "cascade_poisoned_use_restore"]);
export type DeleteResponseAction =
| { kind: "queued"; sessionId: string; tenantId: string; manifestId: string | null }
| { kind: "conflict"; sessionId: string; title: string; message: string; hint: string | null }
| { kind: "conflict"; sessionId: string; title: string; message: string; code: string | null }
| { kind: "unavailable"; sessionId: string; message: string }
| { kind: "notFound"; sessionId: string; message: string }
| { kind: "error"; sessionId: string; message: string };
@@ -56,14 +68,14 @@ export async function classifyDeleteResponse(
}
const errorBody = await safeJson<DeleteErrorBody>(response);
const message = errorBody?.message ?? `HTTP ${response.status}`;
const message = errorBody?.error ?? errorBody?.message ?? `HTTP ${response.status}`;
if (response.status === 409) {
const hint = errorBody?.hint ?? null;
const title = hint === "cascade_poisoned_use_restore"
const code = errorBody?.code ?? errorBody?.hint ?? null;
const title = code !== null && POISONED_CODES.has(code)
? "Cascade poisoned"
: "Cascade already in flight";
return { kind: "conflict", sessionId, title, message, hint };
return { kind: "conflict", sessionId, title, message, code };
}
if (response.status === 503) {
@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isChunkLoadError, tryRecoverFromChunkError } from "@/utils/chunkReloadRecovery";
import { trackEvent } from "@/lib/appInsights";
/**
* Route-level error boundary — catches unhandled exceptions within page
@@ -25,7 +26,11 @@ export default function Error({
useEffect(() => {
if (isChunkLoadError(error)) {
tryRecoverFromChunkError("error-boundary");
return;
}
// The digest is Next's stable hash of the error (also in the server log) — the only
// reference a user can quote for a client-side crash; the event makes it searchable.
trackEvent("client_error_boundary", { digest: error.digest ?? "", name: error.name });
}, [error]);
return (
@@ -36,6 +41,9 @@ export default function Error({
<p className="text-gray-600 mb-6 leading-relaxed">
Your session may have expired. Try reloading the page or signing in again.
</p>
{error.digest && (
<p className="text-[11px] text-gray-400 mb-4 font-mono">Ref {error.digest}</p>
)}
<div className="flex gap-3 justify-center flex-wrap">
<button
onClick={() => router.push("/")}
@@ -9,6 +9,7 @@
* it replaces the entire root layout when triggered.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
@@ -45,6 +46,11 @@ export default function GlobalError({
<p style={{ color: "#6b7280", marginBottom: "1.5rem", lineHeight: 1.5 }}>
Your session may have expired or the app encountered an unexpected error.
</p>
{error?.digest && (
<p style={{ color: "#9ca3af", fontSize: "0.6875rem", fontFamily: "ui-monospace, monospace", marginBottom: "1rem" }}>
Ref {error.digest}
</p>
)}
<div style={{ display: "flex", gap: "0.75rem", justifyContent: "center", flexWrap: "wrap" }}>
<button
onClick={() => {
@@ -389,6 +389,9 @@ export default function Navbar() {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{notification.title}</p>
<p className="text-xs text-gray-600 mt-0.5">{notification.message}</p>
{notification.reference && (
<p className="text-[11px] text-gray-400 mt-0.5 font-mono">Ref {notification.reference}</p>
)}
<div className="flex items-center gap-3 mt-1">
<p className="text-[10px] text-gray-400">{formatTime(notification.timestamp)}</p>
{notification.href && (
@@ -13,12 +13,13 @@ export interface Notification {
read: boolean;
key?: string; // Optional unique key for deduplication
href?: string; // Optional navigation link shown as "View" action
reference?: string; // Optional short correlation id (backend failures) rendered as "Ref …"
}
interface NotificationContextType {
notifications: Notification[];
unreadCount: number;
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string) => void;
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string, reference?: string) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
@@ -30,7 +31,7 @@ const NotificationContext = createContext<NotificationContextType | undefined>(u
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((type: NotificationType, title: string, message: string, key?: string, href?: string) => {
const addNotification = useCallback((type: NotificationType, title: string, message: string, key?: string, href?: string, reference?: string) => {
// Check if a notification with this key already exists
if (key) {
setNotifications(prev => {
@@ -43,6 +44,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
...existing,
message,
href,
reference,
timestamp: new Date(),
read: false, // Mark as unread to get user's attention again
};
@@ -62,6 +64,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
read: false,
key,
href,
reference,
};
// Auto-remove success/info notifications after 10 seconds
@@ -84,6 +87,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
timestamp: new Date(),
read: false,
href,
reference,
};
setNotifications(prev => [notification, ...prev]);
@@ -1,5 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { authenticatedFetch, TokenExpiredError } from "../authenticatedFetch";
import { trackEvent } from "../appInsights";
vi.mock("../appInsights", () => ({ trackEvent: vi.fn() }));
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
function mockResponse(status: number, body: unknown = {}): Response {
return new Response(JSON.stringify(body), {
@@ -80,6 +85,61 @@ describe("authenticatedFetch", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("sends a fresh X-Correlation-ID on every call", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValue(mockResponse(200));
await authenticatedFetch("https://api/a", getToken);
await authenticatedFetch("https://api/b", getToken);
const first = (fetchMock.mock.calls[0][1].headers as Headers).get("X-Correlation-ID");
const second = (fetchMock.mock.calls[1][1].headers as Headers).get("X-Correlation-ID");
expect(first).toMatch(UUID);
expect(second).toMatch(UUID);
expect(first).not.toBe(second);
});
it("keeps the same correlation id and the caller's headers on the 401 retry", async () => {
const getToken = vi.fn().mockResolvedValueOnce("stale").mockResolvedValueOnce("fresh");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce(mockResponse(401)).mockResolvedValueOnce(mockResponse(200));
await authenticatedFetch("https://api/x", getToken, { headers: { "X-Client-Hint": "keep-me" } });
const first = fetchMock.mock.calls[0][1].headers as Headers;
const retry = fetchMock.mock.calls[1][1].headers as Headers;
expect(retry.get("X-Correlation-ID")).toBe(first.get("X-Correlation-ID"));
expect(retry.get("X-Client-Hint")).toBe("keep-me");
});
it("tracks api_request_failed with path, method, status and the correlation id on a final >= 400", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce(mockResponse(404, { error: "nope" }));
await authenticatedFetch("https://api/api/sessions/abc?tenantId=t1", getToken, { method: "delete" });
const sent = (fetchMock.mock.calls[0][1].headers as Headers).get("X-Correlation-ID");
expect(trackEvent).toHaveBeenCalledWith("api_request_failed", {
path: "/api/sessions/abc",
method: "DELETE",
status: 404,
correlationId: sent,
});
});
it("does not track a 401 that the retry turned into a success", async () => {
const getToken = vi.fn().mockResolvedValueOnce("stale").mockResolvedValueOnce("fresh");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce(mockResponse(401)).mockResolvedValueOnce(mockResponse(200));
vi.mocked(trackEvent).mockClear();
await authenticatedFetch("https://api/x", getToken);
expect(trackEvent).not.toHaveBeenCalled();
});
it("passes through non-401 error responses without retry", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
@@ -71,7 +71,7 @@ describe("summarizeDeleteActions", () => {
it("is a warning when some sessions could not be queued", () => {
const s = summarizeDeleteActions([
queued("a"),
{ kind: "conflict", sessionId: "b", title: "Cascade already in flight", message: "m", hint: null },
{ kind: "conflict", sessionId: "b", title: "Cascade already in flight", message: "m", code: null },
{ kind: "unavailable", sessionId: "c", message: "m" },
{ kind: "error", sessionId: "d", message: "m" },
]);
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { CORRELATION_HEADER, newCorrelationId, shortCorrelationId } from "../correlationId";
// Mirrors CorrelationIdMiddleware's allow-list — an id outside it is silently replaced server-side
// and the client-side reference would then never match a backend row.
const BACKEND_ALLOW_LIST = /^[A-Za-z0-9_-]{1,128}$/;
describe("correlationId", () => {
it("uses the backend's header spelling (capital ID)", () => {
expect(CORRELATION_HEADER).toBe("X-Correlation-ID");
});
it("mints ids the backend accepts verbatim and never repeats", () => {
const ids = new Set(Array.from({ length: 200 }, () => newCorrelationId()));
expect(ids.size).toBe(200);
for (const id of ids) expect(id).toMatch(BACKEND_ALLOW_LIST);
});
it("shortens a UUID to its first block and anything else to 8 chars", () => {
expect(shortCorrelationId("3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f")).toBe("3f2a9c1e");
expect(shortCorrelationId("abcdefghijklmnop")).toBe("abcdefgh");
});
});
@@ -62,7 +62,7 @@ describe("classifyDeleteResponse", () => {
expect(action.kind).toBe("conflict");
if (action.kind !== "conflict") throw new Error("type narrowing");
expect(action.title).toBe("Cascade already in flight");
expect(action.hint).toBe("cascade_already_in_flight");
expect(action.code).toBe("cascade_already_in_flight");
expect(action.message).toContain("already in flight");
});
@@ -79,7 +79,7 @@ describe("classifyDeleteResponse", () => {
expect(action.kind).toBe("conflict");
if (action.kind !== "conflict") throw new Error("type narrowing");
expect(action.title).toBe("Cascade poisoned");
expect(action.hint).toBe("cascade_poisoned_use_restore");
expect(action.code).toBe("cascade_poisoned_use_restore");
// The UI surfaces this verbatim → must mention the restore endpoint so the user knows where to go.
expect(action.message).toContain("/restore");
});
@@ -98,6 +98,22 @@ describe("classifyDeleteResponse", () => {
expect(action.message).toContain("kill-switch");
});
it("reads the error envelope (error + PascalCase code) and still maps the legacy hint", async () => {
const r = jsonResponse(409, {
error: "Cascade is poisoned; recover via POST /api/global/sessions/{id}/restore before retrying delete.",
code: "CascadePoisonedUseRestore",
correlationId: "cid-1",
deletionState: "Poisoned",
manifestId: "01J0123456789ABCDEFGHIJKLM",
});
const action = await classifyDeleteResponse(r, SESSION_ID, TENANT_ID);
expect(action.kind).toBe("conflict");
if (action.kind !== "conflict") throw new Error("unreachable");
expect(action.title).toBe("Cascade poisoned");
expect(action.code).toBe("CascadePoisonedUseRestore");
expect(action.message).toContain("/restore");
});
it("classifies 404 as notFound (idempotent: row already gone)", async () => {
const r = jsonResponse(404, { success: false, message: `Session ${SESSION_ID} not found` });
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiErrorFromResponse, describeApiError, fetchJson } from "../scopedFetch";
import { TokenExpiredError } from "../authenticatedFetch";
vi.mock("../appInsights", () => ({ trackEvent: vi.fn() }));
function jsonResponse(status: number, body: unknown, statusText = "Error"): Response {
return new Response(JSON.stringify(body), { status, statusText, headers: { "Content-Type": "application/json" } });
}
describe("apiErrorFromResponse", () => {
it("reads the error envelope: error, code, correlationId, hint", async () => {
const err = await apiErrorFromResponse(
jsonResponse(404, { error: "Session not found", code: "NotFound", correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f", hint: "Check the id." }),
);
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(404);
expect(err.message).toBe("Session not found");
expect(err.code).toBe("NotFound");
expect(err.correlationId).toBe("3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
expect(err.hint).toBe("Check the id.");
});
it("falls back to the pre-envelope message and then to statusText", async () => {
const legacy = await apiErrorFromResponse(jsonResponse(400, { success: false, message: "tenantId is required" }, "Bad Request"));
expect(legacy.message).toBe("tenantId is required");
expect(legacy.code).toBe("");
expect(legacy.correlationId).toBe("");
const bare = await apiErrorFromResponse(new Response("not json", { status: 502, statusText: "Bad Gateway" }));
expect(bare.message).toBe("Bad Gateway");
expect(bare.hint).toBeNull();
});
});
describe("fetchJson", () => {
beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("returns the parsed body on success and throws the envelope ApiError otherwise", async () => {
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce(jsonResponse(200, { items: [1] }));
const getToken = vi.fn().mockResolvedValue("tok");
await expect(fetchJson<{ items: number[] }>("https://api/x", getToken)).resolves.toEqual({ items: [1] });
fetchMock.mockResolvedValueOnce(jsonResponse(403, { error: "Access denied.", code: "Forbidden", correlationId: "cid-1" }));
const err = await fetchJson("https://api/x", getToken).catch((e) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).code).toBe("Forbidden");
expect((err as ApiError).correlationId).toBe("cid-1");
});
});
describe("describeApiError", () => {
it("renders an ApiError with the short correlation id as reference", () => {
const err = new ApiError(500, "GetSessions failed.", "InternalError", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
expect(describeApiError(err)).toEqual({ message: "GetSessions failed.", reference: "3f2a9c1e" });
});
it("has no reference for a token expiry or a plain Error, and uses the fallback for unknown values", () => {
expect(describeApiError(new TokenExpiredError()).reference).toBeNull();
expect(describeApiError(new Error("boom"))).toEqual({ message: "boom", reference: null });
expect(describeApiError(undefined, "Could not load.")).toEqual({ message: "Could not load.", reference: null });
expect(describeApiError(new ApiError(502, "", "", ""), "Upstream failed.").message).toBe("Upstream failed.");
});
});
@@ -0,0 +1,11 @@
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
/**
* Machine-readable `code` values an error envelope can carry — derived from the generated shared
* manifest (source of truth: Constants.ApiErrorCodes, DelegationCodes, DelegatedSlots and
* AppHomingReasonCodes in AutopilotMonitor.Shared). A web literal that the backend no longer
* emits fails tsc (`satisfies ApiErrorCode`) instead of silently never matching.
*/
export const API_ERROR_CODES = SHARED_MANIFEST.apiErrorCodes;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
@@ -1,7 +1,22 @@
import { ApplicationInsights } from "@microsoft/applicationinsights-web";
import { ApplicationInsights, DistributedTracingModes } from "@microsoft/applicationinsights-web";
import { API_BASE_URL } from "@/utils/config";
let appInsights: ApplicationInsights | null = null;
/**
* Host of the backend API for the SDK's cross-origin correlation: the portal → API calls are
* cross-origin, and without an explicit allow-list the SDK attaches no W3C `traceparent` to
* them, so the portal's dependency rows and the backend's request rows never share an
* operation_Id. Only the API host — never the identity or blob hosts.
*/
function apiHost(): string | null {
try {
return new URL(API_BASE_URL).host;
} catch {
return null;
}
}
// Events fired before initAppInsights (module-init code like the MSAL redirect handling and
// the dual app-reg login fallback runs before React mounts AppInsightsInit) are buffered and
// replayed on init instead of being dropped — those early auth events are exactly the ones
@@ -20,6 +35,7 @@ const telemetryConfig = {
export function initAppInsights(connectionString: string) {
if (appInsights || !connectionString || typeof window === "undefined") return;
const host = apiHost();
appInsights = new ApplicationInsights({
config: {
connectionString,
@@ -27,6 +43,12 @@ export function initAppInsights(connectionString: string) {
enableAutoRouteTracking: true,
disableFetchTracking: false,
disablePageUnloadEvents: ["unload"],
// Distributed tracing to the backend: W3C traceparent on API calls (backend side:
// RequestTelemetryMiddleware reads Activity.Current), scoped to the API host so the
// preflight-triggering headers never reach login.microsoftonline.com or blob storage.
enableCorsCorrelation: host !== null,
correlationHeaderDomains: host ? [host] : undefined,
distributedTracingMode: DistributedTracingModes.W3C,
},
});
@@ -11,9 +11,27 @@
* by passing their own AbortSignal via init.signal.
*/
import { trackEvent } from "./appInsights";
import { CORRELATION_HEADER, newCorrelationId } from "./correlationId";
/** Default request timeout in milliseconds */
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Every failed API call (final status >= 400) lands in App Insights with its correlation id,
* so a user report ("it said error") joins the backend request row by one KQL predicate.
* Path only — query strings can carry ids and filters that are not telemetry material.
*/
function trackFailure(url: string, method: string, status: number, correlationId: string) {
let path = url;
try {
path = new URL(url, "http://localhost").pathname;
} catch {
/* keep the raw url */
}
trackEvent("api_request_failed", { path, method, status, correlationId });
}
export class TokenExpiredError extends Error {
constructor() {
super('Your session has expired. Please reload the page to sign in again.');
@@ -33,8 +51,14 @@ export async function authenticatedFetch(
throw new TokenExpiredError();
}
// One id per logical call: the 401 retry below is the same call and carries the same id, so
// both backend request rows join on it. Minted here, never read back (see correlationId.ts).
const correlationId = newCorrelationId();
const method = (init?.method ?? 'GET').toUpperCase();
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${token}`);
headers.set(CORRELATION_HEADER, correlationId);
// Apply a default timeout unless the caller already provided an AbortSignal
const signal = init?.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
@@ -50,14 +74,17 @@ export async function authenticatedFetch(
const retryHeaders = new Headers(init?.headers);
retryHeaders.set('Authorization', `Bearer ${freshToken}`);
retryHeaders.set(CORRELATION_HEADER, correlationId);
const retrySignal = init?.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
const retryResponse = await fetch(url, { ...init, headers: retryHeaders, signal: retrySignal });
if (retryResponse.status === 401) {
throw new TokenExpiredError();
}
if (retryResponse.status >= 400) trackFailure(url, method, retryResponse.status, correlationId);
return retryResponse;
}
if (response.status >= 400) trackFailure(url, method, response.status, correlationId);
return response;
}
@@ -0,0 +1,28 @@
/**
* Per-request correlation id for API calls.
*
* The backend's CorrelationIdMiddleware honours an inbound `X-Correlation-ID` when it matches
* `^[A-Za-z0-9_-]{1,128}$`, echoes it as a response header, stamps it on the App Insights request
* row (customDimensions.CorrelationId) and writes it into every error body (`correlationId`).
* Minting the id HERE — not reading the server's — is deliberate: platform CORS exposes no
* response headers to browser script, so a client-minted id is the only one both sides know.
* A UUID satisfies the backend's allow-list.
*/
export const CORRELATION_HEADER = "X-Correlation-ID";
export function newCorrelationId(): string {
const c = typeof globalThis !== "undefined" ? (globalThis as { crypto?: Crypto }).crypto : undefined;
if (c && typeof c.randomUUID === "function") return c.randomUUID();
if (c && typeof c.getRandomValues === "function") {
const bytes = new Uint8Array(16);
c.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
// No Web Crypto (very old runtime): still unique enough to correlate one report.
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 14)}`;
}
/** Short form for user-facing "Ref" lines: the first block of a UUID, or the first 8 chars. */
export function shortCorrelationId(id: string): string {
return id.split("-")[0].slice(0, 8);
}
@@ -1,3 +1,5 @@
import type { ApiErrorCode } from "./apiErrorCodes";
/**
* The Entra identity (home tenant + object id) a cross-tenant-role UPN is bound to, as returned by
* /api/global/identity-bindings (camelCase JSON). A grant is only usable from this identity: the backend
@@ -15,7 +17,7 @@ export interface IdentityBinding {
}
/** Error code the grant endpoints return (HTTP 422) when the person's home tenant could not be resolved. */
export const HOME_TENANT_UNRESOLVED = "HomeTenantUnresolved";
export const HOME_TENANT_UNRESOLVED = "HomeTenantUnresolved" satisfies ApiErrorCode;
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -1,21 +1,52 @@
import { authenticatedFetch } from "./authenticatedFetch";
import { authenticatedFetch, TokenExpiredError } from "./authenticatedFetch";
import { shortCorrelationId } from "./correlationId";
/**
* JSON fetch layer for the scope-migrated pages: authenticatedFetch + ok-check + body
* parse in one call, so call sites stop hand-rolling the response boilerplate. Throws
* ApiError on a non-ok response with the backend's `message` when the body carries one
* (TokenExpiredError from authenticatedFetch passes through untouched).
* ApiError on a non-ok response carrying the backend's error envelope
* (`{ error, code, correlationId, hint? }` — every non-2xx body since the 2026-09 envelope
* pass; TokenExpiredError from authenticatedFetch passes through untouched).
*/
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
/** Machine-readable code (Constants.ApiErrorCodes or a domain code class); "" when the body had none. */
public readonly code: string = "",
/** The request's correlation id — the handle for the backend log; "" when the body had none. */
public readonly correlationId: string = "",
public readonly hint: string | null = null,
) {
super(message);
this.name = "ApiError";
}
}
/** Shape of the backend error envelope (ApiErrorResponse and its specialised siblings). */
interface ErrorEnvelope {
error?: unknown;
code?: unknown;
correlationId?: unknown;
hint?: unknown;
/** Pre-envelope bodies used `message`; still read for the deploy window between backend and web. */
message?: unknown;
}
const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
/** Build an ApiError from a non-ok response: envelope fields when present, statusText otherwise. */
export async function apiErrorFromResponse(response: Response): Promise<ApiError> {
let body: ErrorEnvelope | null = null;
try {
body = (await response.json()) as ErrorEnvelope;
} catch {
/* not JSON */
}
const message = str(body?.error) ?? str(body?.message) ?? response.statusText;
return new ApiError(response.status, message, str(body?.code) ?? "", str(body?.correlationId) ?? "", str(body?.hint));
}
export async function fetchJson<T>(
url: string,
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
@@ -23,14 +54,21 @@ export async function fetchJson<T>(
): Promise<T> {
const response = await authenticatedFetch(url, getAccessToken, init);
if (!response.ok) {
let detail = response.statusText;
try {
const body = await response.json();
if (body?.message) detail = body.message;
} catch {
/* not JSON */
}
throw new ApiError(response.status, detail);
throw await apiErrorFromResponse(response);
}
return (await response.json()) as T;
}
/**
* One rendering for any thrown error: the user-facing message plus, for backend failures,
* the short correlation id as a reference the user can quote to support. Pass `reference`
* to addNotification so the bell shows it.
*/
export function describeApiError(err: unknown, fallback = "Request failed."): { message: string; reference: string | null } {
if (err instanceof TokenExpiredError) return { message: err.message, reference: null };
if (err instanceof ApiError) {
return { message: err.message || fallback, reference: err.correlationId ? shortCorrelationId(err.correlationId) : null };
}
if (err instanceof Error && err.message) return { message: err.message, reference: null };
return { message: fallback, reference: null };
}