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:
2026-06-04 16:16:25 +03:00
parent 2d4caefeff
commit 3be05d7b88
2 changed files with 31 additions and 11 deletions

View File

@@ -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 ══════════════════════════════════════════════════════

View File

@@ -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. */