mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
# Description of Changes - Added error handling and toast notifications --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
255 lines
9.3 KiB
TypeScript
255 lines
9.3 KiB
TypeScript
// frontend/src/services/http.ts
|
|
import axios from 'axios';
|
|
import type { AxiosInstance } from 'axios';
|
|
import { alert } from '../components/toast';
|
|
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils';
|
|
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();
|
|
if (!s) return true;
|
|
// Common unhelpful payloads we see
|
|
if (s === '{}' || s === '[]') return true;
|
|
if (/^request failed/i.test(s)) return true;
|
|
if (/^network error/i.test(s)) return true;
|
|
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
|
|
return false;
|
|
}
|
|
|
|
function titleForStatus(status?: number): string {
|
|
if (!status) return 'Network error';
|
|
if (status >= 500) return 'Server error';
|
|
if (status >= 400) return 'Request error';
|
|
return 'Request failed';
|
|
}
|
|
|
|
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
|
|
if (axios.isAxiosError(error)) {
|
|
const status = error.response?.status;
|
|
const _statusText = error.response?.statusText || '';
|
|
let parsed: any = undefined;
|
|
const raw = error.response?.data;
|
|
if (typeof raw === 'string') {
|
|
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
|
|
} else {
|
|
parsed = raw;
|
|
}
|
|
const extractIds = (): string[] | undefined => {
|
|
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
|
|
const rawText = typeof raw === 'string' ? raw : '';
|
|
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
|
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
|
|
};
|
|
|
|
const body = ((): string => {
|
|
const data = parsed;
|
|
if (!data) return typeof raw === 'string' ? raw : '';
|
|
const ids = extractIds();
|
|
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
|
if (data?.message) return data.message as string;
|
|
if (typeof raw === 'string') return raw;
|
|
try { return JSON.stringify(data); } catch { return ''; }
|
|
})();
|
|
const ids = extractIds();
|
|
const title = titleForStatus(status);
|
|
if (ids && ids.length > 0) {
|
|
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
|
|
}
|
|
if (status === 422) {
|
|
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
|
|
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
|
return { title, body: bodyMsg };
|
|
}
|
|
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
|
|
return { title, body: bodyMsg };
|
|
}
|
|
try {
|
|
const msg = (error?.message || String(error)) as string;
|
|
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
|
} catch (e) {
|
|
// ignore extraction errors
|
|
console.debug('extractAxiosErrorMessage', e);
|
|
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
|
}
|
|
}
|
|
|
|
// ---------- Axios instance creation ----------
|
|
const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined);
|
|
|
|
type ExtendedAxiosInstance = AxiosInstance & {
|
|
CancelToken: typeof axios.CancelToken;
|
|
isCancel: typeof axios.isCancel;
|
|
};
|
|
|
|
const __PREV_CLIENT: ExtendedAxiosInstance | undefined =
|
|
__globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined;
|
|
|
|
let __createdClient: any;
|
|
if (__PREV_CLIENT) {
|
|
__createdClient = __PREV_CLIENT;
|
|
} else if (typeof (axios as any)?.create === 'function') {
|
|
try {
|
|
__createdClient = (axios as any).create();
|
|
} 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
|
|
if (apiClient) {
|
|
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); }
|
|
}
|
|
|
|
// ---------- 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) {
|
|
console.debug('ejectInterceptor', e);
|
|
}
|
|
}
|
|
|
|
const __recentSpecialByEndpoint: Record<string, number> = (__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) => {
|
|
// 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;
|
|
let normalized: unknown = raw;
|
|
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
)
|
|
: 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<Response> {
|
|
const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init });
|
|
|
|
if (!res.ok) {
|
|
let detail = '';
|
|
try {
|
|
const ct = res.headers.get('content-type') || '';
|
|
if (ct.includes('application/json')) {
|
|
const data = await res.json();
|
|
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
|
|
} else {
|
|
detail = await res.text();
|
|
}
|
|
} 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: 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,
|
|
put: apiClient.put,
|
|
patch: apiClient.patch,
|
|
delete: apiClient.delete,
|
|
request: apiClient.request,
|
|
};
|
|
|
|
export default apiClient;
|
|
export type { CancelTokenSource } from 'axios'; |