From 821d3d0ca38b77b4ef098e43410bcefd298d7655 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 16 Jul 2025 19:19:08 -0500 Subject: [PATCH 1/7] Add rate limiting support --- RATE_LIMITING.md | 324 +++++++++++++++++++++++++++++++++++++ mod.ts | 6 + src/FetchClientProvider.ts | 12 ++ src/RateLimit.test.ts | 253 +++++++++++++++++++++++++++++ src/RateLimitMiddleware.ts | 124 ++++++++++++++ src/RateLimiter.ts | 153 ++++++++++++++++++ 6 files changed, 872 insertions(+) create mode 100644 RATE_LIMITING.md create mode 100644 src/RateLimit.test.ts create mode 100644 src/RateLimitMiddleware.ts create mode 100644 src/RateLimiter.ts diff --git a/RATE_LIMITING.md b/RATE_LIMITING.md new file mode 100644 index 0000000..f383400 --- /dev/null +++ b/RATE_LIMITING.md @@ -0,0 +1,324 @@ +# Rate Limiting Middleware + +The FetchClient now supports optional rate limiting middleware that can be +tracked at the provider level. This allows you to control the rate of outgoing +HTTP requests to prevent overwhelming APIs or to comply with rate limits. + +## Features + +- **Provider-level tracking**: Rate limits are managed at the + FetchClientProvider level and apply to all FetchClient instances created from + that provider +- **Flexible configuration**: Support for different rate limits per URL, HTTP + method, or custom keys +- **Multiple response modes**: Can either throw errors or return 429 responses + when rate limits are exceeded +- **Automatic cleanup**: Old requests outside the time window are automatically + cleaned up +- **Thread-safe**: Uses in-memory buckets with proper time-based expiration + +## Basic Usage + +### Simple Rate Limiting + +```typescript +import { FetchClientProvider } from "@exceptionless/fetchclient"; + +const provider = new FetchClientProvider(); + +// Enable rate limiting: 10 requests per second +provider.useRateLimit({ + maxRequests: 10, + windowMs: 1000, +}); + +const client = provider.getFetchClient(); + +// This will be rate limited +try { + const response = await client.get("https://api.example.com/data"); + console.log(response.data); +} catch (error) { + if (error instanceof RateLimitError) { + console.log( + `Rate limit exceeded. Try again at: ${new Date(error.resetTime)}`, + ); + } +} +``` + +### Custom Key Generator + +```typescript +// Rate limit per user or API key +provider.useRateLimit({ + maxRequests: 100, + windowMs: 60000, // 1 minute + keyGenerator: (url, method) => { + // Extract user ID from URL or use API key + const userId = extractUserIdFromUrl(url); + return `user:${userId}:${method}`; + }, +}); +``` + +### Return 429 Instead of Throwing + +```typescript +provider.useRateLimit({ + maxRequests: 5, + windowMs: 1000, + throwOnRateLimit: false, // Return 429 response instead of throwing + errorMessage: "API rate limit exceeded. Please slow down.", +}); + +const client = provider.getFetchClient(); + +try { + const response = await client.get("https://api.example.com/data"); + // This won't be reached if rate limited +} catch (response) { + // FetchClient throws 4xx/5xx responses + if (response.status === 429) { + console.log("Rate limited:", response.problem.detail); + console.log("Retry after:", response.headers.get("Retry-After")); + } +} +``` + +## Configuration Options + +### RateLimitMiddlewareOptions + +```typescript +interface RateLimitMiddlewareOptions { + /** + * Maximum number of requests allowed per time window. + */ + maxRequests: number; + + /** + * Time window in milliseconds. + */ + windowMs: number; + + /** + * Optional key generator function to create unique rate limit buckets. + * If not provided, a global rate limit is applied. + */ + keyGenerator?: (url: string, method: string) => string; + + /** + * Whether to throw an error when rate limit is exceeded. + * If false, the middleware will set a 429 status response. + * @default true + */ + throwOnRateLimit?: boolean; + + /** + * Custom error message when rate limit is exceeded. + */ + errorMessage?: string; + + /** + * Callback function called when rate limit is exceeded. + */ + onRateLimitExceeded?: (resetTime: number) => void; +} +``` + +## Provider Methods + +### `useRateLimit(options)` + +Enables rate limiting with the specified configuration. If rate limiting is +already enabled, it replaces the existing configuration. + +```typescript +provider.useRateLimit({ + maxRequests: 50, + windowMs: 60000, // 1 minute +}); +``` + +### `removeRateLimit()` + +Disables rate limiting for all FetchClient instances created by this provider. + +```typescript +provider.removeRateLimit(); +``` + +### `isRateLimitEnabled` + +Returns whether rate limiting is currently enabled. + +```typescript +if (provider.isRateLimitEnabled) { + console.log("Rate limiting is active"); +} +``` + +## Error Handling + +### RateLimitError + +When `throwOnRateLimit` is `true` (default), a `RateLimitError` is thrown when +the rate limit is exceeded: + +```typescript +import { RateLimitError } from "@exceptionless/fetchclient"; + +try { + await client.get("https://api.example.com/data"); +} catch (error) { + if (error instanceof RateLimitError) { + console.log(`Rate limit exceeded`); + console.log(`Reset time: ${new Date(error.resetTime)}`); + console.log(`Remaining requests: ${error.remainingRequests}`); + + // Wait until reset time + const waitTime = error.resetTime - Date.now(); + if (waitTime > 0) { + await new Promise((resolve) => setTimeout(resolve, waitTime)); + // Retry the request + } + } +} +``` + +### 429 Response + +When `throwOnRateLimit` is `false`, a 429 response is returned with helpful +headers: + +```typescript +try { + await client.get("https://api.example.com/data"); +} catch (response) { + if (response.status === 429) { + const limit = response.headers.get("X-RateLimit-Limit"); + const remaining = response.headers.get("X-RateLimit-Remaining"); + const reset = response.headers.get("X-RateLimit-Reset"); + const retryAfter = response.headers.get("Retry-After"); + + console.log(`Rate limit: ${remaining}/${limit} requests remaining`); + console.log(`Reset at: ${new Date(parseInt(reset) * 1000)}`); + console.log(`Retry after: ${retryAfter} seconds`); + } +} +``` + +## Advanced Examples + +### Different Limits for Different APIs + +```typescript +// Create separate providers for different APIs +const githubProvider = new FetchClientProvider(); +githubProvider.setBaseUrl("https://api.github.com"); +githubProvider.useRateLimit({ + maxRequests: 5000, + windowMs: 60 * 60 * 1000, // 1 hour (GitHub's limit) +}); + +const twitterProvider = new FetchClientProvider(); +twitterProvider.setBaseUrl("https://api.twitter.com"); +twitterProvider.useRateLimit({ + maxRequests: 300, + windowMs: 15 * 60 * 1000, // 15 minutes (Twitter's limit) +}); +``` + +### Per-User Rate Limiting + +```typescript +provider.useRateLimit({ + maxRequests: 1000, + windowMs: 24 * 60 * 60 * 1000, // 24 hours + keyGenerator: (url, method) => { + // Extract user ID from authentication header or URL + const userId = getCurrentUserId(); + return `user:${userId}`; + }, + onRateLimitExceeded: (resetTime) => { + console.log(`User rate limit exceeded. Resets at ${new Date(resetTime)}`); + // Log to analytics, send notification, etc. + }, +}); +``` + +### Graceful Degradation + +```typescript +provider.useRateLimit({ + maxRequests: 10, + windowMs: 1000, + throwOnRateLimit: false, +}); + +const client = provider.getFetchClient(); + +async function makeRequestWithFallback(url: string) { + try { + const response = await client.get(url); + return response.data; + } catch (error) { + if (error.status === 429) { + // Use cached data or default response + return getCachedData(url) || + { message: "Service temporarily unavailable" }; + } + throw error; + } +} +``` + +## Rate Limiter Class + +You can also use the `RateLimiter` class directly for custom implementations: + +```typescript +import { RateLimiter } from "@exceptionless/fetchclient"; + +const rateLimiter = new RateLimiter({ + maxRequests: 5, + windowMs: 1000, +}); + +if (rateLimiter.isAllowed("https://api.example.com", "GET")) { + // Make request + console.log( + `Remaining: ${ + rateLimiter.getRemainingRequests("https://api.example.com", "GET") + }`, + ); +} else { + const resetTime = rateLimiter.getResetTime("https://api.example.com", "GET"); + console.log(`Rate limited. Reset at: ${new Date(resetTime)}`); +} +``` + +## Best Practices + +1. **Set appropriate limits**: Choose rate limits that balance performance with + API requirements +2. **Use key generators**: Implement per-user or per-API-key rate limiting for + multi-tenant applications +3. **Handle errors gracefully**: Always provide fallback mechanisms when rate + limits are exceeded +4. **Monitor usage**: Use the `onRateLimitExceeded` callback to log and monitor + rate limit violations +5. **Test thoroughly**: Test your rate limiting configuration under load to + ensure it works as expected +6. **Consider caching**: Implement caching to reduce the number of requests + needed +7. **Respect external limits**: Configure your rate limits to be slightly below + external API limits + +## Thread Safety + +The rate limiter is designed to be thread-safe within a single JavaScript +context. However, it maintains state in memory, so rate limits are not shared +across different processes or browser tabs. For distributed rate limiting, +consider using external stores like Redis with a custom implementation. diff --git a/mod.ts b/mod.ts index 115aa01..ac8dc53 100644 --- a/mod.ts +++ b/mod.ts @@ -11,3 +11,9 @@ export { FetchClientProvider, } from "./src/FetchClientProvider.ts"; export * from "./src/DefaultHelpers.ts"; +export { RateLimiter, type RateLimiterOptions } from "./src/RateLimiter.ts"; +export { + createRateLimitMiddleware, + RateLimitError, + type RateLimitMiddlewareOptions, +} from "./src/RateLimitMiddleware.ts"; diff --git a/src/FetchClientProvider.ts b/src/FetchClientProvider.ts index 5c35523..4de4fd9 100644 --- a/src/FetchClientProvider.ts +++ b/src/FetchClientProvider.ts @@ -5,6 +5,10 @@ import type { ProblemDetails } from "./ProblemDetails.ts"; import { FetchClientCache } from "./FetchClientCache.ts"; import type { FetchClientOptions } from "./FetchClientOptions.ts"; import { type IObjectEvent, ObjectEvent } from "./ObjectEvent.ts"; +import { + createRateLimitMiddleware, + type RateLimitMiddlewareOptions, +} from "./RateLimitMiddleware.ts"; type Fetch = typeof globalThis.fetch; @@ -187,6 +191,14 @@ export class FetchClientProvider { ], }; } + + /** + * Enables rate limiting for all FetchClient instances created by this provider. + * @param options - The rate limiting configuration options. + */ + public useRateLimit(options: RateLimitMiddlewareOptions) { + this.useMiddleware(createRateLimitMiddleware(options)); + } } const provider = new FetchClientProvider(); diff --git a/src/RateLimit.test.ts b/src/RateLimit.test.ts new file mode 100644 index 0000000..48e4ed3 --- /dev/null +++ b/src/RateLimit.test.ts @@ -0,0 +1,253 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { FetchClientProvider } from "./FetchClientProvider.ts"; +import { + RateLimitError, + type RateLimitMiddlewareOptions, +} from "./RateLimitMiddleware.ts"; +import type { FetchClientResponse } from "./FetchClientResponse.ts"; +import { RateLimiter } from "./RateLimiter.ts"; + +// Mock fetch function for testing +const createMockFetch = (response: { + status?: number; + statusText?: string; + body?: string; + headers?: Record; +} = {}) => { + return ( + _input: RequestInfo | URL, + _init?: RequestInit, + ): Promise => { + const headers = new Headers(response.headers || {}); + headers.set("Content-Type", "application/json"); + + return Promise.resolve( + new Response(response.body || JSON.stringify({ success: true }), { + status: response.status || 200, + statusText: response.statusText || "OK", + headers, + }), + ); + }; +}; + +Deno.test("RateLimiter - basic functionality", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 2, + windowMs: 1000, + }); + + // First request should be allowed + assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); + assertEquals(rateLimiter.getRequestCount("http://example.com", "GET"), 1); + assertEquals( + rateLimiter.getRemainingRequests("http://example.com", "GET"), + 1, + ); + + // Second request should be allowed + assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); + assertEquals(rateLimiter.getRequestCount("http://example.com", "GET"), 2); + assertEquals( + rateLimiter.getRemainingRequests("http://example.com", "GET"), + 0, + ); + + // Third request should be denied + assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), false); + assertEquals(rateLimiter.getRequestCount("http://example.com", "GET"), 2); + assertEquals( + rateLimiter.getRemainingRequests("http://example.com", "GET"), + 0, + ); +}); + +Deno.test("RateLimiter - key generator", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 1, + windowMs: 1000, + keyGenerator: (url, method) => `${method}:${url}`, + }); + + // Different methods should have separate buckets + assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); + assertEquals(rateLimiter.isAllowed("http://example.com", "POST"), true); + assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), false); + assertEquals(rateLimiter.isAllowed("http://example.com", "POST"), false); +}); + +Deno.test("RateLimiter - time window expiry", async () => { + const rateLimiter = new RateLimiter({ + maxRequests: 1, + windowMs: 100, // 100ms window + }); + + // First request should be allowed + assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); + + // Second request should be denied + assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), false); + + // Wait for window to expire + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Request should be allowed again + assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); +}); + +Deno.test("RateLimitMiddleware - throws error when rate limit exceeded", async () => { + const mockFetch = createMockFetch(); + const provider = new FetchClientProvider(mockFetch); + + const options: RateLimitMiddlewareOptions = { + maxRequests: 1, + windowMs: 1000, + throwOnRateLimit: true, + }; + + provider.useRateLimit(options); + + const client = provider.getFetchClient(); + + // First request should succeed + const response1 = await client.get("http://example.com"); + assertEquals(response1.status, 200); + + // Second request should throw RateLimitError + await assertRejects( + () => client.get("http://example.com"), + RateLimitError, + "Rate limit exceeded", + ); +}); + +Deno.test("RateLimitMiddleware - returns 429 response when configured", async () => { + const mockFetch = createMockFetch(); + const provider = new FetchClientProvider(mockFetch); + + const options: RateLimitMiddlewareOptions = { + maxRequests: 1, + windowMs: 1000, + throwOnRateLimit: false, + errorMessage: "Custom rate limit message", + }; + + provider.useRateLimit(options); + + const client = provider.getFetchClient(); + + // First request should succeed + const response1 = await client.get("http://example.com"); + assertEquals(response1.status, 200); + + // Second request should throw 429 response + try { + await client.get("http://example.com"); + throw new Error("Expected rate limit response to be thrown"); + } catch (error) { + // The response object is thrown by FetchClient for 4xx/5xx status codes + const response = error as FetchClientResponse; + assertEquals(response.status, 429); + assertEquals(response.problem?.title, "Unexpected status code: 429"); + if (response.problem?.detail) { + assertEquals( + response.problem.detail.includes("Custom rate limit message"), + true, + ); + } + } +}); + +Deno.test("RateLimitMiddleware - provides rate limit info in error response", async () => { + const mockFetch = createMockFetch(); + const provider = new FetchClientProvider(mockFetch); + + const options: RateLimitMiddlewareOptions = { + maxRequests: 1, + windowMs: 1000, + throwOnRateLimit: false, + }; + + provider.useRateLimit(options); + + const client = provider.getFetchClient(); + + // First request should succeed + const response1 = await client.get("http://example.com"); + assertEquals(response1.status, 200); + + // Second request should throw 429 with rate limit headers + try { + await client.get("http://example.com"); + throw new Error("Expected rate limit response to be thrown"); + } catch (error) { + const response = error as FetchClientResponse; + assertEquals(response.status, 429); + assertEquals(response.headers.get("X-RateLimit-Limit"), "1"); + assertEquals(response.headers.get("X-RateLimit-Remaining"), "0"); + assertEquals(response.headers.get("X-RateLimit-Reset") !== null, true); + assertEquals(response.headers.get("Retry-After") !== null, true); + } +}); + +Deno.test("createRateLimitMiddleware - custom key generator", async () => { + const mockFetch = createMockFetch(); + const provider = new FetchClientProvider(mockFetch); + + let callCount = 0; + const options: RateLimitMiddlewareOptions = { + maxRequests: 1, + windowMs: 1000, + keyGenerator: (url, method) => { + callCount++; + return `custom-${method}-${url}`; + }, + throwOnRateLimit: true, + }; + + provider.useRateLimit(options); + + const client = provider.getFetchClient(); + + // First request should succeed and call key generator + await client.get("http://example.com"); + assertEquals(callCount, 1); + + // Second request should call key generator and throw + await assertRejects( + () => client.get("http://example.com"), + RateLimitError, + ); + // The key generator might be called multiple times due to the rate limiting logic + assertEquals(callCount >= 2, true); +}); + +Deno.test("RateLimitError - contains correct information", async () => { + const mockFetch = createMockFetch(); + const provider = new FetchClientProvider(mockFetch); + + provider.useRateLimit({ + maxRequests: 1, + windowMs: 1000, + throwOnRateLimit: true, + }); + + const client = provider.getFetchClient(); + + // First request should succeed + await client.get("http://example.com"); + + // Second request should throw with proper error info + try { + await client.get("http://example.com"); + } catch (error) { + if (error instanceof RateLimitError) { + assertEquals(error.name, "RateLimitError"); + assertEquals(error.remainingRequests, 0); + assertEquals(typeof error.resetTime, "number"); + assertEquals(error.resetTime > Date.now(), true); + } else { + throw new Error("Expected RateLimitError"); + } + } +}); diff --git a/src/RateLimitMiddleware.ts b/src/RateLimitMiddleware.ts new file mode 100644 index 0000000..b80b1ee --- /dev/null +++ b/src/RateLimitMiddleware.ts @@ -0,0 +1,124 @@ +import type { FetchClientContext } from "./FetchClientContext.ts"; +import type { FetchClientMiddleware } from "./FetchClientMiddleware.ts"; +import type { FetchClientResponse } from "./FetchClientResponse.ts"; +import { ProblemDetails } from "./ProblemDetails.ts"; +import { RateLimiter, type RateLimiterOptions } from "./RateLimiter.ts"; + +/** + * Rate limiting error thrown when requests exceed the rate limit. + */ +export class RateLimitError extends Error { + public readonly resetTime: number; + public readonly remainingRequests: number; + + constructor(resetTime: number, remainingRequests: number, message?: string) { + super( + message || + `Rate limit exceeded. Try again after ${ + new Date(resetTime).toISOString() + }`, + ); + this.name = "RateLimitError"; + this.resetTime = resetTime; + this.remainingRequests = remainingRequests; + } +} + +/** + * Configuration options for the rate limiting middleware. + */ +export interface RateLimitMiddlewareOptions extends RateLimiterOptions { + /** + * Whether to throw an error when rate limit is exceeded. + * If false, the middleware will set a 429 status response. + * @default true + */ + throwOnRateLimit?: boolean; + + /** + * Custom error message when rate limit is exceeded. + */ + errorMessage?: string; +} + +/** + * Creates a rate limiting middleware for FetchClient. + * @param options - Rate limiting configuration options + * @returns A FetchClient middleware function + */ +export function createRateLimitMiddleware( + options: RateLimitMiddlewareOptions, +): FetchClientMiddleware { + const rateLimiter = new RateLimiter(options); + const throwOnRateLimit = options.throwOnRateLimit ?? true; + + return async (context: FetchClientContext, next: () => Promise) => { + const url = context.request.url; + const method = context.request.method || "GET"; + + // Check if request is allowed + if (!rateLimiter.isAllowed(url, method)) { + const resetTime = rateLimiter.getResetTime(url, method) ?? Date.now(); + const remainingRequests = rateLimiter.getRemainingRequests(url, method); + + if (throwOnRateLimit) { + throw new RateLimitError( + resetTime, + remainingRequests, + options.errorMessage, + ); + } else { + // Create a 429 Too Many Requests response + const headers = new Headers({ + "Content-Type": "application/problem+json", + "X-RateLimit-Limit": options.maxRequests.toString(), + "X-RateLimit-Remaining": remainingRequests.toString(), + "X-RateLimit-Reset": Math.ceil(resetTime / 1000).toString(), + "Retry-After": Math.ceil((resetTime - Date.now()) / 1000).toString(), + }); + + const problem = new ProblemDetails(); + problem.status = 429; + problem.title = "Too Many Requests"; + problem.detail = options.errorMessage || + `Rate limit exceeded. Try again after ${ + new Date(resetTime).toISOString() + }`; + problem.type = "about:blank"; + + context.response = { + url: context.request.url, + status: 429, + statusText: "Too Many Requests", + body: null, + bodyUsed: true, + ok: false, + headers: headers, + redirected: false, + type: "basic", + problem: problem, + data: null, + meta: { links: {} }, + json: () => Promise.resolve(problem), + text: () => Promise.resolve(JSON.stringify(problem)), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + // @ts-ignore: New in Deno 1.44 + bytes: () => Promise.resolve(new Uint8Array()), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + clone: () => { + throw new Error("Not implemented"); + }, + } as FetchClientResponse; + return; + } + } + + // Continue with the request + await next(); + + // Note: We cannot modify response headers after the response is created + // as Response headers are read-only. Rate limit information is only + // provided in error responses (429) where we control the entire response. + }; +} diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts new file mode 100644 index 0000000..85abefd --- /dev/null +++ b/src/RateLimiter.ts @@ -0,0 +1,153 @@ +/** + * Configuration options for the rate limiter. + */ +export interface RateLimiterOptions { + /** + * Maximum number of requests allowed per time window. + */ + maxRequests: number; + + /** + * Time window in milliseconds. + */ + windowMs: number; + + /** + * Optional key generator function to create unique rate limit buckets. + * If not provided, a global rate limit is applied. + * @param url - The request URL + * @param method - The HTTP method + * @returns A string key to identify the rate limit bucket + */ + keyGenerator?: (url: string, method: string) => string; + + /** + * Callback function called when rate limit is exceeded. + * @param resetTime - Time when the rate limit will reset (in milliseconds since epoch) + */ + onRateLimitExceeded?: (resetTime: number) => void; +} + +/** + * Represents a rate limit bucket with request tracking. + */ +interface RateLimitBucket { + requests: number[]; + resetTime: number; +} + +/** + * A rate limiter that tracks requests per time window. + */ +export class RateLimiter { + private readonly options: Required; + private readonly buckets = new Map(); + + constructor(options: RateLimiterOptions) { + this.options = { + keyGenerator: () => "global", + onRateLimitExceeded: () => {}, + ...options, + }; + } + + /** + * Checks if a request is allowed and updates the rate limit state. + * @param url - The request URL + * @param method - The HTTP method + * @returns True if the request is allowed, false if rate limit is exceeded + */ + public isAllowed(url: string, method: string): boolean { + const key = this.options.keyGenerator(url, method); + const now = Date.now(); + + let bucket = this.buckets.get(key); + if (!bucket) { + bucket = { + requests: [], + resetTime: now + this.options.windowMs, + }; + this.buckets.set(key, bucket); + } + + // Clean up old requests outside the time window + const windowStart = now - this.options.windowMs; + bucket.requests = bucket.requests.filter((time) => time > windowStart); + + // Update reset time if all requests have expired + if (bucket.requests.length === 0) { + bucket.resetTime = now + this.options.windowMs; + } + + // Check if we're within the rate limit + if (bucket.requests.length >= this.options.maxRequests) { + this.options.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 + * @param method - The HTTP method + * @returns The current number of requests in the time window + */ + public getRequestCount(url: string, method: string): number { + const key = this.options.keyGenerator(url, method); + const bucket = this.buckets.get(key); + + if (!bucket) { + return 0; + } + + const now = Date.now(); + const windowStart = now - this.options.windowMs; + return bucket.requests.filter((time) => time > windowStart).length; + } + + /** + * Gets the remaining requests allowed for a specific key. + * @param url - The request URL + * @param method - The HTTP method + * @returns The number of remaining requests allowed + */ + public getRemainingRequests(url: string, method: string): number { + return Math.max( + 0, + this.options.maxRequests - this.getRequestCount(url, method), + ); + } + + /** + * Gets the time when the rate limit will reset for a specific key. + * @param url - The request URL + * @param method - The HTTP method + * @returns The reset time in milliseconds since epoch, or null if no bucket exists + */ + public getResetTime(url: string, method: string): number | null { + const key = this.options.keyGenerator(url, method); + const bucket = this.buckets.get(key); + return bucket?.resetTime ?? null; + } + + /** + * Clears the rate limit state for a specific key. + * @param url - The request URL + * @param method - The HTTP method + */ + public clearBucket(url: string, method: string): void { + const key = this.options.keyGenerator(url, method); + this.buckets.delete(key); + } + + /** + * Clears all rate limit state. + */ + public clearAll(): void { + this.buckets.clear(); + } +} From d491edc3061aaf69fd8c84e50405e59f57ea0a76 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 16 Jul 2025 22:44:24 -0500 Subject: [PATCH 2/7] Various changes --- RATE_LIMITING.md | 324 ---------------------------------- mod.ts | 6 - src/FetchClientProvider.ts | 39 ++++- src/RateLimit.test.ts | 284 +++++++++++++++++++++++++++--- src/RateLimitMiddleware.ts | 138 +++++++++++---- src/RateLimiter.ts | 351 ++++++++++++++++++++++++++++++++++--- 6 files changed, 720 insertions(+), 422 deletions(-) delete mode 100644 RATE_LIMITING.md diff --git a/RATE_LIMITING.md b/RATE_LIMITING.md deleted file mode 100644 index f383400..0000000 --- a/RATE_LIMITING.md +++ /dev/null @@ -1,324 +0,0 @@ -# Rate Limiting Middleware - -The FetchClient now supports optional rate limiting middleware that can be -tracked at the provider level. This allows you to control the rate of outgoing -HTTP requests to prevent overwhelming APIs or to comply with rate limits. - -## Features - -- **Provider-level tracking**: Rate limits are managed at the - FetchClientProvider level and apply to all FetchClient instances created from - that provider -- **Flexible configuration**: Support for different rate limits per URL, HTTP - method, or custom keys -- **Multiple response modes**: Can either throw errors or return 429 responses - when rate limits are exceeded -- **Automatic cleanup**: Old requests outside the time window are automatically - cleaned up -- **Thread-safe**: Uses in-memory buckets with proper time-based expiration - -## Basic Usage - -### Simple Rate Limiting - -```typescript -import { FetchClientProvider } from "@exceptionless/fetchclient"; - -const provider = new FetchClientProvider(); - -// Enable rate limiting: 10 requests per second -provider.useRateLimit({ - maxRequests: 10, - windowMs: 1000, -}); - -const client = provider.getFetchClient(); - -// This will be rate limited -try { - const response = await client.get("https://api.example.com/data"); - console.log(response.data); -} catch (error) { - if (error instanceof RateLimitError) { - console.log( - `Rate limit exceeded. Try again at: ${new Date(error.resetTime)}`, - ); - } -} -``` - -### Custom Key Generator - -```typescript -// Rate limit per user or API key -provider.useRateLimit({ - maxRequests: 100, - windowMs: 60000, // 1 minute - keyGenerator: (url, method) => { - // Extract user ID from URL or use API key - const userId = extractUserIdFromUrl(url); - return `user:${userId}:${method}`; - }, -}); -``` - -### Return 429 Instead of Throwing - -```typescript -provider.useRateLimit({ - maxRequests: 5, - windowMs: 1000, - throwOnRateLimit: false, // Return 429 response instead of throwing - errorMessage: "API rate limit exceeded. Please slow down.", -}); - -const client = provider.getFetchClient(); - -try { - const response = await client.get("https://api.example.com/data"); - // This won't be reached if rate limited -} catch (response) { - // FetchClient throws 4xx/5xx responses - if (response.status === 429) { - console.log("Rate limited:", response.problem.detail); - console.log("Retry after:", response.headers.get("Retry-After")); - } -} -``` - -## Configuration Options - -### RateLimitMiddlewareOptions - -```typescript -interface RateLimitMiddlewareOptions { - /** - * Maximum number of requests allowed per time window. - */ - maxRequests: number; - - /** - * Time window in milliseconds. - */ - windowMs: number; - - /** - * Optional key generator function to create unique rate limit buckets. - * If not provided, a global rate limit is applied. - */ - keyGenerator?: (url: string, method: string) => string; - - /** - * Whether to throw an error when rate limit is exceeded. - * If false, the middleware will set a 429 status response. - * @default true - */ - throwOnRateLimit?: boolean; - - /** - * Custom error message when rate limit is exceeded. - */ - errorMessage?: string; - - /** - * Callback function called when rate limit is exceeded. - */ - onRateLimitExceeded?: (resetTime: number) => void; -} -``` - -## Provider Methods - -### `useRateLimit(options)` - -Enables rate limiting with the specified configuration. If rate limiting is -already enabled, it replaces the existing configuration. - -```typescript -provider.useRateLimit({ - maxRequests: 50, - windowMs: 60000, // 1 minute -}); -``` - -### `removeRateLimit()` - -Disables rate limiting for all FetchClient instances created by this provider. - -```typescript -provider.removeRateLimit(); -``` - -### `isRateLimitEnabled` - -Returns whether rate limiting is currently enabled. - -```typescript -if (provider.isRateLimitEnabled) { - console.log("Rate limiting is active"); -} -``` - -## Error Handling - -### RateLimitError - -When `throwOnRateLimit` is `true` (default), a `RateLimitError` is thrown when -the rate limit is exceeded: - -```typescript -import { RateLimitError } from "@exceptionless/fetchclient"; - -try { - await client.get("https://api.example.com/data"); -} catch (error) { - if (error instanceof RateLimitError) { - console.log(`Rate limit exceeded`); - console.log(`Reset time: ${new Date(error.resetTime)}`); - console.log(`Remaining requests: ${error.remainingRequests}`); - - // Wait until reset time - const waitTime = error.resetTime - Date.now(); - if (waitTime > 0) { - await new Promise((resolve) => setTimeout(resolve, waitTime)); - // Retry the request - } - } -} -``` - -### 429 Response - -When `throwOnRateLimit` is `false`, a 429 response is returned with helpful -headers: - -```typescript -try { - await client.get("https://api.example.com/data"); -} catch (response) { - if (response.status === 429) { - const limit = response.headers.get("X-RateLimit-Limit"); - const remaining = response.headers.get("X-RateLimit-Remaining"); - const reset = response.headers.get("X-RateLimit-Reset"); - const retryAfter = response.headers.get("Retry-After"); - - console.log(`Rate limit: ${remaining}/${limit} requests remaining`); - console.log(`Reset at: ${new Date(parseInt(reset) * 1000)}`); - console.log(`Retry after: ${retryAfter} seconds`); - } -} -``` - -## Advanced Examples - -### Different Limits for Different APIs - -```typescript -// Create separate providers for different APIs -const githubProvider = new FetchClientProvider(); -githubProvider.setBaseUrl("https://api.github.com"); -githubProvider.useRateLimit({ - maxRequests: 5000, - windowMs: 60 * 60 * 1000, // 1 hour (GitHub's limit) -}); - -const twitterProvider = new FetchClientProvider(); -twitterProvider.setBaseUrl("https://api.twitter.com"); -twitterProvider.useRateLimit({ - maxRequests: 300, - windowMs: 15 * 60 * 1000, // 15 minutes (Twitter's limit) -}); -``` - -### Per-User Rate Limiting - -```typescript -provider.useRateLimit({ - maxRequests: 1000, - windowMs: 24 * 60 * 60 * 1000, // 24 hours - keyGenerator: (url, method) => { - // Extract user ID from authentication header or URL - const userId = getCurrentUserId(); - return `user:${userId}`; - }, - onRateLimitExceeded: (resetTime) => { - console.log(`User rate limit exceeded. Resets at ${new Date(resetTime)}`); - // Log to analytics, send notification, etc. - }, -}); -``` - -### Graceful Degradation - -```typescript -provider.useRateLimit({ - maxRequests: 10, - windowMs: 1000, - throwOnRateLimit: false, -}); - -const client = provider.getFetchClient(); - -async function makeRequestWithFallback(url: string) { - try { - const response = await client.get(url); - return response.data; - } catch (error) { - if (error.status === 429) { - // Use cached data or default response - return getCachedData(url) || - { message: "Service temporarily unavailable" }; - } - throw error; - } -} -``` - -## Rate Limiter Class - -You can also use the `RateLimiter` class directly for custom implementations: - -```typescript -import { RateLimiter } from "@exceptionless/fetchclient"; - -const rateLimiter = new RateLimiter({ - maxRequests: 5, - windowMs: 1000, -}); - -if (rateLimiter.isAllowed("https://api.example.com", "GET")) { - // Make request - console.log( - `Remaining: ${ - rateLimiter.getRemainingRequests("https://api.example.com", "GET") - }`, - ); -} else { - const resetTime = rateLimiter.getResetTime("https://api.example.com", "GET"); - console.log(`Rate limited. Reset at: ${new Date(resetTime)}`); -} -``` - -## Best Practices - -1. **Set appropriate limits**: Choose rate limits that balance performance with - API requirements -2. **Use key generators**: Implement per-user or per-API-key rate limiting for - multi-tenant applications -3. **Handle errors gracefully**: Always provide fallback mechanisms when rate - limits are exceeded -4. **Monitor usage**: Use the `onRateLimitExceeded` callback to log and monitor - rate limit violations -5. **Test thoroughly**: Test your rate limiting configuration under load to - ensure it works as expected -6. **Consider caching**: Implement caching to reduce the number of requests - needed -7. **Respect external limits**: Configure your rate limits to be slightly below - external API limits - -## Thread Safety - -The rate limiter is designed to be thread-safe within a single JavaScript -context. However, it maintains state in memory, so rate limits are not shared -across different processes or browser tabs. For distributed rate limiting, -consider using external stores like Redis with a custom implementation. diff --git a/mod.ts b/mod.ts index ac8dc53..115aa01 100644 --- a/mod.ts +++ b/mod.ts @@ -11,9 +11,3 @@ export { FetchClientProvider, } from "./src/FetchClientProvider.ts"; export * from "./src/DefaultHelpers.ts"; -export { RateLimiter, type RateLimiterOptions } from "./src/RateLimiter.ts"; -export { - createRateLimitMiddleware, - RateLimitError, - type RateLimitMiddlewareOptions, -} from "./src/RateLimitMiddleware.ts"; diff --git a/src/FetchClientProvider.ts b/src/FetchClientProvider.ts index 4de4fd9..130f9be 100644 --- a/src/FetchClientProvider.ts +++ b/src/FetchClientProvider.ts @@ -6,9 +6,10 @@ import { FetchClientCache } from "./FetchClientCache.ts"; import type { FetchClientOptions } from "./FetchClientOptions.ts"; import { type IObjectEvent, ObjectEvent } from "./ObjectEvent.ts"; import { - createRateLimitMiddleware, + RateLimitMiddleware, type RateLimitMiddlewareOptions, } from "./RateLimitMiddleware.ts"; +import { groupByDomain, type RateLimiter } from "./RateLimiter.ts"; type Fetch = typeof globalThis.fetch; @@ -19,6 +20,7 @@ export class FetchClientProvider { #options: FetchClientOptions = {}; #fetch?: Fetch; #cache: FetchClientCache; + #rateLimitMiddleware?: RateLimitMiddleware; #counter = new Counter(); #onLoading = new ObjectEvent(); @@ -197,7 +199,40 @@ export class FetchClientProvider { * @param options - The rate limiting configuration options. */ public useRateLimit(options: RateLimitMiddlewareOptions) { - this.useMiddleware(createRateLimitMiddleware(options)); + this.#rateLimitMiddleware = new RateLimitMiddleware(options); + this.useMiddleware(this.#rateLimitMiddleware.middleware()); + } + + /** + * Enables rate limiting for all FetchClient instances created by this provider. + * @param options - The rate limiting configuration options. + */ + public usePerDomainRateLimit( + options: Omit, + ) { + this.#rateLimitMiddleware = new RateLimitMiddleware({ + ...options, + getGroupFunc: groupByDomain, + }); + this.useMiddleware(this.#rateLimitMiddleware.middleware()); + } + + /** + * Gets the rate limiter instance used for rate limiting. + * @returns The rate limiter instance, or undefined if rate limiting is not enabled. + */ + public get rateLimiter(): RateLimiter | undefined { + return this.#rateLimitMiddleware?.rateLimiter; + } + + /** + * Removes the rate limiting middleware from all FetchClient instances created by this provider. + */ + public removeRateLimit() { + this.#rateLimitMiddleware = undefined; + this.#options.middleware = this.#options.middleware?.filter( + (m) => !(m instanceof RateLimitMiddleware), + ); } } diff --git a/src/RateLimit.test.ts b/src/RateLimit.test.ts index 48e4ed3..c4787ec 100644 --- a/src/RateLimit.test.ts +++ b/src/RateLimit.test.ts @@ -5,7 +5,13 @@ import { type RateLimitMiddlewareOptions, } from "./RateLimitMiddleware.ts"; import type { FetchClientResponse } from "./FetchClientResponse.ts"; -import { RateLimiter } from "./RateLimiter.ts"; +import { + buildRateLimitHeader, + buildRateLimitPolicyHeader, + parseRateLimitHeader, + parseRateLimitPolicyHeader, + RateLimiter, +} from "./RateLimiter.ts"; // Mock fetch function for testing const createMockFetch = (response: { @@ -38,42 +44,42 @@ Deno.test("RateLimiter - basic functionality", () => { }); // First request should be allowed - assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); - assertEquals(rateLimiter.getRequestCount("http://example.com", "GET"), 1); + assertEquals(rateLimiter.isAllowed("http://example.com"), true); + assertEquals(rateLimiter.getRequestCount("http://example.com"), 1); assertEquals( - rateLimiter.getRemainingRequests("http://example.com", "GET"), + rateLimiter.getRemainingRequests("http://example.com"), 1, ); // Second request should be allowed - assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); - assertEquals(rateLimiter.getRequestCount("http://example.com", "GET"), 2); + assertEquals(rateLimiter.isAllowed("http://example.com"), true); + assertEquals(rateLimiter.getRequestCount("http://example.com"), 2); assertEquals( - rateLimiter.getRemainingRequests("http://example.com", "GET"), + rateLimiter.getRemainingRequests("http://example.com"), 0, ); // Third request should be denied - assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), false); - assertEquals(rateLimiter.getRequestCount("http://example.com", "GET"), 2); + assertEquals(rateLimiter.isAllowed("http://example.com"), false); + assertEquals(rateLimiter.getRequestCount("http://example.com"), 2); assertEquals( - rateLimiter.getRemainingRequests("http://example.com", "GET"), + rateLimiter.getRemainingRequests("http://example.com"), 0, ); }); -Deno.test("RateLimiter - key generator", () => { +Deno.test("RateLimiter - group generator", () => { const rateLimiter = new RateLimiter({ maxRequests: 1, windowMs: 1000, - keyGenerator: (url, method) => `${method}:${url}`, + getGroupFunc: (url: string) => `${url}`, }); - // Different methods should have separate buckets - assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); - assertEquals(rateLimiter.isAllowed("http://example.com", "POST"), true); - assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), false); - assertEquals(rateLimiter.isAllowed("http://example.com", "POST"), false); + // Different URLs should have separate buckets + assertEquals(rateLimiter.isAllowed("http://example.com"), true); + assertEquals(rateLimiter.isAllowed("http://other.com"), true); + assertEquals(rateLimiter.isAllowed("http://example.com"), false); + assertEquals(rateLimiter.isAllowed("http://other.com"), false); }); Deno.test("RateLimiter - time window expiry", async () => { @@ -83,16 +89,16 @@ Deno.test("RateLimiter - time window expiry", async () => { }); // First request should be allowed - assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); + assertEquals(rateLimiter.isAllowed("http://example.com"), true); // Second request should be denied - assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), false); + assertEquals(rateLimiter.isAllowed("http://example.com"), false); // Wait for window to expire await new Promise((resolve) => setTimeout(resolve, 150)); // Request should be allowed again - assertEquals(rateLimiter.isAllowed("http://example.com", "GET"), true); + assertEquals(rateLimiter.isAllowed("http://example.com"), true); }); Deno.test("RateLimitMiddleware - throws error when rate limit exceeded", async () => { @@ -183,14 +189,14 @@ Deno.test("RateLimitMiddleware - provides rate limit info in error response", as } catch (error) { const response = error as FetchClientResponse; assertEquals(response.status, 429); - assertEquals(response.headers.get("X-RateLimit-Limit"), "1"); - assertEquals(response.headers.get("X-RateLimit-Remaining"), "0"); - assertEquals(response.headers.get("X-RateLimit-Reset") !== null, true); + assertEquals(response.headers.get("RateLimit-Limit"), "1"); + assertEquals(response.headers.get("RateLimit-Remaining"), "0"); + assertEquals(response.headers.get("RateLimit-Reset") !== null, true); assertEquals(response.headers.get("Retry-After") !== null, true); } }); -Deno.test("createRateLimitMiddleware - custom key generator", async () => { +Deno.test("createRateLimitMiddleware - custom group generator", async () => { const mockFetch = createMockFetch(); const provider = new FetchClientProvider(mockFetch); @@ -198,11 +204,12 @@ Deno.test("createRateLimitMiddleware - custom key generator", async () => { const options: RateLimitMiddlewareOptions = { maxRequests: 1, windowMs: 1000, - keyGenerator: (url, method) => { + getGroupFunc: (url: string) => { callCount++; - return `custom-${method}-${url}`; + return `custom-${url}`; }, throwOnRateLimit: true, + autoUpdateFromHeaders: false, // Disable auto-update to prevent extra getGroupFunc calls }; provider.useRateLimit(options); @@ -251,3 +258,228 @@ Deno.test("RateLimitError - contains correct information", async () => { } } }); + +Deno.test("RateLimiter - updateFromHeaders with standard headers", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 10, + windowMs: 5000, + }); + + // Test with IETF standard headers + const headers = new Headers({ + "ratelimit-policy": '"default";q=100;w=60', + "ratelimit": '"default";r=75;t=30', + }); + + rateLimiter.updateFromHeaders("test-group", headers); + + const groupOptions = rateLimiter.getGroupOptions("test-group"); + assertEquals(groupOptions.maxRequests, 100); + assertEquals(groupOptions.windowMs, 60000); // 60 seconds in milliseconds +}); + +Deno.test("RateLimiter - updateFromHeaders with x-ratelimit fallback headers", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 10, + windowMs: 5000, + }); + + // Test with fallback x-ratelimit headers + const headers = new Headers({ + "x-ratelimit-limit": "50", + "x-ratelimit-remaining": "25", + "x-ratelimit-reset": "1234567890", + "x-ratelimit-window": "120", + }); + + rateLimiter.updateFromHeaders("test-group", headers); + + const groupOptions = rateLimiter.getGroupOptions("test-group"); + assertEquals(groupOptions.maxRequests, 50); + assertEquals(groupOptions.windowMs, 120000); // 120 seconds in milliseconds +}); + +Deno.test("RateLimiter - updateFromHeaders with x-rate-limit fallback headers", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 10, + windowMs: 5000, + }); + + // Test with alternate x-rate-limit headers + const headers = new Headers({ + "x-rate-limit-limit": "200", + "x-rate-limit-remaining": "150", + "x-rate-limit-reset": "1234567890", + "x-rate-limit-window": "300", + }); + + rateLimiter.updateFromHeaders("test-group", headers); + + const groupOptions = rateLimiter.getGroupOptions("test-group"); + assertEquals(groupOptions.maxRequests, 200); + assertEquals(groupOptions.windowMs, 300000); // 300 seconds in milliseconds +}); + +Deno.test("RateLimiter - updateFromHeaders prioritizes standard over x-ratelimit", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 10, + windowMs: 5000, + }); + + // Test with both IETF and x-ratelimit headers - IETF should take precedence + const headers = new Headers({ + "ratelimit-policy": '"default";q=100;w=60', + "ratelimit": '"default";r=75;t=30', + "x-ratelimit-limit": "50", + "x-ratelimit-remaining": "25", + "x-ratelimit-reset": "1234567890", + "x-ratelimit-window": "120", + }); + + rateLimiter.updateFromHeaders("test-group", headers); + + const groupOptions = rateLimiter.getGroupOptions("test-group"); + // Should use IETF standard values (100 limit, 60 window), not x-ratelimit values + assertEquals(groupOptions.maxRequests, 100); + assertEquals(groupOptions.windowMs, 60000); // 60 seconds in milliseconds +}); + +Deno.test("RateLimiter - updateFromHeaders with reset time calculation", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 10, + windowMs: 5000, + }); + + // Test with only reset time (no window) + const futureTime = Math.floor(Date.now() / 1000) + 90; // 90 seconds in the future + const headers = new Headers({ + "x-ratelimit-limit": "50", + "x-ratelimit-reset": futureTime.toString(), + }); + + rateLimiter.updateFromHeaders("test-group", headers); + + const groupOptions = rateLimiter.getGroupOptions("test-group"); + assertEquals(groupOptions.maxRequests, 50); + // Window should be approximately 90 seconds (90000ms) + assertEquals(groupOptions.windowMs! >= 85000, true); + assertEquals(groupOptions.windowMs! <= 95000, true); +}); + +Deno.test("RateLimiter - updateFromHeaders with malformed IETF headers", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 10, + windowMs: 5000, + }); + + // Test with malformed IETF headers should fall back to x-ratelimit + const headers = new Headers({ + "ratelimit-policy": '"default";invalid=format', + "ratelimit": '"default";bad=format', + "x-ratelimit-limit": "50", + "x-ratelimit-window": "120", + }); + + rateLimiter.updateFromHeaders("test-group", headers); + + const groupOptions = rateLimiter.getGroupOptions("test-group"); + assertEquals(groupOptions.maxRequests, 50); + assertEquals(groupOptions.windowMs, 120000); +}); + +Deno.test("createRateLimitHeader - creates correct header format", () => { + const result = buildRateLimitHeader({ + policy: "default", + limit: 100, + remaining: 75, + resetSeconds: 30, + windowSeconds: 60, + }); + + assertEquals(result, '"default";r=75;t=30'); +}); + +Deno.test("createRateLimitHeader - handles missing reset time", () => { + const result = buildRateLimitHeader({ + policy: "default", + limit: 100, + remaining: 75, + resetSeconds: 0, + windowSeconds: 60, + }); + + assertEquals(result, '"default";r=75'); +}); + +Deno.test("createRateLimitPolicyHeader - creates correct header format", () => { + const result = buildRateLimitPolicyHeader({ + policy: "default", + limit: 100, + remaining: 75, + resetSeconds: 30, + windowSeconds: 60, + }); + + assertEquals(result, '"default";q=100;w=60'); +}); + +Deno.test("createRateLimitPolicyHeader - handles missing window", () => { + const result = buildRateLimitPolicyHeader({ + policy: "default", + limit: 100, + remaining: 75, + resetSeconds: 30, + }); + + assertEquals(result, '"default";q=100'); +}); + +Deno.test("parseRateLimitHeader - parses correct header format", () => { + const result = parseRateLimitHeader('"default";r=75;t=30'); + + assertEquals(result, { + policy: "default", + remaining: 75, + resetSeconds: 30, + }); +}); + +Deno.test("parseRateLimitHeader - handles missing parameters", () => { + const result = parseRateLimitHeader('"default";r=75'); + + assertEquals(result, { + policy: "default", + remaining: 75, + }); +}); + +Deno.test("parseRateLimitHeader - handles invalid format", () => { + const result = parseRateLimitHeader("invalid-format"); + + assertEquals(result, {}); +}); + +Deno.test("parseRateLimitPolicyHeader - parses correct header format", () => { + const result = parseRateLimitPolicyHeader('"default";q=100;w=60'); + + assertEquals(result, { + policy: "default", + limit: 100, + windowSeconds: 60, + }); +}); + +Deno.test("parseRateLimitPolicyHeader - handles missing parameters", () => { + const result = parseRateLimitPolicyHeader('"default";q=100'); + + assertEquals(result, { + policy: "default", + limit: 100, + }); +}); + +Deno.test("parseRateLimitPolicyHeader - handles invalid format", () => { + const result = parseRateLimitPolicyHeader("invalid-format"); + + assertEquals(result, {}); +}); diff --git a/src/RateLimitMiddleware.ts b/src/RateLimitMiddleware.ts index b80b1ee..fc6e920 100644 --- a/src/RateLimitMiddleware.ts +++ b/src/RateLimitMiddleware.ts @@ -2,7 +2,12 @@ import type { FetchClientContext } from "./FetchClientContext.ts"; import type { FetchClientMiddleware } from "./FetchClientMiddleware.ts"; import type { FetchClientResponse } from "./FetchClientResponse.ts"; import { ProblemDetails } from "./ProblemDetails.ts"; -import { RateLimiter, type RateLimiterOptions } from "./RateLimiter.ts"; +import { + buildRateLimitHeader, + buildRateLimitPolicyHeader, + RateLimiter, + type RateLimiterOptions, +} from "./RateLimiter.ts"; /** * Rate limiting error thrown when requests exceed the rate limit. @@ -39,52 +44,106 @@ export interface RateLimitMiddlewareOptions extends RateLimiterOptions { * Custom error message when rate limit is exceeded. */ errorMessage?: string; + + /** + * Whether to automatically update rate limits based on response headers. + * @default true + */ + autoUpdateFromHeaders?: boolean; } /** - * Creates a rate limiting middleware for FetchClient. - * @param options - Rate limiting configuration options - * @returns A FetchClient middleware function + * Rate limiting middleware instance that can be shared across requests. */ -export function createRateLimitMiddleware( - options: RateLimitMiddlewareOptions, -): FetchClientMiddleware { - const rateLimiter = new RateLimiter(options); - const throwOnRateLimit = options.throwOnRateLimit ?? true; - - return async (context: FetchClientContext, next: () => Promise) => { - const url = context.request.url; - const method = context.request.method || "GET"; - - // Check if request is allowed - if (!rateLimiter.isAllowed(url, method)) { - const resetTime = rateLimiter.getResetTime(url, method) ?? Date.now(); - const remainingRequests = rateLimiter.getRemainingRequests(url, method); - - if (throwOnRateLimit) { - throw new RateLimitError( - resetTime, - remainingRequests, - options.errorMessage, +export class RateLimitMiddleware { + #rateLimiter: RateLimiter; + + private readonly throwOnRateLimit: boolean; + private readonly errorMessage?: string; + private readonly autoUpdateFromHeaders: boolean; + + constructor(options: RateLimitMiddlewareOptions) { + this.#rateLimiter = new RateLimiter(options); + this.throwOnRateLimit = options.throwOnRateLimit ?? true; + this.errorMessage = options.errorMessage; + this.autoUpdateFromHeaders = options.autoUpdateFromHeaders ?? true; + } + + /** + * Gets the underlying rate limiter instance. + */ + public get rateLimiter(): RateLimiter { + return this.#rateLimiter; + } + + /** + * Creates the middleware function. + * @returns The middleware function + */ + public middleware(): FetchClientMiddleware { + return async (context: FetchClientContext, next: () => Promise) => { + const url = context.request.url; + + // Check if request is allowed + if (!this.#rateLimiter.isAllowed(url)) { + const group = this.#rateLimiter.getGroup(url); + const bucket = this.#rateLimiter["buckets"].get(group); + const resetTime = bucket?.resetTime ?? Date.now(); + const remainingRequests = this.#rateLimiter.getRemainingRequests( + url, ); - } else { + + if (this.throwOnRateLimit) { + throw new RateLimitError( + resetTime, + remainingRequests, + this.errorMessage, + ); + } + // Create a 429 Too Many Requests response + const groupOptions = this.#rateLimiter.getGroupOptions(group); + const maxRequests = groupOptions?.maxRequests ?? + this.#rateLimiter["options"].maxRequests; + const windowMs = groupOptions?.windowMs ?? + this.#rateLimiter["options"].windowMs; + + // Create IETF standard rate limit headers + const resetSeconds = Math.ceil((resetTime - Date.now()) / 1000); + const rateLimitHeader = buildRateLimitHeader({ + policy: group, + limit: maxRequests, + remaining: remainingRequests, + resetSeconds: resetSeconds, + windowSeconds: Math.floor(windowMs / 1000), + }); + + const rateLimitPolicyHeader = buildRateLimitPolicyHeader({ + policy: group, + limit: maxRequests, + remaining: remainingRequests, + resetSeconds: resetSeconds, + windowSeconds: Math.floor(windowMs / 1000), + }); + const headers = new Headers({ "Content-Type": "application/problem+json", - "X-RateLimit-Limit": options.maxRequests.toString(), - "X-RateLimit-Remaining": remainingRequests.toString(), - "X-RateLimit-Reset": Math.ceil(resetTime / 1000).toString(), - "Retry-After": Math.ceil((resetTime - Date.now()) / 1000).toString(), + "RateLimit": rateLimitHeader, + "RateLimit-Policy": rateLimitPolicyHeader, + // Legacy headers for backward compatibility + "RateLimit-Limit": maxRequests.toString(), + "RateLimit-Remaining": remainingRequests.toString(), + "RateLimit-Reset": Math.ceil(resetTime / 1000).toString(), + "Retry-After": resetSeconds.toString(), }); const problem = new ProblemDetails(); problem.status = 429; problem.title = "Too Many Requests"; - problem.detail = options.errorMessage || + problem.detail = this.errorMessage || `Rate limit exceeded. Try again after ${ new Date(resetTime).toISOString() }`; - problem.type = "about:blank"; context.response = { url: context.request.url, @@ -110,15 +169,18 @@ export function createRateLimitMiddleware( throw new Error("Not implemented"); }, } as FetchClientResponse; + return; } - } - // Continue with the request - await next(); + await next(); - // Note: We cannot modify response headers after the response is created - // as Response headers are read-only. Rate limit information is only - // provided in error responses (429) where we control the entire response. - }; + if (this.autoUpdateFromHeaders && context.response) { + this.rateLimiter.updateFromHeadersForRequest( + url, + context.response.headers, + ); + } + }; + } } diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts index 85abefd..fd02f0b 100644 --- a/src/RateLimiter.ts +++ b/src/RateLimiter.ts @@ -1,3 +1,24 @@ +/** + * Per-group rate limiter options that can override the global options. + */ +export interface GroupRateLimiterOptions { + /** + * Maximum number of requests allowed per time window for this group. + */ + maxRequests?: number; + + /** + * Time window in milliseconds for this group. + */ + windowMs?: number; + + /** + * Callback function called when rate limit is exceeded for this group. + * @param resetTime - Time when the rate limit will reset (in milliseconds since epoch) + */ + onRateLimitExceeded?: (resetTime: number) => void; +} + /** * Configuration options for the rate limiter. */ @@ -13,13 +34,12 @@ export interface RateLimiterOptions { windowMs: number; /** - * Optional key generator function to create unique rate limit buckets. + * Optional group generator function to create unique rate limit buckets. * If not provided, a global rate limit is applied. * @param url - The request URL - * @param method - The HTTP method - * @returns A string key to identify the rate limit bucket + * @returns A string group to identify the rate limit bucket */ - keyGenerator?: (url: string, method: string) => string; + getGroupFunc?: (url: string) => string; /** * Callback function called when rate limit is exceeded. @@ -42,10 +62,11 @@ interface RateLimitBucket { export class RateLimiter { private readonly options: Required; private readonly buckets = new Map(); + private readonly groupOptions = new Map(); constructor(options: RateLimiterOptions) { this.options = { - keyGenerator: () => "global", + getGroupFunc: () => "global", onRateLimitExceeded: () => {}, ...options, }; @@ -54,34 +75,40 @@ export class RateLimiter { /** * Checks if a request is allowed and updates the rate limit state. * @param url - The request URL - * @param method - The HTTP method * @returns True if the request is allowed, false if rate limit is exceeded */ - public isAllowed(url: string, method: string): boolean { - const key = this.options.keyGenerator(url, method); + public isAllowed(url: string): boolean { + 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 ?? this.options.maxRequests; + const windowMs = groupOptions.windowMs ?? this.options.windowMs; + const onRateLimitExceeded = groupOptions.onRateLimitExceeded ?? + this.options.onRateLimitExceeded; + let bucket = this.buckets.get(key); if (!bucket) { bucket = { requests: [], - resetTime: now + this.options.windowMs, + resetTime: now + windowMs, }; this.buckets.set(key, bucket); } // Clean up old requests outside the time window - const windowStart = now - this.options.windowMs; + const windowStart = now - windowMs; bucket.requests = bucket.requests.filter((time) => time > windowStart); // Update reset time if all requests have expired if (bucket.requests.length === 0) { - bucket.resetTime = now + this.options.windowMs; + bucket.resetTime = now + windowMs; } // Check if we're within the rate limit - if (bucket.requests.length >= this.options.maxRequests) { - this.options.onRateLimitExceeded(bucket.resetTime); + if (bucket.requests.length >= maxRequests) { + onRateLimitExceeded(bucket.resetTime); return false; } @@ -93,11 +120,11 @@ export class RateLimiter { /** * Gets the current request count for a specific key. * @param url - The request URL - * @param method - The HTTP method * @returns The current number of requests in the time window */ - public getRequestCount(url: string, method: string): number { - const key = this.options.keyGenerator(url, method); + public getRequestCount(url: string): number { + const key = this.options.getGroupFunc(url); + const groupOptions = this.getGroupOptions(key); const bucket = this.buckets.get(key); if (!bucket) { @@ -105,31 +132,34 @@ export class RateLimiter { } const now = Date.now(); - const windowStart = now - this.options.windowMs; + const windowMs = groupOptions.windowMs ?? this.options.windowMs; + const windowStart = now - windowMs; return bucket.requests.filter((time) => time > windowStart).length; } /** * Gets the remaining requests allowed for a specific key. * @param url - The request URL - * @param method - The HTTP method * @returns The number of remaining requests allowed */ - public getRemainingRequests(url: string, method: string): number { + public getRemainingRequests(url: string): number { + const key = this.options.getGroupFunc(url); + const groupOptions = this.getGroupOptions(key); + const maxRequests = groupOptions.maxRequests ?? this.options.maxRequests; + return Math.max( 0, - this.options.maxRequests - this.getRequestCount(url, method), + maxRequests - this.getRequestCount(url), ); } /** * Gets the time when the rate limit will reset for a specific key. * @param url - The request URL - * @param method - The HTTP method * @returns The reset time in milliseconds since epoch, or null if no bucket exists */ - public getResetTime(url: string, method: string): number | null { - const key = this.options.keyGenerator(url, method); + public getResetTime(url: string): number | null { + const key = this.options.getGroupFunc(url); const bucket = this.buckets.get(key); return bucket?.resetTime ?? null; } @@ -137,13 +167,148 @@ export class RateLimiter { /** * Clears the rate limit state for a specific key. * @param url - The request URL - * @param method - The HTTP method */ - public clearBucket(url: string, method: string): void { - const key = this.options.keyGenerator(url, method); + public clearBucket(url: string): void { + 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 + */ + public getGroup(url: string): string { + return this.options.getGroupFunc(url); + } + + /** + * Gets the options for a specific group. + * @param group - The group key + * @returns The options for the group + */ + public getGroupOptions(group: string): GroupRateLimiterOptions { + return this.groupOptions.get(group) || {}; + } + + /** + * Sets options for a specific group. + * @param group - The group key + * @param options - The options to set + */ + public setGroupOptions( + group: string, + options: GroupRateLimiterOptions, + ): void { + 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 + */ + public setOptionsForRequest( + url: string, + options: GroupRateLimiterOptions, + ): void { + 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 + */ + public updateFromHeadersForRequest( + url: string, + headers: Headers, + ): void { + 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 + */ + public updateFromHeaders(group: string, headers: Headers): void { + const currentOptions = this.getGroupOptions(group); + const newOptions: GroupRateLimiterOptions = { ...currentOptions }; + + // Parse IETF standard rate limit headers first, then fall back to x-ratelimit headers + let limit: string | null = null; + let window: string | null = null; + let reset: string | null = 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"); + } + + // Apply the parsed values + if (limit) { + const maxRequests = parseInt(limit, 10); + if (!isNaN(maxRequests)) { + newOptions.maxRequests = maxRequests; + } + } + + if (window) { + const windowMs = parseInt(window, 10) * 1000; // Convert seconds to milliseconds + if (!isNaN(windowMs)) { + newOptions.windowMs = windowMs; + } + } 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 windowMs = Math.max(1000, (resetTime - now) * 1000); + newOptions.windowMs = windowMs; + } + } + + // Update the group options if we found valid headers + if (Object.keys(newOptions).length > Object.keys(currentOptions).length) { + this.setGroupOptions(group, newOptions); + } + } + /** * Clears all rate limit state. */ @@ -151,3 +316,137 @@ export class RateLimiter { 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: string): string { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return url; + } +} + +/** + * IETF rate limit header information structure. + */ +export interface RateLimitInfo { + /** The policy name/identifier */ + policy: string; + /** Maximum requests allowed (quota) */ + limit: number; + /** Remaining requests */ + remaining: number; + /** Reset time in seconds from now */ + resetSeconds: number; + /** Window duration in seconds */ + windowSeconds?: number; +} + +/** + * Creates an IETF standard RateLimit header value. + * @param info - The rate limit information + * @returns The formatted RateLimit header value + */ +export function buildRateLimitHeader(info: RateLimitInfo): string { + 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: RateLimitInfo): string { + 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: string, +): Partial | null { + if (!headerValue) return null; + + try { + const result: Partial = {}; + + // 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: string, +): Partial | null { + if (!headerValue) return null; + + try { + const result: Partial = {}; + + // 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; + } +} From aa6fac87f06755ea63ad98683bcbf0175df4f722 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 17 Jul 2025 00:21:12 -0500 Subject: [PATCH 3/7] More progress --- readme.md | 35 +++++++++-- src/DefaultHelpers.ts | 21 +++++++ src/FetchClient.test.ts | 118 +++++++++++++++++++++++++++++++++++++ src/RateLimit.test.ts | 98 ++++++++++++++++++++---------- src/RateLimitMiddleware.ts | 10 +--- src/RateLimiter.ts | 52 +++++++++++----- 6 files changed, 274 insertions(+), 60 deletions(-) diff --git a/readme.md b/readme.md index f5301a4..3b88b1c 100644 --- a/readme.md +++ b/readme.md @@ -3,12 +3,18 @@ FetchClient is a library that makes it easier to use the fetch API for JSON APIs. It provides the following features: -* [Makes fetch easier to use for JSON APIs](#typed-response) -* [Automatic model validation](#model-validator) -* [Caching](#caching) -* [Middleware](#middleware) -* [Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) support -* Option to parse dates in responses +- [FetchClient ](#fetchclient---) + - [Install](#install) + - [Docs](#docs) + - [Usage](#usage) + - [Typed Response](#typed-response) + - [Typed Response Using a Function](#typed-response-using-a-function) + - [Model Validator](#model-validator) + - [Caching](#caching) + - [Middleware](#middleware) + - [Rate Limiting](#rate-limiting) + - [Contributing](#contributing) + - [License](#license) ## Install @@ -130,6 +136,23 @@ const response = await client.getJSON( ); ``` +### Rate Limiting + +```ts +import { FetchClient, useRateLimit } from '@exceptionless/fetchclient'; + +// Enable rate limiting globally with 100 requests per minute +useRateLimit({ + maxRequests: 100, + windowSeconds: 60, +}); + +const client = new FetchClient(); +const response = await client.getJSON( + `https://api.example.com/data`, +); +``` + Also, take a look at the tests: [FetchClient Tests](src/FetchClient.test.ts) diff --git a/src/DefaultHelpers.ts b/src/DefaultHelpers.ts index 9697e35..3e8208e 100644 --- a/src/DefaultHelpers.ts +++ b/src/DefaultHelpers.ts @@ -7,6 +7,7 @@ import { } from "./FetchClientProvider.ts"; import type { FetchClientResponse } from "./FetchClientResponse.ts"; import type { ProblemDetails } from "./ProblemDetails.ts"; +import type { RateLimitMiddlewareOptions } from "./RateLimitMiddleware.ts"; import type { GetRequestOptions, RequestOptions } from "./RequestOptions.ts"; let getCurrentProviderFunc: () => FetchClientProvider | null = () => null; @@ -164,3 +165,23 @@ export function useMiddleware(middleware: FetchClientMiddleware) { export function setRequestOptions(options: RequestOptions) { getCurrentProvider().applyOptions({ defaultRequestOptions: options }); } + +/** + * Enables rate limiting for any FetchClient instances created by the current provider. + * @param options - The rate limiting configuration options. + */ +export function useRateLimit( + options: RateLimitMiddlewareOptions, +) { + getCurrentProvider().useRateLimit(options); +} + +/** + * Enables per-domain rate limiting for any FetchClient instances created by the current provider. + * @param options - The rate limiting configuration options. + */ +export function usePerDomainRateLimit( + options: Omit, +) { + getCurrentProvider().usePerDomainRateLimit(options); +} diff --git a/src/FetchClient.test.ts b/src/FetchClient.test.ts index 5c45879..800e641 100644 --- a/src/FetchClient.test.ts +++ b/src/FetchClient.test.ts @@ -16,6 +16,7 @@ import { } from "../mod.ts"; import { FetchClientProvider } from "./FetchClientProvider.ts"; import { z, type ZodTypeAny } from "zod"; +import { buildRateLimitHeader } from "./RateLimiter.ts"; export const TodoSchema = z.object({ userId: z.number(), @@ -970,6 +971,123 @@ Deno.test("handles 400 response with non-JSON text", async () => { ); }); +Deno.test("can use per-domain rate limiting with auto-update from headers", async () => { + const provider = new FetchClientProvider(); + + const groupTracker = new Map(); + + const startTime = Date.now(); + + groupTracker.set("api.example.com", 100); + groupTracker.set("slow-api.example.com", 5); + + provider.usePerDomainRateLimit({ + maxRequests: 50, // Default limit + windowSeconds: 60, // 1 minute default window + autoUpdateFromHeaders: true, + groups: { + "api.example.com": { + maxRequests: 100, + windowSeconds: 60, + }, + "slow-api.example.com": { + maxRequests: 5, + windowSeconds: 30, + }, + }, + }); + + provider.fetch = ( + input: RequestInfo | URL, + _init?: RequestInit, + ): Promise => { + let url: URL; + if (input instanceof Request) { + url = new URL(input.url); + } else { + url = new URL(input.toString()); + } + + const headers = new Headers({ + "Content-Type": "application/json", + }); + + // Simulate different rate limits for different domains + if (url.hostname === "api.example.com") { + headers.set("X-RateLimit-Limit", "100"); + let remaining = groupTracker.get("api.example.com") ?? 0; + remaining = remaining > 0 ? remaining - 1 : 0; + groupTracker.set("api.example.com", remaining); + headers.set("X-RateLimit-Remaining", String(remaining)); + } else if (url.hostname === "slow-api.example.com") { + let remaining = groupTracker.get("slow-api.example.com") ?? 0; + remaining = remaining > 0 ? remaining - 1 : 0; + groupTracker.set("slow-api.example.com", remaining); + + headers.set( + "RateLimit-Policy", + buildRateLimitHeader({ + policy: "slow-api.example.com", + remaining: remaining, + resetSeconds: 30 - ((Date.now() - startTime) / 1000), + }), + ); + headers.set( + "RateLimit", + buildRateLimitHeader({ + policy: "slow-api.example.com", + remaining: remaining, + resetSeconds: 30 - ((Date.now() - startTime) / 1000), + }), + ); + } + // other-api.example.com gets no rate limit headers + + return Promise.resolve( + new Response(JSON.stringify({ success: true }), { + status: 200, + statusText: "OK", + headers, + }), + ); + }; + + const client = provider.getFetchClient(); + + const response1 = await client.getJSON( + "https://api.example.com/data", + ); + assertEquals(response1.status, 200); + + const response2 = await client.getJSON( + "https://slow-api.example.com/data", + ); + assertEquals(response2.status, 200); + + const response3 = await client.getJSON( + "https://other-api.example.com/data", + ); + assertEquals(response3.status, 200); + + assert(provider.rateLimiter); + + const apiOptions = provider.rateLimiter.getGroupOptions("api.example.com"); + assertEquals(apiOptions.maxRequests, 100); + assertEquals(apiOptions.windowSeconds, 60); + + const slowApiOptions = provider.rateLimiter.getGroupOptions( + "slow-api.example.com", + ); + assertEquals(slowApiOptions.maxRequests, 5); + assertEquals(slowApiOptions.windowSeconds, 30); + + const otherOptions = provider.rateLimiter.getGroupOptions( + "other-api.example.com", + ); + assertEquals(otherOptions.maxRequests, undefined); + assertEquals(otherOptions.windowSeconds, undefined); +}); + function delay(time: number): Promise { return new Promise((resolve) => setTimeout(resolve, time)); } diff --git a/src/RateLimit.test.ts b/src/RateLimit.test.ts index c4787ec..7d518ca 100644 --- a/src/RateLimit.test.ts +++ b/src/RateLimit.test.ts @@ -40,7 +40,7 @@ const createMockFetch = (response: { Deno.test("RateLimiter - basic functionality", () => { const rateLimiter = new RateLimiter({ maxRequests: 2, - windowMs: 1000, + windowSeconds: 1, }); // First request should be allowed @@ -71,7 +71,7 @@ Deno.test("RateLimiter - basic functionality", () => { Deno.test("RateLimiter - group generator", () => { const rateLimiter = new RateLimiter({ maxRequests: 1, - windowMs: 1000, + windowSeconds: 1, getGroupFunc: (url: string) => `${url}`, }); @@ -82,10 +82,53 @@ Deno.test("RateLimiter - group generator", () => { assertEquals(rateLimiter.isAllowed("http://other.com"), false); }); +Deno.test("RateLimiter - group initialization", () => { + const rateLimiter = new RateLimiter({ + maxRequests: 5, + windowSeconds: 1, + getGroupFunc: (url: string) => new URL(url).hostname, + groups: { + "example.com": { + maxRequests: 2, + windowSeconds: 1, + }, + "api.example.com": { + maxRequests: 10, + windowSeconds: 2, + }, + }, + }); + + // Check that group options were applied correctly + const exampleOptions = rateLimiter.getGroupOptions("example.com"); + assertEquals(exampleOptions.maxRequests, 2); + assertEquals(exampleOptions.windowSeconds, 1); + + const apiOptions = rateLimiter.getGroupOptions("api.example.com"); + assertEquals(apiOptions.maxRequests, 10); + assertEquals(apiOptions.windowSeconds, 2); + + // Check that non-configured groups get empty options (will use defaults) + const otherOptions = rateLimiter.getGroupOptions("other.com"); + assertEquals(otherOptions.maxRequests, undefined); + assertEquals(otherOptions.windowSeconds, undefined); + + // Test that the group-specific limits are actually used + assertEquals(rateLimiter.isAllowed("https://example.com/test"), true); + assertEquals(rateLimiter.isAllowed("https://example.com/test"), true); + assertEquals(rateLimiter.isAllowed("https://example.com/test"), false); // Should be denied (limit=2) + + // API subdomain should have different limits + assertEquals( + rateLimiter.getRemainingRequests("https://api.example.com/test"), + 10, + ); +}); + Deno.test("RateLimiter - time window expiry", async () => { const rateLimiter = new RateLimiter({ maxRequests: 1, - windowMs: 100, // 100ms window + windowSeconds: 0.1, }); // First request should be allowed @@ -107,7 +150,7 @@ Deno.test("RateLimitMiddleware - throws error when rate limit exceeded", async ( const options: RateLimitMiddlewareOptions = { maxRequests: 1, - windowMs: 1000, + windowSeconds: 1, throwOnRateLimit: true, }; @@ -133,7 +176,7 @@ Deno.test("RateLimitMiddleware - returns 429 response when configured", async () const options: RateLimitMiddlewareOptions = { maxRequests: 1, - windowMs: 1000, + windowSeconds: 1, throwOnRateLimit: false, errorMessage: "Custom rate limit message", }; @@ -170,7 +213,7 @@ Deno.test("RateLimitMiddleware - provides rate limit info in error response", as const options: RateLimitMiddlewareOptions = { maxRequests: 1, - windowMs: 1000, + windowSeconds: 1, throwOnRateLimit: false, }; @@ -203,7 +246,7 @@ Deno.test("createRateLimitMiddleware - custom group generator", async () => { let callCount = 0; const options: RateLimitMiddlewareOptions = { maxRequests: 1, - windowMs: 1000, + windowSeconds: 1, getGroupFunc: (url: string) => { callCount++; return `custom-${url}`; @@ -235,7 +278,7 @@ Deno.test("RateLimitError - contains correct information", async () => { provider.useRateLimit({ maxRequests: 1, - windowMs: 1000, + windowSeconds: 1, throwOnRateLimit: true, }); @@ -247,6 +290,7 @@ Deno.test("RateLimitError - contains correct information", async () => { // Second request should throw with proper error info try { await client.get("http://example.com"); + throw new Error("Expected request to fail"); } catch (error) { if (error instanceof RateLimitError) { assertEquals(error.name, "RateLimitError"); @@ -262,7 +306,7 @@ Deno.test("RateLimitError - contains correct information", async () => { Deno.test("RateLimiter - updateFromHeaders with standard headers", () => { const rateLimiter = new RateLimiter({ maxRequests: 10, - windowMs: 5000, + windowSeconds: 5, }); // Test with IETF standard headers @@ -275,13 +319,13 @@ Deno.test("RateLimiter - updateFromHeaders with standard headers", () => { const groupOptions = rateLimiter.getGroupOptions("test-group"); assertEquals(groupOptions.maxRequests, 100); - assertEquals(groupOptions.windowMs, 60000); // 60 seconds in milliseconds + assertEquals(groupOptions.windowSeconds, 60); }); Deno.test("RateLimiter - updateFromHeaders with x-ratelimit fallback headers", () => { const rateLimiter = new RateLimiter({ maxRequests: 10, - windowMs: 5000, + windowSeconds: 5, }); // Test with fallback x-ratelimit headers @@ -296,13 +340,13 @@ Deno.test("RateLimiter - updateFromHeaders with x-ratelimit fallback headers", ( const groupOptions = rateLimiter.getGroupOptions("test-group"); assertEquals(groupOptions.maxRequests, 50); - assertEquals(groupOptions.windowMs, 120000); // 120 seconds in milliseconds + assertEquals(groupOptions.windowSeconds, 120); }); Deno.test("RateLimiter - updateFromHeaders with x-rate-limit fallback headers", () => { const rateLimiter = new RateLimiter({ maxRequests: 10, - windowMs: 5000, + windowSeconds: 5, }); // Test with alternate x-rate-limit headers @@ -310,20 +354,20 @@ Deno.test("RateLimiter - updateFromHeaders with x-rate-limit fallback headers", "x-rate-limit-limit": "200", "x-rate-limit-remaining": "150", "x-rate-limit-reset": "1234567890", - "x-rate-limit-window": "300", + "x-rate-limit-window": "30", }); rateLimiter.updateFromHeaders("test-group", headers); const groupOptions = rateLimiter.getGroupOptions("test-group"); assertEquals(groupOptions.maxRequests, 200); - assertEquals(groupOptions.windowMs, 300000); // 300 seconds in milliseconds + assertEquals(groupOptions.windowSeconds, 30); }); Deno.test("RateLimiter - updateFromHeaders prioritizes standard over x-ratelimit", () => { const rateLimiter = new RateLimiter({ maxRequests: 10, - windowMs: 5000, + windowSeconds: 5, }); // Test with both IETF and x-ratelimit headers - IETF should take precedence @@ -341,13 +385,13 @@ Deno.test("RateLimiter - updateFromHeaders prioritizes standard over x-ratelimit const groupOptions = rateLimiter.getGroupOptions("test-group"); // Should use IETF standard values (100 limit, 60 window), not x-ratelimit values assertEquals(groupOptions.maxRequests, 100); - assertEquals(groupOptions.windowMs, 60000); // 60 seconds in milliseconds + assertEquals(groupOptions.windowSeconds, 60); }); Deno.test("RateLimiter - updateFromHeaders with reset time calculation", () => { const rateLimiter = new RateLimiter({ maxRequests: 10, - windowMs: 5000, + windowSeconds: 5, }); // Test with only reset time (no window) @@ -361,15 +405,15 @@ Deno.test("RateLimiter - updateFromHeaders with reset time calculation", () => { const groupOptions = rateLimiter.getGroupOptions("test-group"); assertEquals(groupOptions.maxRequests, 50); - // Window should be approximately 90 seconds (90000ms) - assertEquals(groupOptions.windowMs! >= 85000, true); - assertEquals(groupOptions.windowMs! <= 95000, true); + // Window should be approximately 90 seconds + assertEquals(groupOptions.windowSeconds! >= 85, true); + assertEquals(groupOptions.windowSeconds! <= 95, true); }); Deno.test("RateLimiter - updateFromHeaders with malformed IETF headers", () => { const rateLimiter = new RateLimiter({ maxRequests: 10, - windowMs: 5000, + windowSeconds: 5, }); // Test with malformed IETF headers should fall back to x-ratelimit @@ -384,16 +428,14 @@ Deno.test("RateLimiter - updateFromHeaders with malformed IETF headers", () => { const groupOptions = rateLimiter.getGroupOptions("test-group"); assertEquals(groupOptions.maxRequests, 50); - assertEquals(groupOptions.windowMs, 120000); + assertEquals(groupOptions.windowSeconds, 120); }); Deno.test("createRateLimitHeader - creates correct header format", () => { const result = buildRateLimitHeader({ policy: "default", - limit: 100, remaining: 75, resetSeconds: 30, - windowSeconds: 60, }); assertEquals(result, '"default";r=75;t=30'); @@ -402,10 +444,8 @@ Deno.test("createRateLimitHeader - creates correct header format", () => { Deno.test("createRateLimitHeader - handles missing reset time", () => { const result = buildRateLimitHeader({ policy: "default", - limit: 100, remaining: 75, resetSeconds: 0, - windowSeconds: 60, }); assertEquals(result, '"default";r=75'); @@ -415,8 +455,6 @@ Deno.test("createRateLimitPolicyHeader - creates correct header format", () => { const result = buildRateLimitPolicyHeader({ policy: "default", limit: 100, - remaining: 75, - resetSeconds: 30, windowSeconds: 60, }); @@ -427,8 +465,6 @@ Deno.test("createRateLimitPolicyHeader - handles missing window", () => { const result = buildRateLimitPolicyHeader({ policy: "default", limit: 100, - remaining: 75, - resetSeconds: 30, }); assertEquals(result, '"default";q=100'); diff --git a/src/RateLimitMiddleware.ts b/src/RateLimitMiddleware.ts index fc6e920..c714b2d 100644 --- a/src/RateLimitMiddleware.ts +++ b/src/RateLimitMiddleware.ts @@ -105,25 +105,21 @@ export class RateLimitMiddleware { const groupOptions = this.#rateLimiter.getGroupOptions(group); const maxRequests = groupOptions?.maxRequests ?? this.#rateLimiter["options"].maxRequests; - const windowMs = groupOptions?.windowMs ?? - this.#rateLimiter["options"].windowMs; + const windowSeconds = groupOptions?.windowSeconds ?? + this.#rateLimiter["options"].windowSeconds; // Create IETF standard rate limit headers const resetSeconds = Math.ceil((resetTime - Date.now()) / 1000); const rateLimitHeader = buildRateLimitHeader({ policy: group, - limit: maxRequests, remaining: remainingRequests, resetSeconds: resetSeconds, - windowSeconds: Math.floor(windowMs / 1000), }); const rateLimitPolicyHeader = buildRateLimitPolicyHeader({ policy: group, limit: maxRequests, - remaining: remainingRequests, - resetSeconds: resetSeconds, - windowSeconds: Math.floor(windowMs / 1000), + windowSeconds: Math.floor(windowSeconds), }); const headers = new Headers({ diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts index fd02f0b..ae6b7ff 100644 --- a/src/RateLimiter.ts +++ b/src/RateLimiter.ts @@ -10,7 +10,7 @@ export interface GroupRateLimiterOptions { /** * Time window in milliseconds for this group. */ - windowMs?: number; + windowSeconds?: number; /** * Callback function called when rate limit is exceeded for this group. @@ -29,9 +29,9 @@ export interface RateLimiterOptions { maxRequests: number; /** - * Time window in milliseconds. + * Time window in seconds. */ - windowMs: number; + windowSeconds: number; /** * Optional group generator function to create unique rate limit buckets. @@ -46,6 +46,12 @@ export interface RateLimiterOptions { * @param resetTime - Time when the rate limit will reset (in milliseconds since epoch) */ onRateLimitExceeded?: (resetTime: number) => void; + + /** + * Optional group-specific rate limit options. + * Map of group keys to their specific rate limit options. + */ + groups?: Record; } /** @@ -68,8 +74,16 @@ export class RateLimiter { 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); + } + } } /** @@ -84,7 +98,8 @@ export class RateLimiter { // Use group-specific options if available, otherwise fall back to global options const maxRequests = groupOptions.maxRequests ?? this.options.maxRequests; - const windowMs = groupOptions.windowMs ?? this.options.windowMs; + const windowSeconds = groupOptions.windowSeconds ?? + this.options.windowSeconds; const onRateLimitExceeded = groupOptions.onRateLimitExceeded ?? this.options.onRateLimitExceeded; @@ -92,18 +107,18 @@ export class RateLimiter { if (!bucket) { bucket = { requests: [], - resetTime: now + windowMs, + resetTime: now + (windowSeconds * 1000), }; this.buckets.set(key, bucket); } // Clean up old requests outside the time window - const windowStart = now - windowMs; + 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 + windowMs; + bucket.resetTime = now + (windowSeconds * 1000); } // Check if we're within the rate limit @@ -132,8 +147,9 @@ export class RateLimiter { } const now = Date.now(); - const windowMs = groupOptions.windowMs ?? this.options.windowMs; - const windowStart = now - windowMs; + const windowSeconds = groupOptions.windowSeconds ?? + this.options.windowSeconds; + const windowStart = now - (windowSeconds * 1000); return bucket.requests.filter((time) => time > windowStart).length; } @@ -289,17 +305,17 @@ export class RateLimiter { } if (window) { - const windowMs = parseInt(window, 10) * 1000; // Convert seconds to milliseconds - if (!isNaN(windowMs)) { - newOptions.windowMs = windowMs; + const windowSeconds = parseInt(window, 10); + if (!isNaN(windowSeconds)) { + newOptions.windowSeconds = windowSeconds; } } 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 windowMs = Math.max(1000, (resetTime - now) * 1000); - newOptions.windowMs = windowMs; + const windowSeconds = Math.max(1, resetTime - now); + newOptions.windowSeconds = windowSeconds; } } @@ -352,7 +368,9 @@ export interface RateLimitInfo { * @param info - The rate limit information * @returns The formatted RateLimit header value */ -export function buildRateLimitHeader(info: RateLimitInfo): string { +export function buildRateLimitHeader( + info: Omit, +): string { let headerValue = `"${info.policy}";r=${info.remaining}`; if (info.resetSeconds > 0) { @@ -367,7 +385,9 @@ export function buildRateLimitHeader(info: RateLimitInfo): string { * @param info - The rate limit information * @returns The formatted RateLimit-Policy header value */ -export function buildRateLimitPolicyHeader(info: RateLimitInfo): string { +export function buildRateLimitPolicyHeader( + info: Omit, +): string { let headerValue = `"${info.policy}";q=${info.limit}`; if (info.windowSeconds && info.windowSeconds > 0) { From fa6fbb13ff686d322ff5249e6666e2538ee01e59 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 17 Jul 2025 10:45:05 -0500 Subject: [PATCH 4/7] Update src/FetchClient.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/FetchClient.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FetchClient.test.ts b/src/FetchClient.test.ts index 800e641..4e86f1f 100644 --- a/src/FetchClient.test.ts +++ b/src/FetchClient.test.ts @@ -1026,7 +1026,7 @@ Deno.test("can use per-domain rate limiting with auto-update from headers", asyn headers.set( "RateLimit-Policy", - buildRateLimitHeader({ + buildRateLimitPolicyHeader({ policy: "slow-api.example.com", remaining: remaining, resetSeconds: 30 - ((Date.now() - startTime) / 1000), From 5f1598adfbba376a08b4dce0245c7f3aacdb2ffd Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 17 Jul 2025 16:29:11 -0500 Subject: [PATCH 5/7] Some updates --- .vscode/launch.json | 4 +-- .vscode/tasks.json | 11 ++++++-- src/FetchClient.test.ts | 56 ++++++++++++++++++++------------------ src/RateLimitMiddleware.ts | 19 +++++-------- src/RateLimiter.ts | 7 ++++- 5 files changed, 53 insertions(+), 44 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index ade2de0..757f269 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -28,9 +28,9 @@ "--inspect-wait", "--allow-all", "--filter", - "handles 400 response with non-JSON text" + "can use per-domain rate limiting with auto-update from headers" ], "attachSimplePort": 9229 } ] -} +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 61e834a..7486988 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -19,6 +19,10 @@ "problemMatcher": [ "$deno" ], + "group": { + "kind": "build", + "isDefault": true + }, "label": "deno: check", "detail": "$ deno check scripts/*.ts *.ts src/*.ts" }, @@ -43,8 +47,11 @@ "problemMatcher": [ "$deno-test" ], - "group": "test", + "group": { + "kind": "test", + "isDefault": true + }, "label": "deno: test" } ] -} +} \ No newline at end of file diff --git a/src/FetchClient.test.ts b/src/FetchClient.test.ts index 4e86f1f..6bd6632 100644 --- a/src/FetchClient.test.ts +++ b/src/FetchClient.test.ts @@ -16,7 +16,10 @@ import { } from "../mod.ts"; import { FetchClientProvider } from "./FetchClientProvider.ts"; import { z, type ZodTypeAny } from "zod"; -import { buildRateLimitHeader } from "./RateLimiter.ts"; +import { + buildRateLimitHeader, + buildRateLimitPolicyHeader, +} from "./RateLimiter.ts"; export const TodoSchema = z.object({ userId: z.number(), @@ -987,11 +990,11 @@ Deno.test("can use per-domain rate limiting with auto-update from headers", asyn autoUpdateFromHeaders: true, groups: { "api.example.com": { - maxRequests: 100, + maxRequests: 75, // API will override this with headers windowSeconds: 60, }, "slow-api.example.com": { - maxRequests: 5, + maxRequests: 30, // API will override this with headers windowSeconds: 30, }, }, @@ -1016,20 +1019,20 @@ Deno.test("can use per-domain rate limiting with auto-update from headers", asyn if (url.hostname === "api.example.com") { headers.set("X-RateLimit-Limit", "100"); let remaining = groupTracker.get("api.example.com") ?? 0; - remaining = remaining > 0 ? remaining - 1 : 0; + remaining = remaining > 0 ? remaining - 2 : 0; groupTracker.set("api.example.com", remaining); headers.set("X-RateLimit-Remaining", String(remaining)); } else if (url.hostname === "slow-api.example.com") { let remaining = groupTracker.get("slow-api.example.com") ?? 0; - remaining = remaining > 0 ? remaining - 1 : 0; + remaining = remaining > 0 ? remaining - 2 : 0; groupTracker.set("slow-api.example.com", remaining); headers.set( "RateLimit-Policy", buildRateLimitPolicyHeader({ policy: "slow-api.example.com", - remaining: remaining, - resetSeconds: 30 - ((Date.now() - startTime) / 1000), + limit: 5, + windowSeconds: 30, }), ); headers.set( @@ -1052,40 +1055,39 @@ Deno.test("can use per-domain rate limiting with auto-update from headers", asyn ); }; + assert(provider.rateLimiter); + const client = provider.getFetchClient(); + // check API rate limit + let apiOptions = provider.rateLimiter.getGroupOptions("api.example.com"); + assertEquals(apiOptions.maxRequests, 75); + assertEquals(apiOptions.windowSeconds, 60); + const response1 = await client.getJSON( "https://api.example.com/data", ); assertEquals(response1.status, 200); + apiOptions = provider.rateLimiter.getGroupOptions("api.example.com"); + assertEquals(apiOptions.maxRequests, 100); // Updated from headers + + // check slow API rate limit + let slowApiOptions = provider.rateLimiter.getGroupOptions( + "slow-api.example.com", + ); + assertEquals(slowApiOptions.maxRequests, 30); + assertEquals(slowApiOptions.windowSeconds, 30); + const response2 = await client.getJSON( "https://slow-api.example.com/data", ); assertEquals(response2.status, 200); - const response3 = await client.getJSON( - "https://other-api.example.com/data", - ); - assertEquals(response3.status, 200); - - assert(provider.rateLimiter); - - const apiOptions = provider.rateLimiter.getGroupOptions("api.example.com"); - assertEquals(apiOptions.maxRequests, 100); - assertEquals(apiOptions.windowSeconds, 60); - - const slowApiOptions = provider.rateLimiter.getGroupOptions( + slowApiOptions = provider.rateLimiter.getGroupOptions( "slow-api.example.com", ); - assertEquals(slowApiOptions.maxRequests, 5); - assertEquals(slowApiOptions.windowSeconds, 30); - - const otherOptions = provider.rateLimiter.getGroupOptions( - "other-api.example.com", - ); - assertEquals(otherOptions.maxRequests, undefined); - assertEquals(otherOptions.windowSeconds, undefined); + assertEquals(slowApiOptions.maxRequests, 5); // Updated from headers }); function delay(time: number): Promise { diff --git a/src/RateLimitMiddleware.ts b/src/RateLimitMiddleware.ts index c714b2d..d0adc96 100644 --- a/src/RateLimitMiddleware.ts +++ b/src/RateLimitMiddleware.ts @@ -85,13 +85,10 @@ export class RateLimitMiddleware { const url = context.request.url; // Check if request is allowed - if (!this.#rateLimiter.isAllowed(url)) { - const group = this.#rateLimiter.getGroup(url); - const bucket = this.#rateLimiter["buckets"].get(group); - const resetTime = bucket?.resetTime ?? Date.now(); - const remainingRequests = this.#rateLimiter.getRemainingRequests( - url, - ); + if (!this.rateLimiter.isAllowed(url)) { + const group = this.rateLimiter.getGroup(url); + const resetTime = this.rateLimiter.getResetTime(url) ?? Date.now(); + const remainingRequests = this.rateLimiter.getRemainingRequests(url); if (this.throwOnRateLimit) { throw new RateLimitError( @@ -102,11 +99,9 @@ export class RateLimitMiddleware { } // Create a 429 Too Many Requests response - const groupOptions = this.#rateLimiter.getGroupOptions(group); - const maxRequests = groupOptions?.maxRequests ?? - this.#rateLimiter["options"].maxRequests; - const windowSeconds = groupOptions?.windowSeconds ?? - this.#rateLimiter["options"].windowSeconds; + const groupOptions = this.rateLimiter.getGroupOptions(group); + const maxRequests = groupOptions.maxRequests ?? 0; + const windowSeconds = groupOptions.windowSeconds ?? 0; // Create IETF standard rate limit headers const resetSeconds = Math.ceil((resetTime - Date.now()) / 1000); diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts index ae6b7ff..459c28c 100644 --- a/src/RateLimiter.ts +++ b/src/RateLimiter.ts @@ -296,11 +296,14 @@ export class RateLimiter { 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; } } @@ -308,6 +311,7 @@ export class RateLimiter { 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 @@ -316,11 +320,12 @@ export class RateLimiter { 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 (Object.keys(newOptions).length > Object.keys(currentOptions).length) { + if (hasChanges) { this.setGroupOptions(group, newOptions); } } From 6cb8b54f86b6e09d1f2a27e54841bc88e5f04aa2 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 17 Jul 2025 16:56:02 -0500 Subject: [PATCH 6/7] Fix formatting --- .vscode/launch.json | 2 +- .vscode/tasks.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 757f269..e23a68c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -33,4 +33,4 @@ "attachSimplePort": 9229 } ] -} \ No newline at end of file +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7486988..e81dfc9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -54,4 +54,4 @@ "label": "deno: test" } ] -} \ No newline at end of file +} From 666c76ed82d4325c41fd19a286a92cb68eb5d73e Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 17 Jul 2025 23:05:37 -0500 Subject: [PATCH 7/7] Fix tests --- src/RateLimit.test.ts | 4 ++-- src/RateLimitMiddleware.ts | 4 ++-- src/RateLimiter.ts | 35 ++++++++++++++++++++++++++--------- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/RateLimit.test.ts b/src/RateLimit.test.ts index 7d518ca..9bdfb52 100644 --- a/src/RateLimit.test.ts +++ b/src/RateLimit.test.ts @@ -110,8 +110,8 @@ Deno.test("RateLimiter - group initialization", () => { // Check that non-configured groups get empty options (will use defaults) const otherOptions = rateLimiter.getGroupOptions("other.com"); - assertEquals(otherOptions.maxRequests, undefined); - assertEquals(otherOptions.windowSeconds, undefined); + assertEquals(otherOptions.maxRequests, 5); + assertEquals(otherOptions.windowSeconds, 1); // Test that the group-specific limits are actually used assertEquals(rateLimiter.isAllowed("https://example.com/test"), true); diff --git a/src/RateLimitMiddleware.ts b/src/RateLimitMiddleware.ts index d0adc96..aec1219 100644 --- a/src/RateLimitMiddleware.ts +++ b/src/RateLimitMiddleware.ts @@ -100,8 +100,8 @@ export class RateLimitMiddleware { // Create a 429 Too Many Requests response const groupOptions = this.rateLimiter.getGroupOptions(group); - const maxRequests = groupOptions.maxRequests ?? 0; - const windowSeconds = groupOptions.windowSeconds ?? 0; + const maxRequests = groupOptions.maxRequests!; + const windowSeconds = groupOptions.windowSeconds!; // Create IETF standard rate limit headers const resetSeconds = Math.ceil((resetTime - Date.now()) / 1000); diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts index 459c28c..a14803f 100644 --- a/src/RateLimiter.ts +++ b/src/RateLimiter.ts @@ -97,9 +97,8 @@ export class RateLimiter { const now = Date.now(); // Use group-specific options if available, otherwise fall back to global options - const maxRequests = groupOptions.maxRequests ?? this.options.maxRequests; - const windowSeconds = groupOptions.windowSeconds ?? - this.options.windowSeconds; + const maxRequests = groupOptions.maxRequests ?? 0; + const windowSeconds = groupOptions.windowSeconds ?? 0; const onRateLimitExceeded = groupOptions.onRateLimitExceeded ?? this.options.onRateLimitExceeded; @@ -147,8 +146,7 @@ export class RateLimiter { } const now = Date.now(); - const windowSeconds = groupOptions.windowSeconds ?? - this.options.windowSeconds; + const windowSeconds = groupOptions.windowSeconds ?? 0; const windowStart = now - (windowSeconds * 1000); return bucket.requests.filter((time) => time > windowStart).length; } @@ -161,7 +159,7 @@ export class RateLimiter { public getRemainingRequests(url: string): number { const key = this.options.getGroupFunc(url); const groupOptions = this.getGroupOptions(key); - const maxRequests = groupOptions.maxRequests ?? this.options.maxRequests; + const maxRequests = groupOptions.maxRequests ?? 0; return Math.max( 0, @@ -199,12 +197,28 @@ export class RateLimiter { } /** - * Gets the options for a specific group. + * 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 */ public getGroupOptions(group: string): GroupRateLimiterOptions { - return this.groupOptions.get(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 + */ + public hasGroupOptions(group: string): boolean { + return this.groupOptions.has(group); } /** @@ -252,7 +266,10 @@ export class RateLimiter { * @param headers - The response headers containing rate limit information */ public updateFromHeaders(group: string, headers: Headers): void { - const currentOptions = this.getGroupOptions(group); + // Get existing group-specific options (not global fallback) + const currentOptions = this.hasGroupOptions(group) + ? this.groupOptions.get(group)! + : {}; const newOptions: GroupRateLimiterOptions = { ...currentOptions }; // Parse IETF standard rate limit headers first, then fall back to x-ratelimit headers