const RETRYABLE_ERROR_NAMES = new Set([ "AbortError", "AuthRetryableFetchError", "TimeoutError", ]); const RETRYABLE_MESSAGE_PATTERNS = [ /failed to fetch/i, /fetch failed/i, /network request failed/i, /networkerror/i, /timed? out/i, ]; export const SERVER_UNAVAILABLE_PATH = "/server-unavailable"; export const BACKEND_REQUEST_TIMEOUT_MS = 8_000; function hasControlCharacter(value: string): boolean { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index); if (code < 0x20 || code === 0x7f) return true; } return false; } /** Validates the already-decoded App Router query value used by the retry link. */ export function sanitizeBackendRetryPath( raw: string | null | undefined, fallback = "/home", ): string { const candidate = raw?.trim(); if (!candidate) return fallback; if (!candidate.startsWith("/") || candidate.startsWith("//")) return fallback; if (candidate.includes("\\") || hasControlCharacter(candidate)) return fallback; if (/^\/[^/]*:/i.test(candidate)) return fallback; try { const parsed = new URL(candidate, "https://minddy.invalid"); if (parsed.origin !== "https://minddy.invalid") return fallback; if (parsed.pathname === SERVER_UNAVAILABLE_PATH) return fallback; return `${parsed.pathname}${parsed.search}${parsed.hash}`; } catch { return fallback; } } /** * API code of a 401 caused by the caller's minddy session itself (missing, * invalid or no longer live), as opposed to a forge or relay credential. * Only this code may end the browser session (MIN-691). */ export const SESSION_INVALID_CODE = "sessionInvalid"; /** An HTTP failure that keeps its status after a client API response is parsed. */ export class HttpResponseError extends Error { constructor(message: string, readonly status: number, readonly code?: string) { super(message); this.name = "HttpResponseError"; } } /** The stable `code` of an API error body, when it has one. */ export function apiErrorCode(body: unknown): string | undefined { const code = (body as { code?: unknown } | null)?.code; return typeof code === "string" ? code : undefined; } /** * A read failure that withdraws already-loaded content: the session, access * or the resource itself is gone. Timeouts, network and 5xx failures only make * the content stale, so screens keep showing it instead of an error. */ export function isDefinitiveReadError(error: unknown): boolean { const status = (error as { status?: unknown } | null)?.status; return status === 401 || status === 403 || status === 404; } /** Default query retry: one more attempt, except for failures a replay cannot fix. */ export function retryTemporaryReadFailure(failureCount: number, error: unknown): boolean { return failureCount < 1 && !isDefinitiveReadError(error); } /** True for temporary transport and upstream failures, not normal auth errors. */ export function isBackendUnavailableError(error: unknown, depth = 0): boolean { if (!error || depth > 2) return false; if (typeof error !== "object") return false; const candidate = error as { cause?: unknown; message?: unknown; name?: unknown; status?: unknown; }; if ( typeof candidate.status === "number" && candidate.status >= 500 && candidate.status <= 599 ) { return true; } if ( typeof candidate.name === "string" && RETRYABLE_ERROR_NAMES.has(candidate.name) ) { return true; } const message = candidate.message; if ( typeof message === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message)) ) { return true; } return isBackendUnavailableError(candidate.cause, depth + 1); } /** Adds a hard deadline while preserving any abort signal supplied by the caller. */ export const backendFetchWithTimeout: typeof fetch = (input, init) => { const signals: AbortSignal[] = [AbortSignal.timeout(BACKEND_REQUEST_TIMEOUT_MS)]; if (input instanceof Request) signals.push(input.signal); if (init?.signal) signals.push(init.signal); return fetch(input, { ...init, signal: signals.length === 1 ? signals[0] : AbortSignal.any(signals), }); };