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
text/typescript
/**
* 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();
}
}