UNPKG

@foundatiofx/fetchclient

Version:

A typed JSON fetch client with middleware support for Deno, Node and the browser.

348 lines (347 loc) 12 kB
/** * A rate limiter that tracks requests per time window. */ export class RateLimiter { options; buckets = new Map(); groupOptions = new Map(); constructor(options) { this.options = { getGroupFunc: () => "global", onRateLimitExceeded: () => { }, groups: {}, ...options, }; // Initialize group options if provided if (options.groups) { for (const [groupKey, groupOptions] of Object.entries(options.groups)) { this.groupOptions.set(groupKey, groupOptions); } } } /** * Checks if a request is allowed and updates the rate limit state. * @param url - The request URL * @returns True if the request is allowed, false if rate limit is exceeded */ isAllowed(url) { const key = this.options.getGroupFunc(url); const groupOptions = this.getGroupOptions(key); const now = Date.now(); // Use group-specific options if available, otherwise fall back to global options const maxRequests = groupOptions.maxRequests ?? 0; const windowSeconds = groupOptions.windowSeconds ?? 0; const onRateLimitExceeded = groupOptions.onRateLimitExceeded ?? this.options.onRateLimitExceeded; let bucket = this.buckets.get(key); if (!bucket) { bucket = { requests: [], resetTime: now + (windowSeconds * 1000), }; this.buckets.set(key, bucket); } // Clean up old requests outside the time window const windowStart = now - (windowSeconds * 1000); bucket.requests = bucket.requests.filter((time) => time > windowStart); // Update reset time if all requests have expired if (bucket.requests.length === 0) { bucket.resetTime = now + (windowSeconds * 1000); } // Check if we're within the rate limit if (bucket.requests.length >= maxRequests) { onRateLimitExceeded(bucket.resetTime); return false; } // Add the current request bucket.requests.push(now); return true; } /** * Gets the current request count for a specific key. * @param url - The request URL * @returns The current number of requests in the time window */ getRequestCount(url) { const key = this.options.getGroupFunc(url); const groupOptions = this.getGroupOptions(key); const bucket = this.buckets.get(key); if (!bucket) { return 0; } const now = Date.now(); const windowSeconds = groupOptions.windowSeconds ?? 0; const windowStart = now - (windowSeconds * 1000); return bucket.requests.filter((time) => time > windowStart).length; } /** * Gets the remaining requests allowed for a specific key. * @param url - The request URL * @returns The number of remaining requests allowed */ getRemainingRequests(url) { const key = this.options.getGroupFunc(url); const groupOptions = this.getGroupOptions(key); const maxRequests = groupOptions.maxRequests ?? 0; return Math.max(0, maxRequests - this.getRequestCount(url)); } /** * Gets the time when the rate limit will reset for a specific key. * @param url - The request URL * @returns The reset time in milliseconds since epoch, or null if no bucket exists */ getResetTime(url) { const key = this.options.getGroupFunc(url); const bucket = this.buckets.get(key); return bucket?.resetTime ?? null; } /** * Clears the rate limit state for a specific key. * @param url - The request URL */ clearBucket(url) { const key = this.options.getGroupFunc(url); this.buckets.delete(key); } /** * Gets the group key for a URL. * @param url - The request URL * @returns The group key */ getGroup(url) { return this.options.getGroupFunc(url); } /** * Gets the options for a specific group. Falls back to global options if not set. * @param group - The group key * @returns The options for the group */ getGroupOptions(group) { const options = this.groupOptions.get(group); if (!options) { return { maxRequests: this.options.maxRequests, windowSeconds: this.options.windowSeconds, }; } return options; } /** * Checks if a group has specific options set. * @param group - The group key * @returns True if the group has options, false otherwise */ hasGroupOptions(group) { return this.groupOptions.has(group); } /** * Sets options for a specific group. * @param group - The group key * @param options - The options to set */ setGroupOptions(group, options) { this.groupOptions.set(group, options); } /** * Sets rate limit options for a request. * @param url - The request URL * @param options - The options to set for this group */ setOptionsForRequest(url, options) { const group = this.getGroup(url); this.setGroupOptions(group, options); } /** * Updates rate limit options for a request based on standard rate limit headers. * @param url - The request URL * @param method - The HTTP method * @param headers - The response headers containing rate limit information */ updateFromHeadersForRequest(url, headers) { const group = this.getGroup(url); this.updateFromHeaders(group, headers); } /** * Updates rate limit options based on standard rate limit headers. * @param group - The group key * @param headers - The response headers containing rate limit information */ updateFromHeaders(group, headers) { // Get existing group-specific options (not global fallback) const currentOptions = this.hasGroupOptions(group) ? this.groupOptions.get(group) : {}; const newOptions = { ...currentOptions }; // Parse IETF standard rate limit headers first, then fall back to x-ratelimit headers let limit = null; let window = null; let reset = null; // Try IETF standard headers first const rateLimitPolicyHeader = headers.get("ratelimit-policy"); if (rateLimitPolicyHeader) { const parsed = parseRateLimitPolicyHeader(rateLimitPolicyHeader); if (parsed?.limit) { limit = parsed.limit.toString(); } if (parsed?.windowSeconds) { window = parsed.windowSeconds.toString(); } } const rateLimitHeader = headers.get("ratelimit"); if (rateLimitHeader) { const parsed = parseRateLimitHeader(rateLimitHeader); if (parsed?.resetSeconds) { reset = parsed.resetSeconds.toString(); } } // Fall back to x-ratelimit headers if IETF headers not found if (!limit) { limit = headers.get("x-ratelimit-limit") || headers.get("x-rate-limit-limit"); } if (!window) { window = headers.get("x-ratelimit-window") || headers.get("x-rate-limit-window"); } if (!reset) { reset = headers.get("x-ratelimit-reset") || headers.get("x-rate-limit-reset"); } let hasChanges = false; // Apply the parsed values if (limit) { const maxRequests = parseInt(limit, 10); if (!isNaN(maxRequests)) { newOptions.maxRequests = maxRequests; hasChanges = true; } } if (window) { const windowSeconds = parseInt(window, 10); if (!isNaN(windowSeconds)) { newOptions.windowSeconds = windowSeconds; hasChanges = true; } } else if (reset) { // If no window header, try to calculate from reset time const resetTime = parseInt(reset, 10); if (!isNaN(resetTime)) { const now = Math.floor(Date.now() / 1000); const windowSeconds = Math.max(1, resetTime - now); newOptions.windowSeconds = windowSeconds; hasChanges = true; } } // Update the group options if we found valid headers if (hasChanges) { this.setGroupOptions(group, newOptions); } } /** * Clears all rate limit state. */ clearAll() { this.buckets.clear(); } } /** * Creates a group generator function that groups requests by domain only (no protocol). * @param url - The request URL * @returns A string representing the domain without protocol */ export function groupByDomain(url) { try { const urlObj = new URL(url); return urlObj.hostname; } catch { return url; } } /** * Creates an IETF standard RateLimit header value. * @param info - The rate limit information * @returns The formatted RateLimit header value */ export function buildRateLimitHeader(info) { let headerValue = `"${info.policy}";r=${info.remaining}`; if (info.resetSeconds > 0) { headerValue += `;t=${info.resetSeconds}`; } return headerValue; } /** * Creates an IETF standard RateLimit-Policy header value. * @param info - The rate limit information * @returns The formatted RateLimit-Policy header value */ export function buildRateLimitPolicyHeader(info) { let headerValue = `"${info.policy}";q=${info.limit}`; if (info.windowSeconds && info.windowSeconds > 0) { headerValue += `;w=${info.windowSeconds}`; } return headerValue; } /** * Parses an IETF standard RateLimit header value. * @param headerValue - The RateLimit header value to parse * @returns The parsed rate limit information or null if invalid */ export function parseRateLimitHeader(headerValue) { if (!headerValue) return null; try { const result = {}; // Extract policy name (quoted string at the beginning) const policyMatch = headerValue.match(/^"([^"]+)"/); if (policyMatch) { result.policy = policyMatch[1]; } // Extract remaining (r parameter) const remainingMatch = headerValue.match(/r=(\d+)/); if (remainingMatch) { result.remaining = parseInt(remainingMatch[1], 10); } // Extract reset time (t parameter) const resetMatch = headerValue.match(/t=(\d+)/); if (resetMatch) { result.resetSeconds = parseInt(resetMatch[1], 10); } return result; } catch { return null; } } /** * Parses an IETF standard RateLimit-Policy header value. * @param headerValue - The RateLimit-Policy header value to parse * @returns The parsed rate limit policy information or null if invalid */ export function parseRateLimitPolicyHeader(headerValue) { if (!headerValue) return null; try { const result = {}; // Extract policy name (quoted string at the beginning) const policyMatch = headerValue.match(/^"([^"]+)"/); if (policyMatch) { result.policy = policyMatch[1]; } // Extract quota/limit (q parameter) const quotaMatch = headerValue.match(/q=(\d+)/); if (quotaMatch) { result.limit = parseInt(quotaMatch[1], 10); } // Extract window (w parameter) const windowMatch = headerValue.match(/w=(\d+)/); if (windowMatch) { result.windowSeconds = parseInt(windowMatch[1], 10); } return result; } catch { return null; } }