DocDoeAI / src /lib /api /client.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
66680b1
Raw History Blame Contribute Delete
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 {}
}
}