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>
548 lines
14 KiB
TypeScript
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,
|
|
};
|