lib/backend-availability.ts
Rama main · réplica de lectura del repositorio original.
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),
});
};