Skip to main content
Glama
retry-budget.ts7.78 kB
/** * @fileoverview Retry budget management to prevent resource exhaustion * This module implements retry budgets to limit the number of retries within a time window. */ import { createLogger } from '../logging/logger.js'; const logger = createLogger('RetryBudget'); /** * Retry budget configuration * @interface * @public */ export interface RetryBudgetConfig { /** Maximum number of retries allowed per time window */ maxRetries: number; /** Time window in milliseconds */ timeWindow: number; /** Name for logging purposes */ name?: string; } /** * Retry budget statistics * @interface * @public */ export interface RetryBudgetStats { /** Current number of retries consumed */ consumed: number; /** Maximum retries allowed */ maximum: number; /** Remaining retries available */ remaining: number; /** Time until budget resets (ms) */ resetInMs: number; /** Whether budget is exhausted */ isExhausted: boolean; /** Percentage of budget consumed */ consumedPercentage: number; } /** * Default retry budget configuration from environment * @private */ const DEFAULT_CONFIG: RetryBudgetConfig = { maxRetries: parseInt(process.env.RETRY_BUDGET_PER_MINUTE || '10', 10), timeWindow: 60000, // 1 minute }; /** * Retry budget implementation * @class * @public */ export class RetryBudget { private readonly config: RetryBudgetConfig; private readonly name: string; private retryTimestamps: number[] = []; /** * Creates a new retry budget * @param config Configuration for the retry budget */ constructor(config: Partial<RetryBudgetConfig> = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; this.name = config.name || 'default'; logger.debug('Retry budget created', { name: this.name, config: this.config }); } /** * Check if a retry can be consumed from the budget * @returns True if a retry is available * @public */ canRetry(): boolean { this.cleanup(); const available = this.retryTimestamps.length < this.config.maxRetries; if (!available) { logger.warn('Retry budget exhausted', { name: this.name, consumed: this.retryTimestamps.length, max: this.config.maxRetries, }); } return available; } /** * Consume a retry from the budget * @returns True if the retry was successfully consumed * @public */ consumeRetry(): boolean { if (!this.canRetry()) { return false; } const now = Date.now(); this.retryTimestamps.push(now); logger.debug('Retry consumed from budget', { name: this.name, consumed: this.retryTimestamps.length, max: this.config.maxRetries, }); return true; } /** * Get current budget statistics * @returns The current budget statistics * @public */ getStats(): RetryBudgetStats { this.cleanup(); const consumed = this.retryTimestamps.length; const remaining = Math.max(0, this.config.maxRetries - consumed); // Calculate time until next reset let resetInMs = 0; if (this.retryTimestamps.length > 0) { const oldestTimestamp = this.retryTimestamps[0]; if (oldestTimestamp !== undefined) { const timeSinceOldest = Date.now() - oldestTimestamp; resetInMs = Math.max(0, this.config.timeWindow - timeSinceOldest); } } const consumedPercentage = (consumed / this.config.maxRetries) * 100; return { consumed, maximum: this.config.maxRetries, remaining, resetInMs, isExhausted: remaining === 0, consumedPercentage: Math.round(consumedPercentage), }; } /** * Reset the budget (clear all consumed retries) * @public */ reset(): void { this.retryTimestamps = []; logger.info('Retry budget reset', { name: this.name }); } /** * Clean up expired retry timestamps * @private */ private cleanup(): void { const now = Date.now(); const cutoff = now - this.config.timeWindow; const before = this.retryTimestamps.length; this.retryTimestamps = this.retryTimestamps.filter((timestamp) => timestamp > cutoff); const after = this.retryTimestamps.length; if (before !== after) { logger.debug('Cleaned up expired retries', { name: this.name, removed: before - after, remaining: after, }); } } } /** * Retry budget manager for managing multiple budgets * @class * @public */ export class RetryBudgetManager { private static instance: RetryBudgetManager; private budgets: Map<string, RetryBudget> = new Map(); private globalBudget: RetryBudget; /** * Private constructor for singleton pattern * @private */ private constructor() { this.globalBudget = new RetryBudget({ name: 'global' }); } /** * Get the singleton instance * @returns The retry budget manager instance * @public */ static getInstance(): RetryBudgetManager { if (!RetryBudgetManager.instance) { RetryBudgetManager.instance = new RetryBudgetManager(); } return RetryBudgetManager.instance; } /** * Get or create a budget for an endpoint * @param endpoint The endpoint name * @param config Optional configuration overrides * @returns The retry budget instance * @public */ getBudget(endpoint: string, config?: Partial<RetryBudgetConfig>): RetryBudget { let budget = this.budgets.get(endpoint); if (!budget) { budget = new RetryBudget({ ...config, name: endpoint }); this.budgets.set(endpoint, budget); } return budget; } /** * Get the global retry budget * @returns The global retry budget instance * @public */ getGlobalBudget(): RetryBudget { return this.globalBudget; } /** * Check if both endpoint and global budgets allow a retry * @param endpoint The endpoint to check * @returns True if both budgets allow a retry * @public */ canRetry(endpoint: string): boolean { const endpointBudget = this.getBudget(endpoint); const globalAllows = this.globalBudget.canRetry(); const endpointAllows = endpointBudget.canRetry(); if (!globalAllows) { logger.warn('Global retry budget exhausted'); } if (!endpointAllows) { logger.warn('Endpoint retry budget exhausted', { endpoint }); } return globalAllows && endpointAllows; } /** * Consume a retry from both endpoint and global budgets * @param endpoint The endpoint to consume from * @returns True if the retry was successfully consumed from both budgets * @public */ consumeRetry(endpoint: string): boolean { const endpointBudget = this.getBudget(endpoint); // Check both budgets first if (!this.globalBudget.canRetry() || !endpointBudget.canRetry()) { return false; } // Consume from both budgets const globalConsumed = this.globalBudget.consumeRetry(); const endpointConsumed = endpointBudget.consumeRetry(); return globalConsumed && endpointConsumed; } /** * Get statistics for all budgets * @returns Map of budget name to statistics * @public */ getAllStats(): Map<string, RetryBudgetStats> { const stats = new Map<string, RetryBudgetStats>(); stats.set('global', this.globalBudget.getStats()); for (const [endpoint, budget] of this.budgets) { stats.set(endpoint, budget.getStats()); } return stats; } /** * Reset all budgets * @public */ resetAll(): void { this.globalBudget.reset(); for (const budget of this.budgets.values()) { budget.reset(); } logger.info('All retry budgets reset'); } /** * Clear all budgets (for testing) * @public */ clear(): void { this.budgets.clear(); this.globalBudget.reset(); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sapientpants/deepsource-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server