From 0a58db817f23a69e8c13bac2385353193965ac86 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 14 Jul 2025 11:38:56 -0500 Subject: [PATCH 1/3] Add rate limiting support --- deno.json | 8 +- deno.lock | 159 +++++++++++------------------- mod.ts | 1 + readme.md | 27 ++++++ src/DefaultHelpers.ts | 9 ++ src/FetchClient.test.ts | 6 +- src/FetchClientProvider.ts | 22 +++++ src/RateLimiter.test.ts | 175 +++++++++++++++++++++++++++++++++ src/RateLimiter.ts | 191 +++++++++++++++++++++++++++++++++++++ 9 files changed, 487 insertions(+), 111 deletions(-) create mode 100644 src/RateLimiter.test.ts create mode 100644 src/RateLimiter.ts diff --git a/deno.json b/deno.json index 25c54cd..304693c 100644 --- a/deno.json +++ b/deno.json @@ -9,10 +9,10 @@ "test": "deno test --allow-net" }, "imports": { - "@deno/dnt": "jsr:@deno/dnt@^0.41.3", - "@std/assert": "jsr:@std/assert@^1.0.10", - "@std/path": "jsr:@std/path@^1.0.8", - "zod": "npm:zod@^3.24.1" + "@deno/dnt": "jsr:@deno/dnt@^0.42.1", + "@std/assert": "jsr:@std/assert@^1.0.13", + "@std/path": "jsr:@std/path@^1.1.1", + "zod": "npm:zod@^4.0.5" }, "exclude": ["npm"] } diff --git a/deno.lock b/deno.lock index 150493c..64e2ae1 100644 --- a/deno.lock +++ b/deno.lock @@ -1,44 +1,40 @@ { - "version": "4", + "version": "5", "specifiers": { "jsr:@david/code-block-writer@^13.0.2": "13.0.3", - "jsr:@deno/cache-dir@~0.10.3": "0.10.3", - "jsr:@deno/dnt@~0.41.3": "0.41.3", - "jsr:@std/assert@0.223": "0.223.0", - "jsr:@std/assert@0.226": "0.226.0", - "jsr:@std/assert@^1.0.10": "1.0.10", - "jsr:@std/bytes@0.223": "0.223.0", - "jsr:@std/fmt@0.223": "0.223.0", - "jsr:@std/fmt@1": "1.0.3", - "jsr:@std/fs@0.223": "0.223.0", - "jsr:@std/fs@1": "1.0.6", - "jsr:@std/fs@~0.229.3": "0.229.3", - "jsr:@std/internal@^1.0.5": "1.0.5", - "jsr:@std/io@0.223": "0.223.0", - "jsr:@std/path@0.223": "0.223.0", - "jsr:@std/path@1": "1.0.8", - "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", - "jsr:@std/path@^1.0.8": "1.0.8", - "jsr:@std/path@~0.225.2": "0.225.2", - "jsr:@ts-morph/bootstrap@0.24": "0.24.0", - "jsr:@ts-morph/common@0.24": "0.24.0", - "npm:zod@^3.24.1": "3.24.1" + "jsr:@deno/cache-dir@0.20": "0.20.1", + "jsr:@deno/dnt@~0.42.1": "0.42.1", + "jsr:@std/assert@^1.0.13": "1.0.13", + "jsr:@std/bytes@^1.0.5": "1.0.6", + "jsr:@std/fmt@1": "1.0.8", + "jsr:@std/fmt@^1.0.3": "1.0.8", + "jsr:@std/fs@1": "1.0.19", + "jsr:@std/fs@^1.0.6": "1.0.19", + "jsr:@std/internal@^1.0.6": "1.0.9", + "jsr:@std/internal@^1.0.9": "1.0.9", + "jsr:@std/io@0.225": "0.225.2", + "jsr:@std/path@1": "1.1.1", + "jsr:@std/path@^1.0.8": "1.1.1", + "jsr:@std/path@^1.1.1": "1.1.1", + "jsr:@ts-morph/bootstrap@0.25": "0.25.0", + "jsr:@ts-morph/common@0.25": "0.25.0", + "npm:zod@^4.0.5": "4.0.5" }, "jsr": { "@david/code-block-writer@13.0.3": { "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" }, - "@deno/cache-dir@0.10.3": { - "integrity": "eb022f84ecc49c91d9d98131c6e6b118ff63a29e343624d058646b9d50404776", + "@deno/cache-dir@0.20.1": { + "integrity": "dc4f3add14307f3ff3b712441ea4acabcbfc9a13f67c5adc78c3aac16ac5e2a0", "dependencies": [ - "jsr:@std/fmt@0.223", - "jsr:@std/fs@0.223", + "jsr:@std/fmt@^1.0.3", + "jsr:@std/fs@^1.0.6", "jsr:@std/io", - "jsr:@std/path@0.223" + "jsr:@std/path@^1.0.8" ] }, - "@deno/dnt@0.41.3": { - "integrity": "b2ef2c8a5111eef86cb5bfcae103d6a2938e8e649e2461634a7befb7fc59d6d2", + "@deno/dnt@0.42.1": { + "integrity": "85322b38eb40d4e8c5216d62536152c35b1bda9dc47c8c60860610397b960223", "dependencies": [ "jsr:@david/code-block-writer", "jsr:@deno/cache-dir", @@ -48,110 +44,65 @@ "jsr:@ts-morph/bootstrap" ] }, - "@std/assert@0.223.0": { - "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" - }, - "@std/assert@0.226.0": { - "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" - }, - "@std/assert@1.0.10": { - "integrity": "59b5cbac5bd55459a19045d95cc7c2ff787b4f8527c0dd195078ff6f9481fbb3", + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.6" ] }, - "@std/bytes@0.223.0": { - "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" - }, - "@std/fmt@0.223.0": { - "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, - "@std/fmt@1.0.3": { - "integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f" - }, - "@std/fs@0.223.0": { - "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" - }, - "@std/fs@0.229.3": { - "integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb", - "dependencies": [ - "jsr:@std/path@1.0.0-rc.1" - ] + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" }, - "@std/fs@1.0.6": { - "integrity": "42b56e1e41b75583a21d5a37f6a6a27de9f510bcd36c0c85791d685ca0b85fa2", + "@std/fs@1.0.19": { + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", "dependencies": [ - "jsr:@std/path@^1.0.8" + "jsr:@std/internal@^1.0.9", + "jsr:@std/path@^1.1.1" ] }, - "@std/internal@1.0.5": { - "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + "@std/internal@1.0.9": { + "integrity": "bdfb97f83e4db7a13e8faab26fb1958d1b80cc64366501af78a0aee151696eb8" }, - "@std/io@0.223.0": { - "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", + "@std/io@0.225.2": { + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", "dependencies": [ - "jsr:@std/assert@0.223", "jsr:@std/bytes" ] }, - "@std/path@0.223.0": { - "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", - "dependencies": [ - "jsr:@std/assert@0.223" - ] - }, - "@std/path@0.225.2": { - "integrity": "0f2db41d36b50ef048dcb0399aac720a5348638dd3cb5bf80685bf2a745aa506", + "@std/path@1.1.1": { + "integrity": "fe00026bd3a7e6a27f73709b83c607798be40e20c81dde655ce34052fd82ec76", "dependencies": [ - "jsr:@std/assert@0.226" + "jsr:@std/internal@^1.0.9" ] }, - "@std/path@1.0.0-rc.1": { - "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" - }, - "@std/path@1.0.8": { - "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" - }, - "@ts-morph/bootstrap@0.24.0": { - "integrity": "a826a2ef7fa8a7c3f1042df2c034d20744d94da2ee32bf29275bcd4dffd3c060", + "@ts-morph/bootstrap@0.25.0": { + "integrity": "3cd33ee80ac0aab8e5d2660c639a02187f0c8abfe454636ce86c00eb7e8407db", "dependencies": [ "jsr:@ts-morph/common" ] }, - "@ts-morph/common@0.24.0": { - "integrity": "12b625b8e562446ba658cdbe9ad77774b4bd96b992ae8bd34c60dbf24d06c1f3", + "@ts-morph/common@0.25.0": { + "integrity": "e3ed1771e2fb61fbc3d2cb39ebbc4f89cd686d6d9bc6d91a71372be055ac1967", "dependencies": [ - "jsr:@std/fs@~0.229.3", - "jsr:@std/path@~0.225.2" + "jsr:@std/fs@1", + "jsr:@std/path@1" ] } }, "npm": { - "zod@3.24.1": { - "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==" + "zod@4.0.5": { + "integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==" } }, - "remote": { - "https://deno.land/x/zod@v3.23.8/ZodError.ts": "528da200fbe995157b9ae91498b103c4ef482217a5c086249507ac850bd78f52", - "https://deno.land/x/zod@v3.23.8/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", - "https://deno.land/x/zod@v3.23.8/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", - "https://deno.land/x/zod@v3.23.8/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", - "https://deno.land/x/zod@v3.23.8/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", - "https://deno.land/x/zod@v3.23.8/helpers/parseUtil.ts": "c14814d167cc286972b6e094df88d7d982572a08424b7cd50f862036b6fcaa77", - "https://deno.land/x/zod@v3.23.8/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", - "https://deno.land/x/zod@v3.23.8/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", - "https://deno.land/x/zod@v3.23.8/helpers/util.ts": "30c273131661ca5dc973f2cfb196fa23caf3a43e224cdde7a683b72e101a31fc", - "https://deno.land/x/zod@v3.23.8/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", - "https://deno.land/x/zod@v3.23.8/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", - "https://deno.land/x/zod@v3.23.8/mod.ts": "ec6e2b1255c1a350b80188f97bd0a6bac45801bb46fc48f50b9763aa66046039", - "https://deno.land/x/zod@v3.23.8/types.ts": "1b172c90782b1eaa837100ebb6abd726d79d6c1ec336350c8e851e0fd706bf5c" - }, "workspace": { "dependencies": [ - "jsr:@deno/dnt@~0.41.3", - "jsr:@std/assert@^1.0.10", - "jsr:@std/path@^1.0.8", - "npm:zod@^3.24.1" + "jsr:@deno/dnt@~0.42.1", + "jsr:@std/assert@^1.0.13", + "jsr:@std/path@^1.1.1", + "npm:zod@^4.0.5" ] } } diff --git a/mod.ts b/mod.ts index 115aa01..377bc7f 100644 --- a/mod.ts +++ b/mod.ts @@ -11,3 +11,4 @@ export { FetchClientProvider, } from "./src/FetchClientProvider.ts"; export * from "./src/DefaultHelpers.ts"; +export { type RateLimitConfig, RateLimiter } from "./src/RateLimiter.ts"; diff --git a/readme.md b/readme.md index f5301a4..57f0d57 100644 --- a/readme.md +++ b/readme.md @@ -7,6 +7,7 @@ FetchClient is a library that makes it easier to use the fetch API for JSON APIs * [Automatic model validation](#model-validator) * [Caching](#caching) * [Middleware](#middleware) +* [Rate limiting](#rate-limiting) * [Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) support * Option to parse dates in responses @@ -130,6 +131,32 @@ const response = await client.getJSON( ); ``` +### Rate Limiting + +```ts +import { FetchClientProvider } from '@exceptionless/fetchclient'; + +const provider = new FetchClientProvider(); + +// Enable rate limiting: max 100 requests per hour +provider.enableRateLimit({ + maxRequests: 100, + windowMs: 60 * 60 * 1000, // 1 hour +}); + +const client = provider.getFetchClient(); + +// Requests exceeding the limit will receive HTTP 429 responses +const response = await client.getJSON('https://api.example.com/data', { + expectedStatusCodes: [200, 429] // Handle 429 without throwing +}); + +if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + console.log(`Rate limited. Retry after ${retryAfter} seconds`); +} +``` + 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..7fec597 100644 --- a/src/DefaultHelpers.ts +++ b/src/DefaultHelpers.ts @@ -8,6 +8,7 @@ import { import type { FetchClientResponse } from "./FetchClientResponse.ts"; import type { ProblemDetails } from "./ProblemDetails.ts"; import type { GetRequestOptions, RequestOptions } from "./RequestOptions.ts"; +import type { RateLimitConfig } from "./RateLimiter.ts"; let getCurrentProviderFunc: () => FetchClientProvider | null = () => null; @@ -164,3 +165,11 @@ export function useMiddleware(middleware: FetchClientMiddleware) { export function setRequestOptions(options: RequestOptions) { getCurrentProvider().applyOptions({ defaultRequestOptions: options }); } + +/** + * Enables rate limiting for the current provider. + * @param config - The rate limit configuration. + */ +export function enableRateLimit(config: RateLimitConfig) { + getCurrentProvider().enableRateLimit(config); +} diff --git a/src/FetchClient.test.ts b/src/FetchClient.test.ts index 67fa994..b8340bb 100644 --- a/src/FetchClient.test.ts +++ b/src/FetchClient.test.ts @@ -8,14 +8,14 @@ import { useFetchClient, } from "../mod.ts"; import { FetchClientProvider } from "./FetchClientProvider.ts"; -import { z, type ZodTypeAny } from "zod"; +import * as z from "zod/mini"; export const TodoSchema = z.object({ userId: z.number(), id: z.number(), title: z.string(), completed: z.boolean(), - completedTime: z.coerce.date().optional(), + completedTime: z.optional(z.coerce.date()), }); type Todo = z.infer; @@ -614,7 +614,7 @@ Deno.test("can getJSON with zod schema", async () => { await next(); if (ctx.options.schema) { - const schema = ctx.options.schema as ZodTypeAny; + const schema = ctx.options.schema as z.ZodMiniType; const parsed = schema.safeParse(ctx.response!.data); if (parsed.success) { diff --git a/src/FetchClientProvider.ts b/src/FetchClientProvider.ts index 5c35523..75e775d 100644 --- a/src/FetchClientProvider.ts +++ b/src/FetchClientProvider.ts @@ -5,6 +5,11 @@ 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 RateLimitConfig, + RateLimiter, +} from "./RateLimiter.ts"; type Fetch = typeof globalThis.fetch; @@ -17,6 +22,7 @@ export class FetchClientProvider { #cache: FetchClientCache; #counter = new Counter(); #onLoading = new ObjectEvent(); + #rateLimiter?: RateLimiter; /** * Creates a new instance of FetchClientProvider. @@ -187,6 +193,22 @@ export class FetchClientProvider { ], }; } + + /** + * Enables rate limiting for all FetchClient instances created by this provider. + * @param config - The rate limit configuration. + */ + public enableRateLimit(config: RateLimitConfig) { + this.#rateLimiter = new RateLimiter(config); + this.useMiddleware(createRateLimitMiddleware(this.#rateLimiter)); + } + + /** + * Gets the current rate limiter instance, if rate limiting is enabled. + */ + public get rateLimiter(): RateLimiter | undefined { + return this.#rateLimiter; + } } const provider = new FetchClientProvider(); diff --git a/src/RateLimiter.test.ts b/src/RateLimiter.test.ts new file mode 100644 index 0000000..1138b55 --- /dev/null +++ b/src/RateLimiter.test.ts @@ -0,0 +1,175 @@ +import { assertEquals } from "jsr:@std/assert"; +import { + createRateLimitMiddleware, + type RateLimitConfig, + RateLimiter, +} from "./RateLimiter.ts"; +import type { FetchClientContext } from "./FetchClientContext.ts"; +import type { RequestOptions } from "./RequestOptions.ts"; +import { FetchClientProvider } from "./FetchClientProvider.ts"; + +Deno.test("RateLimiter - basic functionality", () => { + const config: RateLimitConfig = { + maxRequests: 3, + windowMs: 1000, + }; + const rateLimiter = new RateLimiter(config); + + // Should be able to make requests up to the limit + assertEquals(rateLimiter.canMakeRequest(), true); + assertEquals(rateLimiter.getCurrentRequestCount(), 0); + assertEquals(rateLimiter.getRemainingRequests(), 3); + + // Record first request + assertEquals(rateLimiter.recordRequest(), true); + assertEquals(rateLimiter.getCurrentRequestCount(), 1); + assertEquals(rateLimiter.getRemainingRequests(), 2); + + // Record second request + assertEquals(rateLimiter.recordRequest(), true); + assertEquals(rateLimiter.getCurrentRequestCount(), 2); + assertEquals(rateLimiter.getRemainingRequests(), 1); + + // Record third request + assertEquals(rateLimiter.recordRequest(), true); + assertEquals(rateLimiter.getCurrentRequestCount(), 3); + assertEquals(rateLimiter.getRemainingRequests(), 0); + + // Should not be able to make more requests + assertEquals(rateLimiter.canMakeRequest(), false); + + // Fourth request should return false + assertEquals(rateLimiter.recordRequest(), false); +}); + +Deno.test("RateLimiter - time window expiry", async () => { + const config: RateLimitConfig = { + maxRequests: 2, + windowMs: 100, // Short window for testing + }; + const rateLimiter = new RateLimiter(config); + + // Fill up the rate limit + assertEquals(rateLimiter.recordRequest(), true); + assertEquals(rateLimiter.recordRequest(), true); + assertEquals(rateLimiter.canMakeRequest(), false); + + // Wait for the window to expire + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Should be able to make requests again + assertEquals(rateLimiter.canMakeRequest(), true); + assertEquals(rateLimiter.getCurrentRequestCount(), 0); +}); + +Deno.test("RateLimitMiddleware - creates 429 response", async () => { + const config: RateLimitConfig = { + maxRequests: 1, + windowMs: 1000, + }; + const rateLimiter = new RateLimiter(config); + const middleware = createRateLimitMiddleware(rateLimiter); + + let nextCalled = 0; + const mockNext = () => { + nextCalled++; + return Promise.resolve(); + }; + + const mockContext: FetchClientContext = { + options: {} as RequestOptions, + request: new Request("https://example.com"), + response: null, + }; + + // First request should go through + await middleware(mockContext, mockNext); + assertEquals(nextCalled, 1); + + // Second request should create a 429 response + await middleware(mockContext, mockNext); + assertEquals(nextCalled, 1); // next should not have been called again + assertEquals(mockContext.response?.status, 429); + assertEquals(mockContext.response?.ok, false); + assertEquals(mockContext.response?.problem?.title, "Too Many Requests"); +}); + +Deno.test("Rate limiting integration with real HTTP requests", async () => { + const provider = new FetchClientProvider(); + + // Enable strict rate limiting: 2 requests per 5 seconds + provider.enableRateLimit({ + maxRequests: 2, + windowMs: 5000, + }); + + const client = provider.getFetchClient(); + + // First two requests should succeed + const response1 = await client.getJSON( + "https://jsonplaceholder.typicode.com/posts/1", + { + expectedStatusCodes: [200, 429], + }, + ); + const response2 = await client.getJSON( + "https://jsonplaceholder.typicode.com/posts/2", + { + expectedStatusCodes: [200, 429], + }, + ); + + assertEquals(response1.ok, true); + assertEquals(response2.ok, true); + + // Third request should get a 429 response + const response3 = await client.getJSON( + "https://jsonplaceholder.typicode.com/posts/3", + { + expectedStatusCodes: [200, 429], + }, + ); + assertEquals(response3.ok, false); + assertEquals(response3.status, 429); + assertEquals(response3.problem?.title, "Too Many Requests"); +}); + +Deno.test("Default provider rate limiting", async () => { + // Create a fresh provider for this test to avoid interference + const provider = new FetchClientProvider(); + + // Test the provider rate limiting functions + provider.enableRateLimit({ + maxRequests: 2, + windowMs: 1000, + }); + + const client = provider.getFetchClient(); + + // First two requests should succeed + const response1 = await client.getJSON( + "https://jsonplaceholder.typicode.com/posts/1", + { + expectedStatusCodes: [200, 429], + }, + ); + const response2 = await client.getJSON( + "https://jsonplaceholder.typicode.com/posts/2", + { + expectedStatusCodes: [200, 429], + }, + ); + + assertEquals(response1.ok, true); + assertEquals(response2.ok, true); + + // Third request should get a 429 response + const response3 = await client.getJSON( + "https://jsonplaceholder.typicode.com/posts/3", + { + expectedStatusCodes: [200, 429], + }, + ); + assertEquals(response3.ok, false); + assertEquals(response3.status, 429); +}); diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts new file mode 100644 index 0000000..74a2972 --- /dev/null +++ b/src/RateLimiter.ts @@ -0,0 +1,191 @@ +import type { FetchClientContext } from "./FetchClientContext.ts"; +import type { FetchClientMiddleware } from "./FetchClientMiddleware.ts"; +import type { FetchClientResponse } from "./FetchClientResponse.ts"; +import { ProblemDetails } from "./ProblemDetails.ts"; + +const RATE_LIMITER_CONTEXT_KEY = "__rateLimiter__"; + +/** + * Creates a rate limiting middleware that controls the rate of requests. + * @param rateLimiter - The rate limiter instance to use. + * @returns A middleware function that enforces rate limiting. + */ +export function createRateLimitMiddleware( + rateLimiter: RateLimiter, +): FetchClientMiddleware { + return async (context: FetchClientContext, next: () => Promise) => { + // Store the rate limiter in context for potential access by other middleware + context[RATE_LIMITER_CONTEXT_KEY] = rateLimiter; + + // Try to record the request - returns false if rate limit is exceeded + const canProceed = rateLimiter.recordRequest(); + + if (!canProceed) { + // Rate limit exceeded - create a 429 response + const problem = new ProblemDetails(); + problem.type = "https://tools.ietf.org/html/rfc6585#section-4"; + problem.title = "Too Many Requests"; + problem.status = 429; + problem.detail = + `Rate limit exceeded: ${rateLimiter.configuration.maxRequests} requests per ${rateLimiter.configuration.windowMs}ms`; + problem.instance = context.request.url; + + const headers = new Headers(); + headers.set("Content-Type", "application/problem+json"); + headers.set( + "Retry-After", + Math.ceil(rateLimiter.getTimeUntilNextRequest() / 1000).toString(), + ); + + context.response = { + url: context.request.url, + status: 429, + statusText: "Too Many Requests", + body: null, + bodyUsed: true, + ok: false, + headers: headers, + redirected: false, + problem: problem, + data: null, + meta: { links: {} }, + type: "basic", + 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; + } else { + // Rate limit not exceeded - continue with the request + await next(); + } + }; +} + +/** + * Gets the rate limiter from the context if available. + * @param context - The fetch client context. + * @returns The rate limiter instance or null if not available. + */ +export function getRateLimiterFromContext( + context: FetchClientContext, +): RateLimiter | null { + return (context[RATE_LIMITER_CONTEXT_KEY] as RateLimiter) || null; +} + +/** + * Configuration options for rate limiting. + */ +export interface RateLimitConfig { + /** Maximum number of requests allowed per time window */ + maxRequests: number; + /** Time window in milliseconds */ + windowMs: number; +} + +/** + * Represents a rate limiter that controls the rate of requests. + */ +export class RateLimiter { + private readonly timestamps: number[]; + private head = 0; + private count = 0; + private readonly config: RateLimitConfig; + + constructor(config: RateLimitConfig) { + this.config = config; + this.timestamps = new Array(config.maxRequests); + } + + /** + * Gets the current rate limit configuration. + */ + public get configuration(): RateLimitConfig { + return { ...this.config }; + } + + /** + * Checks if a request can be made without exceeding the rate limit. + * @returns true if the request can be made, false otherwise. + */ + public canMakeRequest(): boolean { + this.cleanup(); + return this.count < this.config.maxRequests; + } + + /** + * Records a request if within rate limits. + * @returns true if the request was recorded, false if rate limit exceeded. + */ + public recordRequest(): boolean { + this.cleanup(); + + if (this.count >= this.config.maxRequests) { + return false; + } + + const now = Date.now(); + const index = (this.head + this.count) % this.config.maxRequests; + this.timestamps[index] = now; + this.count++; + + return true; + } + + /** + * Gets the number of requests made in the current time window. + */ + public getCurrentRequestCount(): number { + this.cleanup(); + return this.count; + } + + /** + * Gets the remaining number of requests that can be made in the current time window. + */ + public getRemainingRequests(): number { + return Math.max(0, this.config.maxRequests - this.getCurrentRequestCount()); + } + + /** + * Gets the time until the next request can be made (in milliseconds). + */ + public getTimeUntilNextRequest(): number { + this.cleanup(); + + if (this.count < this.config.maxRequests) { + return 0; + } + + const oldestRequest = this.timestamps[this.head]; + const timeUntilExpiry = (oldestRequest + this.config.windowMs) - Date.now(); + return Math.max(0, timeUntilExpiry); + } + + /** + * Resets the rate limiter state. + */ + public reset(): void { + this.head = 0; + this.count = 0; + } + + private cleanup(): void { + if (this.count === 0) return; + + const now = Date.now(); + const cutoff = now - this.config.windowMs; + + // Remove expired requests from the front of the circular buffer + while (this.count > 0 && this.timestamps[this.head] <= cutoff) { + this.head = (this.head + 1) % this.config.maxRequests; + this.count--; + } + } +} From 84cd0456686ea35ca484e94269170b3a97801b5e Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 14 Jul 2025 12:19:56 -0500 Subject: [PATCH 2/3] Fix build --- .github/copilot-instructions.md | 77 +++++++++++++++++++++++++++++++++ deno.lock | 1 + scripts/build.ts | 2 - 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..700d9d3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,77 @@ +# FetchClient - Copilot Instructions + +## Project Architecture + +This is a **Deno-first TypeScript library** that provides a typed HTTP client with middleware support. The project builds to NPM for Node.js compatibility via `@deno/dnt`. + +### Core Components + +- **`FetchClient`** - Main HTTP client class with typed JSON methods (`getJSON`, `postJSON`, etc.) +- **`FetchClientProvider`** - Singleton pattern for shared configuration, caching, and rate limiting across multiple client instances +- **Middleware System** - Pipeline architecture using `FetchClientContext` and `next()` functions +- **Rate Limiting** - Built-in middleware for HTTP 429 responses with `Retry-After` headers +- **Caching** - Key-based response caching with TTL support +- **Problem Details** - RFC 9457 compliant error handling + +### Key Patterns + +**Provider Pattern**: Use `FetchClientProvider` for shared state: +```typescript +const provider = new FetchClientProvider(); +provider.enableRateLimit({ maxRequests: 100, windowMs: 60000 }); +const client = provider.getFetchClient(); +``` + +**Middleware Chain**: Always call `next()`, modify `context.response` after: +```typescript +provider.useMiddleware(async (ctx, next) => { + // pre-processing + await next(); + // post-processing with ctx.response +}); +``` + +**Global Helpers**: Default provider instance accessible via `useFetchClient()`, `getJSON()`, `setBaseUrl()` + +## Development Workflow + +### Essential Commands +- `deno task test` - Run tests with network access +- `deno task build` - Generate NPM package in `./npm/` +- `deno task check` - Type check all TypeScript files +- `deno lint` and `deno fmt` - Linting and formatting + +### Testing Patterns +- Use `FetchClientProvider` with `fakeFetch` for mocking +- Rate limiting tests require `await delay()` for time windows +- Cache tests use `provider.cache.has()` and `provider.cache.delete()` for assertions +- Middleware tests check `ctx.response` before/after `next()` + +### File Organization +- `src/` - Core TypeScript source +- `mod.ts` - Main export file +- `scripts/build.ts` - Deno-to-NPM build configuration +- Tests are co-located (`.test.ts` suffix) + +## Critical Implementation Details + +**Context Mutation**: Middleware modifies `FetchClientContext` in-place. The `response` property is null before `next()` and populated after. + +**Error Handling**: Uses `expectedStatusCodes` array instead of try/catch for HTTP errors. `errorCallback` can suppress throwing. + +**Cache Keys**: Use array format `["resource", "id"]` for hierarchical cache invalidation. + +**Rate Limiting**: Middleware returns HTTP 429 responses instead of throwing errors. Check `response.status === 429` for rate limit handling. + +**Schema Validation**: Use `meta: { schema: ZodSchema }` option with middleware for runtime validation. + +**Date Parsing**: Enable with `shouldParseDates: true` option or custom `reviver` function. + +## Integration Points + +- **Zod**: Runtime schema validation via middleware +- **Problem Details**: Standard error format for HTTP APIs +- **AbortController**: Native timeout and cancellation support +- **Headers**: Link header parsing for pagination + +When working on this codebase, always consider the middleware pipeline order and the provider/client distinction for shared vs. instance-specific behavior. diff --git a/deno.lock b/deno.lock index 64e2ae1..440f7f3 100644 --- a/deno.lock +++ b/deno.lock @@ -4,6 +4,7 @@ "jsr:@david/code-block-writer@^13.0.2": "13.0.3", "jsr:@deno/cache-dir@0.20": "0.20.1", "jsr:@deno/dnt@~0.42.1": "0.42.1", + "jsr:@std/assert@*": "1.0.13", "jsr:@std/assert@^1.0.13": "1.0.13", "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/fmt@1": "1.0.8", diff --git a/scripts/build.ts b/scripts/build.ts index ea9eed0..d74fbba 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -14,8 +14,6 @@ await build({ typeCheck: false, test: true, - importMap: "deno.json", - package: { name: "@exceptionless/fetchclient", version: Deno.args[0], From efc1744a640b38e8c6df52afef7626c231375996 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 14 Jul 2025 12:21:34 -0500 Subject: [PATCH 3/3] Fix formatting --- .github/copilot-instructions.md | 48 +++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 700d9d3..41358d2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,20 +2,27 @@ ## Project Architecture -This is a **Deno-first TypeScript library** that provides a typed HTTP client with middleware support. The project builds to NPM for Node.js compatibility via `@deno/dnt`. +This is a **Deno-first TypeScript library** that provides a typed HTTP client +with middleware support. The project builds to NPM for Node.js compatibility via +`@deno/dnt`. ### Core Components -- **`FetchClient`** - Main HTTP client class with typed JSON methods (`getJSON`, `postJSON`, etc.) -- **`FetchClientProvider`** - Singleton pattern for shared configuration, caching, and rate limiting across multiple client instances -- **Middleware System** - Pipeline architecture using `FetchClientContext` and `next()` functions -- **Rate Limiting** - Built-in middleware for HTTP 429 responses with `Retry-After` headers +- **`FetchClient`** - Main HTTP client class with typed JSON methods (`getJSON`, + `postJSON`, etc.) +- **`FetchClientProvider`** - Singleton pattern for shared configuration, + caching, and rate limiting across multiple client instances +- **Middleware System** - Pipeline architecture using `FetchClientContext` and + `next()` functions +- **Rate Limiting** - Built-in middleware for HTTP 429 responses with + `Retry-After` headers - **Caching** - Key-based response caching with TTL support - **Problem Details** - RFC 9457 compliant error handling ### Key Patterns **Provider Pattern**: Use `FetchClientProvider` for shared state: + ```typescript const provider = new FetchClientProvider(); provider.enableRateLimit({ maxRequests: 100, windowMs: 60000 }); @@ -23,6 +30,7 @@ const client = provider.getFetchClient(); ``` **Middleware Chain**: Always call `next()`, modify `context.response` after: + ```typescript provider.useMiddleware(async (ctx, next) => { // pre-processing @@ -31,23 +39,28 @@ provider.useMiddleware(async (ctx, next) => { }); ``` -**Global Helpers**: Default provider instance accessible via `useFetchClient()`, `getJSON()`, `setBaseUrl()` +**Global Helpers**: Default provider instance accessible via `useFetchClient()`, +`getJSON()`, `setBaseUrl()` ## Development Workflow ### Essential Commands + - `deno task test` - Run tests with network access - `deno task build` - Generate NPM package in `./npm/` - `deno task check` - Type check all TypeScript files - `deno lint` and `deno fmt` - Linting and formatting ### Testing Patterns + - Use `FetchClientProvider` with `fakeFetch` for mocking - Rate limiting tests require `await delay()` for time windows -- Cache tests use `provider.cache.has()` and `provider.cache.delete()` for assertions +- Cache tests use `provider.cache.has()` and `provider.cache.delete()` for + assertions - Middleware tests check `ctx.response` before/after `next()` ### File Organization + - `src/` - Core TypeScript source - `mod.ts` - Main export file - `scripts/build.ts` - Deno-to-NPM build configuration @@ -55,17 +68,23 @@ provider.useMiddleware(async (ctx, next) => { ## Critical Implementation Details -**Context Mutation**: Middleware modifies `FetchClientContext` in-place. The `response` property is null before `next()` and populated after. +**Context Mutation**: Middleware modifies `FetchClientContext` in-place. The +`response` property is null before `next()` and populated after. -**Error Handling**: Uses `expectedStatusCodes` array instead of try/catch for HTTP errors. `errorCallback` can suppress throwing. +**Error Handling**: Uses `expectedStatusCodes` array instead of try/catch for +HTTP errors. `errorCallback` can suppress throwing. -**Cache Keys**: Use array format `["resource", "id"]` for hierarchical cache invalidation. +**Cache Keys**: Use array format `["resource", "id"]` for hierarchical cache +invalidation. -**Rate Limiting**: Middleware returns HTTP 429 responses instead of throwing errors. Check `response.status === 429` for rate limit handling. +**Rate Limiting**: Middleware returns HTTP 429 responses instead of throwing +errors. Check `response.status === 429` for rate limit handling. -**Schema Validation**: Use `meta: { schema: ZodSchema }` option with middleware for runtime validation. +**Schema Validation**: Use `meta: { schema: ZodSchema }` option with middleware +for runtime validation. -**Date Parsing**: Enable with `shouldParseDates: true` option or custom `reviver` function. +**Date Parsing**: Enable with `shouldParseDates: true` option or custom +`reviver` function. ## Integration Points @@ -74,4 +93,5 @@ provider.useMiddleware(async (ctx, next) => { - **AbortController**: Native timeout and cancellation support - **Headers**: Link header parsing for pagination -When working on this codebase, always consider the middleware pipeline order and the provider/client distinction for shared vs. instance-specific behavior. +When working on this codebase, always consider the middleware pipeline order and +the provider/client distinction for shared vs. instance-specific behavior.