import type { ApiErrorBody } from "@/lib/api/types"; import { getPublicApiBaseUrl, getPublicApiTimeoutMs } from "@/lib/env"; import { getUserFriendlyError } from "./errorTypes"; import { markAuthenticationRequired } from "@/lib/auth/session-requirement"; const DEMO_PREVIEW_TOKEN = "docdoe-demo-preview-token"; export function getApiBaseUrl() { if (typeof window !== "undefined" && process.env.NODE_ENV === "production") { return `${window.location.origin}/api/backend`; } return getPublicApiBaseUrl(); } /** Cookie-bearing fetches only work first-party. Chrome blocks credentialed * calls from docdoe.in to the Hugging Face Space, which made login look dead. */ export function getFetchCredentials(): RequestCredentials { if (typeof window === "undefined") return "omit"; try { return new URL(getApiBaseUrl()).origin === window.location.origin ? "include" : "omit"; } catch { return "omit"; } } function resolveApiUrl(path: string) { return `${getApiBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`; } export function resolveMediaUrl(url: string | null | undefined) { if (!url) return null; if (/^https?:\/\//i.test(url)) { try { const media = new URL(url); if (media.origin === new URL(getPublicApiBaseUrl()).origin && media.pathname.startsWith("/generated/")) { return `${getApiBaseUrl()}${media.pathname}${media.search}`; } } catch { /* Preserve already resolved third-party media URLs. */ } } if (/^https?:\/\//i.test(url)) return url; return `${getApiBaseUrl()}${url.startsWith("/") ? url : `/${url}`}`; } export type ApiRequestOptions = RequestInit & { timeoutMs?: number; /** Keep stored session on 401/403 (used by /auth/session soft-fail). */ skipAuthClear?: boolean; }; /** How long the caller should wait before retrying, and whether the wait is a * short recoverable burst ("minute") or a hard daily cap ("daily"). */ export type RateLimitScope = "minute" | "daily"; export class ApiError extends Error { status: number | null; body: unknown; isAuthError: boolean; userMessage: string; /** Seconds to wait before retrying (from backend `retry_after` or `Retry-After` header). */ retryAfter: number | null; /** "minute" = auto-retry safe; "daily" = come back later, do not loop. */ rateLimitScope: RateLimitScope | null; /** Stable backend error code for feature-specific handling/support. */ code: string | null; /** Correlation ID shared by the backend response, logs, and support report. */ requestId: string | null; /** Whether retrying the same idempotent operation is expected to be safe. */ retryable: boolean; constructor({ message, status, body, retryAfter = null, rateLimitScope = null, code = null, requestId = null, retryable = false, }: { message: string; status: number | null; body?: unknown; retryAfter?: number | null; rateLimitScope?: RateLimitScope | null; code?: string | null; requestId?: string | null; retryable?: boolean; }) { super(message); this.name = "ApiError"; this.status = status; this.body = body; this.retryAfter = retryAfter; this.rateLimitScope = rateLimitScope; this.code = code; this.requestId = requestId; this.retryable = retryable; this.isAuthError = status === 401 || status === 403; this.userMessage = normalizeApiError(this); } } export function isApiError(error: unknown): error is ApiError { return error instanceof ApiError; } function flattenBackendDetail(detail: unknown): string | null { if (typeof detail === "string") return detail; if (detail && typeof detail === "object") { if ("message" in detail && typeof (detail as { message: unknown }).message === "string") { return (detail as { message: string }).message; } if ("error" in detail && typeof (detail as { error: unknown }).error === "string") { return (detail as { error: string }).error; } } if (Array.isArray(detail)) { const messages = detail .map((item) => { if (item && typeof item === "object" && "msg" in item) { return String((item as { msg: unknown }).msg); } return null; }) .filter(Boolean); return messages.length > 0 ? messages.join(" ") : null; } return null; } /** Pull `retry_after` (seconds) and `scope` out of a 429 error envelope, falling * back to the `Retry-After` HTTP header. Returns nulls when absent. */ function extractRateLimitMeta( body: unknown, response: Response, ): { retryAfter: number | null; scope: RateLimitScope | null } { let retryAfter: number | null = null; let scope: RateLimitScope | null = null; const details = body && typeof body === "object" && "error" in body ? (body as { error?: { details?: unknown } }).error?.details : undefined; if (details && typeof details === "object") { const d = details as { retry_after?: unknown; scope?: unknown }; if (typeof d.retry_after === "number" && Number.isFinite(d.retry_after)) { retryAfter = Math.max(0, Math.round(d.retry_after)); } if (d.scope === "minute" || d.scope === "daily") { scope = d.scope; } } if (retryAfter === null) { const header = response.headers.get("Retry-After"); const parsed = header ? Number.parseInt(header, 10) : NaN; if (Number.isFinite(parsed)) retryAfter = Math.max(0, parsed); } return { retryAfter, scope }; } /** @internal — exported for unit tests only */ export async function readError(response: Response) { const fallback = "Could not complete this right now. Please try again."; try { const body = (await response.json()) as ApiErrorBody; const { retryAfter, scope } = extractRateLimitMeta(body, response); const requestId = response.headers.get("X-Request-ID") ?? body.error?.request_id ?? body.request_id ?? null; const detailCode = body.detail && typeof body.detail === "object" && "code" in body.detail && typeof (body.detail as { code?: unknown }).code === "string" ? (body.detail as { code: string }).code : null; const code = body.error?.code ?? detailCode; const retryable = body.error?.retryable === true; // New hardened envelope: { success: false, error: { code, message } } if (body.error?.message) { return { message: body.error.message, body, retryAfter, scope, requestId, code, retryable }; } // Backward-compat: { detail: { code, message } } or { detail: "string" } const detail = flattenBackendDetail(body.detail); if (detail) return { message: detail, body, retryAfter, scope, requestId, code, retryable }; // Legacy: { message: "..." } if (typeof body.message === "string") { return { message: body.message, body, retryAfter, scope, requestId, code, retryable }; } } catch { const { retryAfter, scope } = extractRateLimitMeta(null, response); return { message: fallback, body: null, retryAfter, scope, requestId: response.headers.get("X-Request-ID"), code: null, retryable: response.status >= 500, }; } const { retryAfter, scope } = extractRateLimitMeta(null, response); return { message: fallback, body: null, retryAfter, scope, requestId: response.headers.get("X-Request-ID"), code: null, retryable: response.status >= 500, }; } function createTimeoutSignal(timeoutMs: number, signal?: AbortSignal) { const controller = new AbortController(); const timeoutId = globalThis.setTimeout(() => controller.abort(), timeoutMs); if (signal) { if (signal.aborted) { controller.abort(); } else { signal.addEventListener("abort", () => controller.abort(), { once: true }); } } return { signal: controller.signal, cleanup: () => globalThis.clearTimeout(timeoutId) }; } function getRequestAuthToken() { if (typeof window === "undefined") return null; const token = ( window.localStorage.getItem("exam_success_access_token") ?? window.sessionStorage.getItem("exam_success_access_token") ?? window.localStorage.getItem("docdoe_access_token") ?? window.sessionStorage.getItem("docdoe_access_token") ?? window.localStorage.getItem("access_token") ?? window.sessionStorage.getItem("access_token") ?? window.localStorage.getItem("auth_token") ?? window.sessionStorage.getItem("auth_token") ); return token === DEMO_PREVIEW_TOKEN ? null : token; } function clearInvalidSession(requestToken: string | null) { if (typeof window === "undefined") return; const token = getRequestAuthToken(); // A request started before OAuth completed must not revoke the new session. if (token !== requestToken) return; if (token === "docdoe-demo-preview-token") return; for (const storage of [window.localStorage, window.sessionStorage]) { storage.removeItem("exam_success_access_token"); storage.removeItem("exam_success_user"); storage.removeItem("exam_success_token_expires_at"); } markAuthenticationRequired(); window.dispatchEvent(new Event("docdoe-auth-session-cleared")); } export function normalizeApiError(error: unknown) { // An ApiError carries an authoritative HTTP status — map it FIRST so status // wins over fragile message-substring classification (e.g. so a 422 carrying // the backend's classification guidance passes through verbatim). if (error instanceof ApiError) { if (error.status === 401 || error.status === 403) { return "Please sign in again to continue."; } if (error.status === 404) { return "DocDoe could not find that item. Refresh and try again."; } if (error.status === 413) { return "This file is too large. Try a smaller file."; } if (error.status === 402) { // Plan/quota limit — backend sends a student-safe specific message. return error.message?.trim() || "You've reached your plan limit. Try again later or upgrade."; } if (error.status === 429) { if (error.rateLimitScope === "daily") { return "You've reached today's AI study limit. Try again when your allowance resets."; } if (error.retryAfter && error.retryAfter > 0) { return `You're moving fast. I'll be ready in ${error.retryAfter} second${error.retryAfter === 1 ? "" : "s"}.`; } return "You're moving fast. I'll be ready in a few seconds."; } // For 5xx errors always return a generic message — never pass raw server // messages to students since they may contain internal provider/path details. if (error.status && error.status >= 500) { const reference = error.requestId ? ` Reference ID: ${error.requestId}` : ""; return `I couldn't generate this right now. Your request is saved. Try again in a few seconds.${reference}`; } if (error.status === 422) { if (/classify this source first/i.test(error.message)) { return error.message.trim(); } // Fall through to the classification system for other 422s. const friendly422 = getUserFriendlyError(error); if (friendly422 !== "Something failed, but your request is saved. Try again.") { return friendly422; } return "We couldn't create this right now. Please try again."; } } if (error instanceof DOMException && error.name === "AbortError") { return "This is taking longer than expected. Your request is saved. Try again."; } if (error instanceof Error && error.name === "AbortError") { return "This is taking longer than expected. Your request is saved. Try again."; } if (error instanceof TypeError) { return "Connection issue. Check your internet and try again."; } // Non-status errors (local validation strings, generic Errors): use the // classification system for a friendly, actionable message. const userFriendlyError = getUserFriendlyError(error); if (userFriendlyError !== "Something failed, but your request is saved. Try again.") { return userFriendlyError; } if (error instanceof ApiError && error.message.trim()) { return error.message; } if (error instanceof Error && error.message.trim()) { return error.message; } return "Something went wrong. Please try again."; } // Calendar hint only: the backend validates the zone and owns all dates. function getStudentTimezone(): string | null { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch { if (process.env.NODE_ENV !== "production" && process.env.LOG_LEVEL === "debug") { console.debug("[FIX:student-calendar] Timezone unavailable; using server fallback"); } return null; } } export async function apiFetch( path: string, init: ApiRequestOptions = {}, ): Promise { const authToken = getRequestAuthToken(); const studentTimezone = getStudentTimezone(); const { timeoutMs = getPublicApiTimeoutMs(), signal, headers, skipAuthClear = false, ...requestInit } = init; const timeout = createTimeoutSignal(timeoutMs, signal ?? undefined); let response: Response; try { response = await fetch(resolveApiUrl(path), { ...requestInit, credentials: getFetchCredentials(), signal: timeout.signal, headers: { Accept: "application/json", ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), ...(studentTimezone ? { "X-Student-Timezone": studentTimezone } : {}), ...(requestInit.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...headers, }, }); } catch (error) { throw new ApiError({ message: normalizeApiError(error), status: null, body: error, }); } finally { timeout.cleanup(); } if (!response.ok) { const errorData = await readError(response); const apiError = new ApiError({ message: errorData.message, status: response.status, body: errorData.body, retryAfter: errorData.retryAfter, rateLimitScope: errorData.scope, code: errorData.code, requestId: errorData.requestId, retryable: errorData.retryable, }); if (apiError.isAuthError) { if (!skipAuthClear) clearInvalidSession(authToken); } throw apiError; } if (response.status === 204) { return undefined as TResponse; } return (await response.json()) as TResponse; } export function apiGet(path: string, init?: ApiRequestOptions) { return apiFetch(path, { ...init, method: "GET", }); } export async function apiJson( path: string, body: TBody, init?: ApiRequestOptions, ): Promise { return apiFetch(path, { ...init, method: "POST", body: JSON.stringify(body), }); } export function apiPost( path: string, body: TBody, init?: ApiRequestOptions, ) { return apiJson(path, body, init); } export function apiPatch( path: string, body: TBody, init?: ApiRequestOptions, ) { return apiFetch(path, { ...init, method: "PATCH", body: JSON.stringify(body), }); } export function apiDelete(path: string, init?: ApiRequestOptions) { return apiFetch(path, { ...init, method: "DELETE", }); } export async function apiForm( path: string, body: FormData, init?: ApiRequestOptions, ): Promise { return apiFetch(path, { ...init, method: "POST", body, }); } export const apiUpload = apiForm; export function buildQuery(params: Record) { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value === null || value === undefined || value === "") return; query.set(key, String(value)); }); const text = query.toString(); return text ? `?${text}` : ""; } export async function isBackendAvailable() { try { await apiFetch("/health", { timeoutMs: 4000 }); return true; } catch { return false; } } /** * Basic SSE streaming helper (for /ask/stream or /chat/stream etc). * Uses native ReadableStream. Callers provide onDelta etc. * (enh1: added to base client for /ask or chat streaming clients) */ type SseEventPayload = Record & { delta?: unknown; done?: unknown; error?: unknown; }; export async function streamSSE( path: string, body: unknown, callbacks: { onDelta?: (text: string) => void; onEvent?: (evt: SseEventPayload) => void; onDone?: (meta?: SseEventPayload) => void; onError?: (msg: string) => void; }, signal?: AbortSignal, ): Promise { const authToken = getRequestAuthToken(); const studentTimezone = getStudentTimezone(); let response: Response; try { response = await fetch(resolveApiUrl(path), { method: "POST", credentials: getFetchCredentials(), signal, headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...(studentTimezone ? { "X-Student-Timezone": studentTimezone } : {}), ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), }, body: JSON.stringify(body), }); } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") return; callbacks.onError?.("Stream request failed to start."); return; } if (!response.ok || !response.body) { callbacks.onError?.("Streaming endpoint unavailable."); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buf = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf("\n\n")) >= 0) { const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 2); if (line.startsWith("data: ")) { const payload = line.slice(6); try { const evt = JSON.parse(payload) as SseEventPayload; callbacks.onEvent?.(evt); if (evt.delta) callbacks.onDelta?.(String(evt.delta)); if (evt.done || evt.error) { callbacks.onDone?.(evt); return; } } catch { // plain text delta fallback callbacks.onDelta?.(payload); } } } } callbacks.onDone?.(); } catch (e: unknown) { if (e instanceof Error && e.name !== "AbortError") { callbacks.onError?.(e.message || "Stream read error."); } } finally { try { reader.releaseLock(); } catch {} } }