Download src/lib/api/client.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 18.8 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/api/client.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/api/client.ts
-
curl -L -o client.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/api/client.ts
18.8 kB
| 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<TResponse>( | |
| path: string, | |
| init: ApiRequestOptions = {}, | |
| ): Promise<TResponse> { | |
| 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<TResponse>(path: string, init?: ApiRequestOptions) { | |
| return apiFetch<TResponse>(path, { | |
| ...init, | |
| method: "GET", | |
| }); | |
| } | |
| export async function apiJson<TResponse, TBody extends object>( | |
| path: string, | |
| body: TBody, | |
| init?: ApiRequestOptions, | |
| ): Promise<TResponse> { | |
| return apiFetch<TResponse>(path, { | |
| ...init, | |
| method: "POST", | |
| body: JSON.stringify(body), | |
| }); | |
| } | |
| export function apiPost<TResponse, TBody extends object>( | |
| path: string, | |
| body: TBody, | |
| init?: ApiRequestOptions, | |
| ) { | |
| return apiJson<TResponse, TBody>(path, body, init); | |
| } | |
| export function apiPatch<TResponse, TBody extends object>( | |
| path: string, | |
| body: TBody, | |
| init?: ApiRequestOptions, | |
| ) { | |
| return apiFetch<TResponse>(path, { | |
| ...init, | |
| method: "PATCH", | |
| body: JSON.stringify(body), | |
| }); | |
| } | |
| export function apiDelete<TResponse = void>(path: string, init?: ApiRequestOptions) { | |
| return apiFetch<TResponse>(path, { | |
| ...init, | |
| method: "DELETE", | |
| }); | |
| } | |
| export async function apiForm<TResponse>( | |
| path: string, | |
| body: FormData, | |
| init?: ApiRequestOptions, | |
| ): Promise<TResponse> { | |
| return apiFetch<TResponse>(path, { | |
| ...init, | |
| method: "POST", | |
| body, | |
| }); | |
| } | |
| export const apiUpload = apiForm; | |
| export function buildQuery(params: Record<string, string | number | boolean | null | undefined>) { | |
| 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<unknown>("/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<string, unknown> & { | |
| 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<void> { | |
| 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 {} | |
| } | |
| } | |