sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* API Client
|
||||
* Centralized HTTP client with interceptors, error handling, and type safety.
|
||||
* (Ported from fe0/src/shared/api/client.ts — only the two intra-package import paths changed.)
|
||||
*/
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosError,
|
||||
InternalAxiosRequestConfig,
|
||||
AxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { env } from '../config/env';
|
||||
import { getAccessToken, clearAccessToken, setAccessToken } from '../utils/token';
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status: number;
|
||||
data?: any;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Thrown by {@link ApiClient.postArrayBuffer} on HTTP error responses so callers can inspect status (e.g. 501). */
|
||||
export class ApiArrayBufferRequestError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiArrayBufferRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the client rejected a response with 404 (Axios or normalized {@link ApiError}). */
|
||||
export function isApiNotFoundError(err: unknown): boolean {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) return true;
|
||||
if (err && typeof err === 'object') {
|
||||
const s = (err as Partial<ApiError>).status;
|
||||
if (typeof s === 'number' && s === 404) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Used by QueryClient retry: never backoff-retry auth failures (avoids poll storms on expired sessions). */
|
||||
export function isAuthQueryError(err: unknown): boolean {
|
||||
if (err instanceof AxiosError) {
|
||||
const s = err.response?.status;
|
||||
return s === 401 || s === 403;
|
||||
}
|
||||
if (err && typeof err === 'object') {
|
||||
const s = (err as Partial<ApiError>).status;
|
||||
if (typeof s === 'number') return s === 401 || s === 403;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function summarizeResponseDataForDevLog(data: unknown): unknown {
|
||||
if (data == null) return data;
|
||||
if (typeof data !== 'object') return data;
|
||||
const rec = data as Record<string, unknown>;
|
||||
if (Array.isArray(rec.data)) {
|
||||
return { ...rec, data: `[${rec.data.length} items]` };
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function stringifyFastApiDetail(detail: unknown): string | undefined {
|
||||
if (detail == null) return undefined;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const parts = detail
|
||||
.map((x) =>
|
||||
typeof x === 'object' && x && 'msg' in x ? String((x as { msg: string }).msg) : String(x),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True for the synthetic "HTTP <status>" message the interceptor attaches to 4xx rejections. */
|
||||
function isSyntheticHttpMessage(message: unknown): boolean {
|
||||
return typeof message === 'string' && /^HTTP \d+$/.test(message.trim());
|
||||
}
|
||||
|
||||
/** Human-readable message from ApiClient errors, ApiError-shaped objects, or raw AxiosErrors. */
|
||||
export function detailFromApiError(err: unknown, fallback = 'Lỗi không xác định.'): string {
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as ApiError & {
|
||||
response?: { data?: { detail?: unknown; message?: string } };
|
||||
};
|
||||
// Prefer the FastAPI `{detail}` — on a raw AxiosError it lives at err.response.data.detail;
|
||||
// on a normalized ApiError it lives at err.data.detail. Either beats the synthetic "HTTP <status>".
|
||||
const detail = stringifyFastApiDetail(e.response?.data?.detail ?? e.data?.detail);
|
||||
if (detail) return detail;
|
||||
const respMessage = e.response?.data?.message;
|
||||
if (typeof respMessage === 'string' && respMessage.trim()) return respMessage;
|
||||
if (typeof e.message === 'string' && e.message.trim() && !isSyntheticHttpMessage(e.message)) {
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
if (err instanceof Error && err.message.trim() && !isSyntheticHttpMessage(err.message)) {
|
||||
return err.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('API Client initialized with baseURL:', env.API_URL);
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: env.API_URL,
|
||||
timeout: 60000, // 60s for Ollama responses
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true,
|
||||
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
this.client.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
// Multipart uploads: let the browser set Content-Type (boundary). A global
|
||||
// application/json default breaks FastAPI's UploadFile / Form parsing.
|
||||
if (config.data instanceof FormData && config.headers) {
|
||||
delete (config.headers as Record<string, unknown>)['Content-Type'];
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => {
|
||||
// Default validateStatus allows 4xx; reject here so `.then(res => res.data)` never treats
|
||||
// FastAPI `{ detail }` bodies as successful entity payloads.
|
||||
if (response.status >= 400) {
|
||||
const err = new AxiosError(
|
||||
`HTTP ${response.status}`,
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
response.config,
|
||||
response.request,
|
||||
response,
|
||||
);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('✅ API OK', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: summarizeResponseDataForDevLog(response.data),
|
||||
});
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('❌ API Error:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
response: error.response
|
||||
? { status: error.response.status, data: error.response.data }
|
||||
: null,
|
||||
config: {
|
||||
url: error.config?.url,
|
||||
baseURL: error.config?.baseURL,
|
||||
method: error.config?.method,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
// Handle 401 - Unauthorized → try one refresh.
|
||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const newToken = await this.refreshToken();
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return this.client(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
clearAccessToken();
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(this.handleError(error));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<string | null> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${env.API_URL}/api/v1/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true, timeout: 15_000 },
|
||||
);
|
||||
const { accessToken } = response.data;
|
||||
setAccessToken(accessToken);
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Token refresh failed:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: AxiosError): ApiError {
|
||||
if (error.response) {
|
||||
const data = error.response.data as any;
|
||||
const detailStr = stringifyFastApiDetail(data?.detail);
|
||||
return {
|
||||
message: data?.message || detailStr || 'An error occurred',
|
||||
status: error.response.status,
|
||||
data,
|
||||
code: data?.code,
|
||||
};
|
||||
} else if (error.request) {
|
||||
return {
|
||||
message: `Network error. Please check your connection. URL: ${error.config?.baseURL}${error.config?.url}`,
|
||||
status: 0,
|
||||
code: 'NETWORK_ERROR',
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
status: 0,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.client.get<T>(url, config).then((res) => res.data);
|
||||
}
|
||||
|
||||
public post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.client.post<T>(url, data, config).then((res) => res.data);
|
||||
}
|
||||
|
||||
/** Binary POST (e.g. .docx). On 4xx/5xx, attempts to read JSON or text from the error body. */
|
||||
public postArrayBuffer(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ArrayBuffer> {
|
||||
const decodeErrorBody = (raw: ArrayBuffer, status: number) => {
|
||||
const text = new TextDecoder().decode(new Uint8Array(raw));
|
||||
let message = `HTTP ${status}`;
|
||||
try {
|
||||
const j = JSON.parse(text) as { detail?: unknown; message?: string };
|
||||
const d = j.detail;
|
||||
if (typeof d === 'string') message = d;
|
||||
else if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') {
|
||||
message = (d[0] as { msg: string }).msg;
|
||||
} else if (j.message) message = j.message;
|
||||
} catch {
|
||||
if (text.trim()) message = text;
|
||||
}
|
||||
return new ApiArrayBufferRequestError(message, status);
|
||||
};
|
||||
return this.client
|
||||
.post(url, data, { ...config, responseType: 'arraybuffer' })
|
||||
.then((res) => {
|
||||
if (res.status >= 400) {
|
||||
return Promise.reject(decodeErrorBody(res.data as ArrayBuffer, res.status));
|
||||
}
|
||||
return res.data as ArrayBuffer;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const ax = err as { response?: { status?: number; data?: ArrayBuffer } };
|
||||
if (ax?.response?.data instanceof ArrayBuffer && ax.response?.status) {
|
||||
return Promise.reject(decodeErrorBody(ax.response.data, ax.response.status));
|
||||
}
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
const ae = err as ApiError;
|
||||
if (typeof ae.status === 'number' && ae.status >= 400) {
|
||||
const msg =
|
||||
typeof ae.message === 'string' && ae.message.trim()
|
||||
? ae.message
|
||||
: stringifyFastApiDetail(ae.data?.detail) || `HTTP ${ae.status}`;
|
||||
return Promise.reject(new ApiArrayBufferRequestError(msg, ae.status));
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
public put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.client.put<T>(url, data, config).then((res) => res.data);
|
||||
}
|
||||
|
||||
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.client.delete<T>(url, config).then((res) => res.data);
|
||||
}
|
||||
|
||||
public patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.client.patch<T>(url, data, config).then((res) => res.data);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
Reference in New Issue
Block a user