aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
197 lines • 6.86 kB
JavaScript
/**
* Base API client with rate limiting and retry logic
*
* @module research/clients/base
*/
import { ResearchError, ResearchErrorCode, } from '../types.js';
/**
* Token bucket rate limiter
*/
export class RateLimiter {
config;
tokens;
lastRefill;
constructor(config) {
this.config = config;
this.tokens = config.currentTokens;
this.lastRefill = config.lastRefill; // Use config value, not Date.now()
}
/**
* Refill tokens based on elapsed time
*/
refill() {
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() {
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;
}
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Get current token count (for testing)
*/
getCurrentTokens() {
this.refill();
return this.tokens;
}
}
/**
* Base API client with common functionality
*/
export class BaseClient {
config;
rateLimiter;
retryConfig;
constructor(config) {
this.config = config;
this.rateLimiter = new RateLimiter(config.rateLimit);
this.retryConfig = config.retry;
}
/**
* Execute an HTTP request with rate limiting and retry logic
*/
async request(url, options = {}) {
// Apply rate limiting
await this.rateLimiter.acquire();
// Execute with retry
return this.executeWithRetry(url, options);
}
/**
* Execute request with exponential backoff retry
*/
async executeWithRetry(url, options, attempt = 0, firstError) {
const controller = new AbortController();
let timeoutId;
try {
// Create timeout promise
const timeoutPromise = new Promise((_, 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());
}
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(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(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);
}
}
/**
* Handle HTTP error responses
*/
async handleHttpError(response) {
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
*/
isRetriable(error) {
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
*/
calculateBackoff(attempt) {
const delay = this.retryConfig.initialDelay *
Math.pow(this.retryConfig.backoffMultiplier, attempt);
return Math.min(delay, this.retryConfig.maxDelay);
}
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Build URL with query parameters
*/
buildUrl(path, params) {
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();
}
}
//# sourceMappingURL=base.js.map