UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

291 lines (248 loc) 7.04 kB
/** * Base API client with rate limiting and retry logic * * @module research/clients/base */ import { ClientConfig, RateLimitConfig, RetryConfig, ResearchError, ResearchErrorCode, } from '../types.js'; /** * Token bucket rate limiter */ export class RateLimiter { private tokens: number; private lastRefill: number; constructor(private config: RateLimitConfig) { this.tokens = config.currentTokens; this.lastRefill = config.lastRefill; // Use config value, not Date.now() } /** * Refill tokens based on elapsed time */ private refill(): void { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; // Convert to seconds const tokensToAdd = elapsed * this.config.refillRate; this.tokens = Math.min(this.config.maxTokens, this.tokens + tokensToAdd); this.lastRefill = now; } /** * Acquire a token, waiting if necessary */ async acquire(): Promise<void> { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return; } // Calculate wait time const deficit = 1 - this.tokens; const waitMs = (deficit / this.config.refillRate) * 1000; await this.sleep(waitMs); // Refill again after waiting this.refill(); this.tokens -= 1; } private sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get current token count (for testing) */ getCurrentTokens(): number { this.refill(); return this.tokens; } } /** * Base API client with common functionality */ export abstract class BaseClient { protected rateLimiter: RateLimiter; protected retryConfig: RetryConfig; constructor(protected config: ClientConfig) { this.rateLimiter = new RateLimiter(config.rateLimit); this.retryConfig = config.retry; } /** * Execute an HTTP request with rate limiting and retry logic */ protected async request<T>( url: string, options: RequestInit = {} ): Promise<T> { // Apply rate limiting await this.rateLimiter.acquire(); // Execute with retry return this.executeWithRetry<T>(url, options); } /** * Execute request with exponential backoff retry */ private async executeWithRetry<T>( url: string, options: RequestInit, attempt = 0, firstError?: ResearchError ): Promise<T> { const controller = new AbortController(); let timeoutId: NodeJS.Timeout | undefined; try { // Create timeout promise const timeoutPromise = new Promise<never>((_, reject) => { timeoutId = setTimeout(() => { controller.abort(); reject( new ResearchError( ResearchErrorCode.RF_104, `Request timeout after ${this.config.timeout}ms` ) ); }, this.config.timeout); }); // Create fetch promise const fetchPromise = fetch(url, { ...options, signal: controller.signal, }); // Race between fetch and timeout const response = await Promise.race([fetchPromise, timeoutPromise]); // Clear timeout if fetch completed first if (timeoutId) { clearTimeout(timeoutId); } if (!response.ok) { const error = await this.handleHttpError(response); throw error; } return (await response.json()) as T; } catch (error) { // Clear timeout on error if (timeoutId) { clearTimeout(timeoutId); } // Handle abort (timeout) if (error instanceof Error && error.name === 'AbortError') { throw new ResearchError( ResearchErrorCode.RF_104, `Request timeout after ${this.config.timeout}ms`, error ); } // Track the first error for better error reporting const currentFirstError = firstError || (error instanceof ResearchError ? error : undefined); // Don't retry ResearchErrors (they're already handled) if (error instanceof ResearchError) { // Retry on transient errors if (this.isRetriable(error) && attempt < this.retryConfig.maxRetries) { const delay = this.calculateBackoff(attempt); await this.sleep(delay); return this.executeWithRetry<T>( url, options, attempt + 1, currentFirstError ); } throw error; } // Retry on network errors if (attempt < this.retryConfig.maxRetries) { const delay = this.calculateBackoff(attempt); await this.sleep(delay); return this.executeWithRetry<T>( url, options, attempt + 1, currentFirstError ); } // If we have a first error from an HTTP response, throw that instead of generic network error if (currentFirstError) { throw currentFirstError; } throw new ResearchError( ResearchErrorCode.RF_500, 'Network error', error as Error ); } } /** * Handle HTTP error responses */ private async handleHttpError(response: Response): Promise<ResearchError> { const status = response.status; if (status === 404) { return new ResearchError( ResearchErrorCode.RF_300, 'Resource not found' ); } if (status === 429) { return new ResearchError( ResearchErrorCode.RF_103, 'Rate limit exceeded' ); } if (status === 401 || status === 403) { return new ResearchError( ResearchErrorCode.RF_102, 'Invalid API key or unauthorized' ); } if (status >= 500) { return new ResearchError( ResearchErrorCode.RF_200, 'API server error' ); } return new ResearchError( ResearchErrorCode.RF_100, `HTTP error ${status}` ); } /** * Determine if an error is retriable */ private isRetriable(error: unknown): boolean { if (error instanceof ResearchError) { // Only retry on server errors (not rate limits or client errors) return error.code === ResearchErrorCode.RF_200; } // Retry on network errors return true; } /** * Calculate exponential backoff delay */ private calculateBackoff(attempt: number): number { const delay = this.retryConfig.initialDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt); return Math.min(delay, this.retryConfig.maxDelay); } private sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Build URL with query parameters */ protected buildUrl( path: string, params: Record<string, string | number | boolean | undefined> ): string { const url = new URL(path, this.config.baseUrl); Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { url.searchParams.append(key, String(value)); } }); return url.toString(); } }