feat(ui): Phase 8 UI/UX optimization and system documentation update
## Sidebar Enhancement - Change tabs to icon + small label layout for better space utilization - Add Teams tab with team collaboration entry point ## Settings Page Improvements - Connect theme toggle to gatewayStore.saveQuickConfig for persistence - Remove OpenFang backend download section, simplify UI - Add time range filter to UsageStats (7d/30d/all) - Add stat cards with icons (sessions, messages, input/output tokens) - Add token usage overview bar chart - Add 8 ZCLAW system skill definitions with categories ## Bug Fixes - Fix ChannelList duplicate content with deduplication logic - Integrate CreateTriggerModal in TriggersPanel - Add independent SecurityStatusPanel with 12 default enabled layers - Change workflow view to use SchedulerPanel as unified entry ## New Components - CreateTriggerModal: Event trigger creation modal - HandApprovalModal: Hand approval workflow dialog - HandParamsForm: Enhanced Hand parameter form - SecurityLayersPanel: 16-layer security status display ## Architecture - Add TOML config parsing support (toml-utils.ts, config-parser.ts) - Add request timeout and retry mechanism (request-helper.ts) - Add secure token storage (secure-storage.ts, secure_storage.rs) ## Tests - Add unit tests for config-parser, toml-utils, request-helper - Add team-client and teamStore tests ## Documentation - Update SYSTEM_ANALYSIS.md with Phase 8 completion - UI completion: 100% (30/30 components) - API coverage: 93% (63/68 endpoints) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
542
desktop/src/lib/request-helper.ts
Normal file
542
desktop/src/lib/request-helper.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* Request Helper Module
|
||||
*
|
||||
* Provides request timeout, automatic retry with exponential backoff,
|
||||
* and request cancellation support for API clients.
|
||||
*
|
||||
* @module lib/request-helper
|
||||
*/
|
||||
|
||||
// === 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 {
|
||||
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);
|
||||
console.warn(
|
||||
`[RequestHelper] 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);
|
||||
console.warn(
|
||||
`[RequestHelper] 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);
|
||||
console.warn(
|
||||
`[RequestHelper] 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();
|
||||
console.log(`[RequestManager] 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,
|
||||
};
|
||||
Reference in New Issue
Block a user