diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts index 710498dfc..20a983525 100644 --- a/frontend/src/services/http.ts +++ b/frontend/src/services/http.ts @@ -1,3 +1,4 @@ +// frontend/src/services/http.ts import axios from 'axios'; import type { AxiosInstance } from 'axios'; import { alert } from '../components/toast'; @@ -5,6 +6,11 @@ import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } f import { showSpecialErrorToast } from './specialErrorToasts'; const FRIENDLY_FALLBACK = 'There was an error processing your request.'; +const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts + +function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string { + return s && s.length > max ? `${s.slice(0, max)}…` : s; +} function isUnhelpfulMessage(msg: string | null | undefined): boolean { const s = (msg || '').trim(); @@ -74,7 +80,7 @@ function extractAxiosErrorMessage(error: any): { title: string; body: string } { } } -// Create axios instance with default config similar to SaaS client +// ---------- Axios instance creation ---------- const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined); type ExtendedAxiosInstance = AxiosInstance & { @@ -82,8 +88,8 @@ type ExtendedAxiosInstance = AxiosInstance & { isCancel: typeof axios.isCancel; }; -// Reuse existing client across HMR reloads to avoid duplicate interceptors -const __PREV_CLIENT: ExtendedAxiosInstance | undefined = __globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined; +const __PREV_CLIENT: ExtendedAxiosInstance | undefined = + __globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined; let __createdClient: any; if (__PREV_CLIENT) { @@ -91,79 +97,125 @@ if (__PREV_CLIENT) { } else if (typeof (axios as any)?.create === 'function') { try { __createdClient = (axios as any).create(); - } catch (_e) { + } catch (e) { + console.debug('createClient', e); __createdClient = axios as any; } } else { __createdClient = axios as any; } + const apiClient: ExtendedAxiosInstance = (__createdClient || (axios as any)) as ExtendedAxiosInstance; -// Augment instance with axios static helpers for backwards compatibility +// Augment instance with axios static helpers for backwards compatibility if (apiClient) { - try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (_e) { void _e; } - try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (_e) { void _e; } + try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (e) { console.debug('setCancelToken', e); } + try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (e) { console.debug('setIsCancel', e); } } -// Install Axios response error interceptor on the instance (guard against double-registration in HMR) +// ---------- Base defaults ---------- +try { + const env = (import.meta as any)?.env || {}; + apiClient.defaults.baseURL = env?.VITE_API_BASE_URL ?? '/'; + apiClient.defaults.responseType = 'json'; + // If OSS relies on cookies, uncomment: + // apiClient.defaults.withCredentials = true; + // Sensible timeout to avoid “forever hanging”: + apiClient.defaults.timeout = 20000; +} catch (e) { + console.debug('setDefaults', e); + apiClient.defaults.baseURL = apiClient.defaults.baseURL || '/'; + apiClient.defaults.responseType = apiClient.defaults.responseType || 'json'; + apiClient.defaults.timeout = apiClient.defaults.timeout || 20000; +} + +// ---------- Install a single response error interceptor (dedup + UX) ---------- if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined && __PREV_CLIENT) { - try { __PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID); } catch (_e) { void _e; } + try { + __PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID); + } catch (e) { + console.debug('ejectInterceptor', e); + } } const __recentSpecialByEndpoint: Record = (__globalAny?.__SPDF_RECENT_SPECIAL || {}); const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast -const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use ? apiClient.interceptors.response.use( - (response) => response, - async (error) => { - const { title, body } = extractAxiosErrorMessage(error); - // If server sends structured file IDs for failures, also mark them errored in UI - try { + +const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use + ? apiClient.interceptors.response.use( + (response) => response, + async (error) => { + // Compute title/body (friendly) from the error object + const { title, body } = extractAxiosErrorMessage(error); + + // Normalize response data ONCE, reuse for both ID extraction and special-toast matching const raw = (error?.response?.data) as any; - const data = await normalizeAxiosErrorData(raw); - const ids = extractErrorFileIds(data); - if (ids && ids.length > 0) { - broadcastErroredFiles(ids); - } - } catch (_e) { void _e; } + let normalized: unknown = raw; + try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); } - // Generic-vs-special dedupe by endpoint - const url: string | undefined = error?.config?.url; - const status: number | undefined = error?.response?.status; - const now = Date.now(); - const isSpecial = status === 422 || /Failed files:/.test(body) || /invalid\/corrupted file\(s\)/i.test(body); - if (isSpecial && url) { - __recentSpecialByEndpoint[url] = now; - if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; - } - if (!isSpecial && url) { - const last = __recentSpecialByEndpoint[url] || 0; - if (now - last < __SPECIAL_SUPPRESS_MS) { - return Promise.reject(error); + // 1) If server sends structured file IDs for failures, also mark them errored in UI + try { + const ids = extractErrorFileIds(normalized); + if (ids && ids.length > 0) { + broadcastErroredFiles(ids); + } + } catch (e) { + console.debug('extractErrorFileIds', e); } - } - // Show specialized friendly toasts if matched; otherwise show the generic one - const raw = (error?.response?.data) as any; - let rawString: string | undefined; - try { - if (typeof raw === 'string') rawString = raw; - else rawString = await normalizeAxiosErrorData(raw).then((d) => (typeof d === 'string' ? d : JSON.stringify(d))); - } catch { /* ignore */ } - const handled = showSpecialErrorToast(rawString, { status }); - if (!handled) { - alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false }); + // 2) Generic-vs-special dedupe by endpoint + const url: string | undefined = error?.config?.url; + const status: number | undefined = error?.response?.status; + const now = Date.now(); + const isSpecial = + status === 422 || + status === 409 || // often actionable conflicts + /Failed files:/.test(body) || + /invalid\/corrupted file\(s\)/i.test(body); + + if (isSpecial && url) { + __recentSpecialByEndpoint[url] = now; + if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; + } + if (!isSpecial && url) { + const last = __recentSpecialByEndpoint[url] || 0; + if (now - last < __SPECIAL_SUPPRESS_MS) { + return Promise.reject(error); + } + } + + // 3) Show specialized friendly toasts if matched; otherwise show the generic one + let rawString: string | undefined; + try { + rawString = + typeof normalized === 'string' + ? normalized + : JSON.stringify(normalized); + } catch (e) { + console.debug('extractErrorFileIds', e); + } + + const handled = showSpecialErrorToast(rawString, { status }); + if (!handled) { + const displayBody = clampText(body); + alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false }); + } + + return Promise.reject(error); } - return Promise.reject(error); - } -) : undefined as any; + ) + : undefined as any; + if (__globalAny) { __globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__; __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; __globalAny.__SPDF_HTTP_CLIENT = apiClient; } +// ---------- Fetch helper ---------- export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init }); + if (!res.ok) { let detail = ''; try { @@ -177,14 +229,19 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr } catch { // ignore parse errors } + const title = titleForStatus(res.status); const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText); - alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false }); + alert({ alertType: 'error', title, body: clampText(body), expandable: true, isPersistentPopup: false }); + + // Important: match Axios semantics so callers can try/catch + throw new Error(body || res.statusText); } + return res; } - +// ---------- Convenience API surface and exports ---------- export const api = { get: apiClient.get, post: apiClient.post, @@ -195,6 +252,4 @@ export const api = { }; export default apiClient; -export type { CancelTokenSource } from 'axios'; - - +export type { CancelTokenSource } from 'axios'; \ No newline at end of file