feat(frontend): add AbortSignal/timeout support to API client
- Add FetchOptions.signal for request cancellation (timeout or unmount) - Propagate signal to native fetch() in fetchApi, fetchApiBlob, postApi, requestApi, patchApi, putApi, and deleteApi - Export API_REQUEST_TIMEOUT constant (30s default) - Add @INVARIANT for signal propagation contract - Add @RATIONALE documenting the anti-loop protocol motivation
This commit is contained in:
@@ -13,7 +13,11 @@
|
||||
// @SIDE_EFFECT Reads localStorage for auth token on every request. Dispatches error toasts on non-suppressed failures.
|
||||
// @INVARIANT Every fetch MUST go through fetchApi/requestApi/postApi/deleteApi — never native fetch().
|
||||
// @INVARIANT Every response.json() is wrapped in try/catch that converts HTTP errors to ApiError.
|
||||
// @INVARIANT FetchOptions.signal is passed to native fetch() for cancellation/timeout support.
|
||||
// @DATA_CONTRACT Input: (endpoint, body?, options?) → Output: Promise<T> | Promise<Blob> | Error(ApiError)
|
||||
// @RATIONALE FetchOptions.signal added per semantics-core anti-loop protocol — prevents infinite loading
|
||||
// state when backend is unreachable. Components can now pass AbortSignal.timeout() or
|
||||
// onDestroy-aborted signals to cancel in-flight requests.
|
||||
// @RATIONALE fetch wrappers exist to enforce auth token injection, centralized error handling,
|
||||
// toast feedback, and trace_id propagation — without wrapping every component in try/catch boilerplate.
|
||||
// @REJECTED Native fetch() rejected — would bypass auth header injection, error normalization,
|
||||
@@ -27,6 +31,9 @@ import type { FetchOptions, DashboardListParams } from '../types/api';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
/** Default timeout for API requests in milliseconds (30s). Consumer may override via AbortSignal.timeout(). */
|
||||
export const API_REQUEST_TIMEOUT = 30_000;
|
||||
|
||||
// #region ApiTypes [C:1] [TYPE Block] [SEMANTICS api, types, interfaces]
|
||||
// @BRIEF Internal type definitions for error handling and API configuration.
|
||||
// @DATA_CONTRACT ApiError: { message, status, detail, error_code }
|
||||
@@ -222,7 +229,9 @@ function getAuthHeaders(extraHeaders: Record<string, string> = {}): Record<strin
|
||||
async function fetchApi<T = unknown>(endpoint: string, options: FetchOptions = {}): Promise<T> {
|
||||
try {
|
||||
log('api', 'REASON', 'fetchApi', { endpoint });
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers: getAuthHeaders(options.headers || {}) });
|
||||
const fetchInit: RequestInit = { headers: getAuthHeaders(options.headers || {}) };
|
||||
if (options.signal) fetchInit.signal = options.signal;
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
if (response.status === 204) return null as T;
|
||||
return await response.json() as T;
|
||||
@@ -248,7 +257,9 @@ async function fetchApi<T = unknown>(endpoint: string, options: FetchOptions = {
|
||||
async function fetchApiBlob(endpoint: string, options: FetchOptions = {}): Promise<Blob> {
|
||||
const notifyError = options.notifyError !== false;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers: getAuthHeaders(options.headers || {}) });
|
||||
const fetchInit: RequestInit = { headers: getAuthHeaders(options.headers || {}) };
|
||||
if (options.signal) fetchInit.signal = options.signal;
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
|
||||
if (response.status === 202) {
|
||||
const payload: Record<string, unknown> = await response.json().catch(() => ({ message: "Resource is being prepared" }));
|
||||
const error: ApiError = new Error((payload?.message as string) || "Resource is being prepared") as ApiError;
|
||||
@@ -278,9 +289,11 @@ async function fetchApiBlob(endpoint: string, options: FetchOptions = {}): Promi
|
||||
// @RELATION DEPENDS_ON -> [getAuthHeaders]
|
||||
async function postApi<T = unknown>(endpoint: string, body: unknown, options: FetchOptions = {}): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
const fetchInit: RequestInit = {
|
||||
method: 'POST', headers: getAuthHeaders(options.headers || {}), body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
if (options.signal) fetchInit.signal = options.signal;
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
if (response.status === 204) return null as T;
|
||||
return await response.json() as T;
|
||||
@@ -307,7 +320,9 @@ async function postApi<T = unknown>(endpoint: string, body: unknown, options: Fe
|
||||
// are destructive operations — the user must know if one failed.
|
||||
async function deleteApi<T = unknown>(endpoint: string, options: FetchOptions = {}): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, { method: 'DELETE', headers: getAuthHeaders(options.headers || {}) });
|
||||
const fetchInit: RequestInit = { method: 'DELETE', headers: getAuthHeaders(options.headers || {}) };
|
||||
if (options.signal) fetchInit.signal = options.signal;
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
if (response.status === 204) return null as T;
|
||||
return await response.json() as T;
|
||||
@@ -335,9 +350,10 @@ async function deleteApi<T = unknown>(endpoint: string, options: FetchOptions =
|
||||
// @RELATION DEPENDS_ON -> [getAuthHeaders]
|
||||
async function requestApi<T = unknown>(endpoint: string, method: string = 'GET', body: unknown = null, requestOptions: FetchOptions = {}): Promise<T> {
|
||||
try {
|
||||
const fetchOptions: RequestInit = { method, headers: getAuthHeaders(requestOptions.headers || {}) };
|
||||
if (body) fetchOptions.body = JSON.stringify(body);
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchOptions);
|
||||
const fetchInit: RequestInit = { method, headers: getAuthHeaders(requestOptions.headers || {}) };
|
||||
if (body) fetchInit.body = JSON.stringify(body);
|
||||
if (requestOptions.signal) fetchInit.signal = requestOptions.signal;
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
if (response.status === 204) return null as T;
|
||||
return await response.json() as T;
|
||||
@@ -482,7 +498,8 @@ export const api = {
|
||||
// @RELATION DEPENDS_ON -> [postApi]
|
||||
// @DATA_CONTRACT params -> { plugin_id, params: object }
|
||||
// @DATA_CONTRACT response -> { task_id: string }
|
||||
createTask: <T = unknown>(pluginId: string, params: unknown) => postApi<T>('/tasks', { plugin_id: pluginId, params }),
|
||||
createTask: <T = unknown>(pluginId: string, params: unknown, requestOptions?: FetchOptions) =>
|
||||
postApi<T>('/tasks', { plugin_id: pluginId, params }, requestOptions),
|
||||
// #endregion createTask
|
||||
|
||||
// ═══ Profile ══════════════════════════════════════════════════
|
||||
@@ -583,7 +600,8 @@ export const api = {
|
||||
// @BRIEF Update environment refresh schedule (PUT).
|
||||
// @LAYER API
|
||||
// @RELATION DEPENDS_ON -> [requestApi]
|
||||
updateEnvironmentSchedule: <T = unknown>(id: string, s: unknown) => requestApi<T>(`/environments/${id}/schedule`, 'PUT', s),
|
||||
updateEnvironmentSchedule: <T = unknown>(id: string, s: unknown, options?: FetchOptions) =>
|
||||
requestApi<T>(`/environments/${id}/schedule`, 'PUT', s, options),
|
||||
// #endregion updateEnvironmentSchedule
|
||||
|
||||
// #region getStorageSettings [C:2] [TYPE Function] [SEMANTICS settings,api,storage,list]
|
||||
@@ -606,7 +624,7 @@ export const api = {
|
||||
// @LAYER API
|
||||
// @RELATION DEPENDS_ON -> [fetchApi]
|
||||
// @DATA_CONTRACT response -> { id, name, url, status }[] // flat list form
|
||||
getEnvironmentsList: <T = unknown>() => fetchApi<T>('/environments'),
|
||||
getEnvironmentsList: <T = unknown>(options?: FetchOptions) => fetchApi<T>('/environments', options),
|
||||
// #endregion getEnvironmentsList
|
||||
|
||||
// ═══ LLM ══════════════════════════════════════════════════════
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface FetchOptions {
|
||||
headers?: Record<string, string>;
|
||||
suppressToast?: boolean;
|
||||
notifyError?: boolean;
|
||||
/** Optional AbortSignal for request cancellation (timeout or component unmount). */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Options for paginated list endpoints. */
|
||||
|
||||
Reference in New Issue
Block a user