Files
zclaw_openfang/desktop/src/lib/request-helper.ts
iven f79560a911 refactor(desktop): split kernel_commands/pipeline_commands into modules, add SaaS client libs and gateway modules
Split monolithic kernel_commands.rs (2185 lines) and pipeline_commands.rs (1391 lines)
into focused sub-modules under kernel_commands/ and pipeline_commands/ directories.
Add gateway module (commands, config, io, runtime), health_check, and 15 new
TypeScript client libraries for SaaS relay, auth, admin, telemetry, and kernel
sub-systems (a2a, agent, chat, hands, skills, triggers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:12:47 +08:00

548 lines
14 KiB
TypeScript

/**
* Request Helper Module
*
* Provides request timeout, automatic retry with exponential backoff,
* and request cancellation support for API clients.
*
* @module lib/request-helper
*/
import { createLogger } from './logger';
const log = createLogger('RequestHelper');
// === Configuration Types ===
export interface RequestConfig {
/** Timeout in milliseconds, default 30000 */
timeout?: number;
/** Number of retry attempts, default 3 */
retries?: number;
/** Base retry delay in milliseconds, default 1000 (exponential backoff applied) */
retryDelay?: number;
/** HTTP status codes that trigger retry, default [408, 429, 500, 502, 503, 504] */
retryOn?: number[];
/** Maximum retry delay cap in milliseconds, default 30000 */
maxRetryDelay?: number;
}
export const DEFAULT_REQUEST_CONFIG: Required<RequestConfig> = {
timeout: 30000,
retries: 3,
retryDelay: 1000,
retryOn: [408, 429, 500, 502, 503, 504],
maxRetryDelay: 30000,
};
// === Error Types ===
export class RequestError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly statusText: string,
public readonly responseBody?: string
) {
super(message);
this.name = 'RequestError';
}
/** Check if error is retryable based on status code */
isRetryable(retryCodes: number[] = DEFAULT_REQUEST_CONFIG.retryOn): boolean {
return retryCodes.includes(this.status);
}
/** Check if error is a timeout */
isTimeout(): boolean {
return this.status === 408 || this.message.includes('timeout');
}
/** Check if error is an authentication error (should NOT retry) */
isAuthError(): boolean {
return this.status === 401 || this.status === 403;
}
}
export class RequestCancelledError extends Error {
constructor(message: string = 'Request cancelled') {
super(message);
this.name = 'RequestCancelledError';
}
}
// === Helper Functions ===
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Calculate exponential backoff delay with jitter
* @param baseDelay Base delay in ms
* @param attempt Current attempt number (0-indexed)
* @param maxDelay Maximum delay cap
* @returns Delay in milliseconds
*/
function calculateBackoff(
baseDelay: number,
attempt: number,
maxDelay: number = DEFAULT_REQUEST_CONFIG.maxRetryDelay
): number {
// Exponential backoff: baseDelay * 2^attempt
const exponentialDelay = baseDelay * Math.pow(2, attempt);
// Cap at maxDelay
const cappedDelay = Math.min(exponentialDelay, maxDelay);
// Add jitter (0-25% of delay) to prevent thundering herd
const jitter = cappedDelay * 0.25 * Math.random();
return Math.floor(cappedDelay + jitter);
}
// === Request with Retry ===
export interface RequestWithRetryOptions extends RequestInit {
/** Request configuration for timeout and retry */
config?: RequestConfig;
}
/**
* Execute a fetch request with timeout and automatic retry support.
*
* Features:
* - Configurable timeout with AbortController
* - Automatic retry with exponential backoff
* - Configurable retry status codes
* - Jitter to prevent thundering herd
*
* @param url Request URL
* @param options Fetch options + request config
* @param config Request configuration (timeout, retries, etc.)
* @returns Promise<Response>
* @throws RequestError on failure after all retries exhausted
* @throws RequestCancelledError if request was cancelled
*/
export async function requestWithRetry(
url: string,
options: RequestInit = {},
config: RequestConfig = {}
): Promise<Response> {
const {
timeout = DEFAULT_REQUEST_CONFIG.timeout,
retries = DEFAULT_REQUEST_CONFIG.retries,
retryDelay = DEFAULT_REQUEST_CONFIG.retryDelay,
retryOn = DEFAULT_REQUEST_CONFIG.retryOn,
} = config;
let lastError: RequestError | null = null;
let responseBody = '';
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
// Try to read response body for error details
try {
responseBody = await response.text();
} catch (e) {
log.debug('Failed to read response body', { error: e });
responseBody = '';
}
const error = new RequestError(
`Request failed: ${response.status} ${response.statusText}`,
response.status,
response.statusText,
responseBody
);
// Check if we should retry
if (retryOn.includes(response.status) && attempt < retries) {
const backoff = calculateBackoff(retryDelay, attempt);
log.warn(
`Request failed (${response.status}), ` +
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
);
await delay(backoff);
continue;
}
throw error;
}
// Success - return response
return response;
} catch (error) {
clearTimeout(timeoutId);
// Re-throw RequestError (already formatted)
if (error instanceof RequestError) {
lastError = error;
// Check if we should retry
if (error.isRetryable(retryOn) && attempt < retries) {
const backoff = calculateBackoff(retryDelay, attempt);
log.warn(
`Request error (${error.status}), ` +
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
);
await delay(backoff);
continue;
}
throw error;
}
// Handle AbortError (timeout)
if (error instanceof Error && error.name === 'AbortError') {
const timeoutError = new RequestError(
`Request timeout after ${timeout}ms`,
408,
'Request Timeout'
);
// Retry on timeout
if (attempt < retries) {
const backoff = calculateBackoff(retryDelay, attempt);
log.warn(
`Request timed out, ` +
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
);
await delay(backoff);
lastError = timeoutError;
continue;
}
throw timeoutError;
}
// Handle cancellation
if (error instanceof RequestCancelledError) {
throw error;
}
// Unknown error - wrap and throw
throw new RequestError(
error instanceof Error ? error.message : 'Unknown error',
0,
'Unknown Error',
error instanceof Error ? error.stack : String(error)
);
}
}
// All retries exhausted
throw lastError || new RequestError('All retry attempts exhausted', 0, 'Retry Exhausted');
}
/**
* Execute a request and parse JSON response.
* Combines requestWithRetry with JSON parsing.
*
* @param url Request URL
* @param options Fetch options
* @param config Request configuration
* @returns Parsed JSON response
*/
export async function requestJson<T = unknown>(
url: string,
options: RequestInit = {},
config: RequestConfig = {}
): Promise<T> {
const response = await requestWithRetry(url, options, config);
try {
return await response.json();
} catch (error) {
throw new RequestError(
`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`,
0,
'Parse Error',
await response.text().catch(() => '')
);
}
}
// === Request Manager (Cancellation Support) ===
/**
* Manages multiple concurrent requests with cancellation support.
* Provides centralized control over request lifecycle.
*/
export class RequestManager {
private controllers = new Map<string, AbortController>();
private requestConfigs = new Map<string, RequestConfig>();
/**
* Create a new request with an ID for tracking.
* Returns the AbortController for signal attachment.
*
* @param id Unique request identifier
* @param config Optional request configuration
* @returns AbortController for the request
*/
createRequest(id: string, config?: RequestConfig): AbortController {
// Cancel existing request with same ID
if (this.controllers.has(id)) {
this.cancelRequest(id);
}
const controller = new AbortController();
this.controllers.set(id, controller);
if (config) {
this.requestConfigs.set(id, config);
}
return controller;
}
/**
* Execute a managed request with automatic tracking.
* The request will be automatically removed when complete.
*
* @param id Unique request identifier
* @param url Request URL
* @param options Fetch options
* @param config Request configuration
* @returns Response promise
*/
async executeManaged(
id: string,
url: string,
options: RequestInit = {},
config: RequestConfig = {}
): Promise<Response> {
const controller = this.createRequest(id, config);
try {
const response = await requestWithRetry(
url,
{
...options,
signal: controller.signal,
},
config
);
// Clean up on success
this.controllers.delete(id);
this.requestConfigs.delete(id);
return response;
} catch (error) {
// Clean up on error
this.controllers.delete(id);
this.requestConfigs.delete(id);
throw error;
}
}
/**
* Execute a managed JSON request with automatic tracking.
*
* @param id Unique request identifier
* @param url Request URL
* @param options Fetch options
* @param config Request configuration
* @returns Parsed JSON response
*/
async executeManagedJson<T = unknown>(
id: string,
url: string,
options: RequestInit = {},
config: RequestConfig = {}
): Promise<T> {
const response = await this.executeManaged(id, url, options, config);
try {
return await response.json();
} catch (error) {
throw new RequestError(
`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`,
0,
'Parse Error',
await response.text().catch(() => '')
);
}
}
/**
* Cancel a specific request by ID.
*
* @param id Request identifier
* @returns true if request was cancelled, false if not found
*/
cancelRequest(id: string): boolean {
const controller = this.controllers.get(id);
if (controller) {
controller.abort();
this.controllers.delete(id);
this.requestConfigs.delete(id);
return true;
}
return false;
}
/**
* Check if a request is currently in progress.
*
* @param id Request identifier
* @returns true if request is active
*/
isRequestActive(id: string): boolean {
return this.controllers.has(id);
}
/**
* Get all active request IDs.
*
* @returns Array of active request IDs
*/
getActiveRequestIds(): string[] {
return Array.from(this.controllers.keys());
}
/**
* Cancel all active requests.
*/
cancelAll(): void {
this.controllers.forEach((controller, id) => {
controller.abort();
log.debug(`Cancelled request: ${id}`);
});
this.controllers.clear();
this.requestConfigs.clear();
}
/**
* Get the number of active requests.
*/
get activeCount(): number {
return this.controllers.size;
}
}
// === Default Request Manager Instance ===
/**
* Global request manager instance for application-wide request tracking.
* Use this for simple cases; create new instances for isolated contexts.
*/
export const globalRequestManager = new RequestManager();
// === Convenience Functions ===
/**
* Create a GET request with retry support.
*/
export async function get(
url: string,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(url, { method: 'GET', headers }, config);
}
/**
* Create a POST request with retry support.
*/
export async function post(
url: string,
body?: unknown,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
},
config
);
}
/**
* Create a PUT request with retry support.
*/
export async function put(
url: string,
body?: unknown,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(
url,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
},
config
);
}
/**
* Create a DELETE request with retry support.
*/
export async function del(
url: string,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(url, { method: 'DELETE', headers }, config);
}
/**
* Create a PATCH request with retry support.
*/
export async function patch(
url: string,
body?: unknown,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(
url,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
},
config
);
}
export default {
requestWithRetry,
requestJson,
RequestManager,
globalRequestManager,
RequestError,
RequestCancelledError,
get,
post,
put,
del,
patch,
DEFAULT_REQUEST_CONFIG,
};