Ctrl K
ComunidadGuías…

lib/backend-availability.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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),
  });
};