Skip to content

Commit 1f0679d

Browse files
authored
Merge pull request #3 from exceptionless/rate-limit2
Add rate limiting support
2 parents 6811f3a + 666c76e commit 1f0679d

File tree

9 files changed

+1418
-8
lines changed

9 files changed

+1418
-8
lines changed

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"--inspect-wait",
2929
"--allow-all",
3030
"--filter",
31-
"handles 400 response with non-JSON text"
31+
"can use per-domain rate limiting with auto-update from headers"
3232
],
3333
"attachSimplePort": 9229
3434
}

.vscode/tasks.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
"problemMatcher": [
2020
"$deno"
2121
],
22+
"group": {
23+
"kind": "build",
24+
"isDefault": true
25+
},
2226
"label": "deno: check",
2327
"detail": "$ deno check scripts/*.ts *.ts src/*.ts"
2428
},
@@ -43,7 +47,10 @@
4347
"problemMatcher": [
4448
"$deno-test"
4549
],
46-
"group": "test",
50+
"group": {
51+
"kind": "test",
52+
"isDefault": true
53+
},
4754
"label": "deno: test"
4855
}
4956
]

readme.md

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33

44
FetchClient is a library that makes it easier to use the fetch API for JSON APIs. It provides the following features:
55

6-
* [Makes fetch easier to use for JSON APIs](#typed-response)
7-
* [Automatic model validation](#model-validator)
8-
* [Caching](#caching)
9-
* [Middleware](#middleware)
10-
* [Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) support
11-
* Option to parse dates in responses
6+
- [FetchClient ](#fetchclient---)
7+
- [Install](#install)
8+
- [Docs](#docs)
9+
- [Usage](#usage)
10+
- [Typed Response](#typed-response)
11+
- [Typed Response Using a Function](#typed-response-using-a-function)
12+
- [Model Validator](#model-validator)
13+
- [Caching](#caching)
14+
- [Middleware](#middleware)
15+
- [Rate Limiting](#rate-limiting)
16+
- [Contributing](#contributing)
17+
- [License](#license)
1218

1319
## Install
1420

@@ -130,6 +136,23 @@ const response = await client.getJSON<Products>(
130136
);
131137
```
132138

139+
### Rate Limiting
140+
141+
```ts
142+
import { FetchClient, useRateLimit } from '@exceptionless/fetchclient';
143+
144+
// Enable rate limiting globally with 100 requests per minute
145+
useRateLimit({
146+
maxRequests: 100,
147+
windowSeconds: 60,
148+
});
149+
150+
const client = new FetchClient();
151+
const response = await client.getJSON(
152+
`https://api.example.com/data`,
153+
);
154+
```
155+
133156
Also, take a look at the tests:
134157

135158
[FetchClient Tests](src/FetchClient.test.ts)

src/DefaultHelpers.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "./FetchClientProvider.ts";
88
import type { FetchClientResponse } from "./FetchClientResponse.ts";
99
import type { ProblemDetails } from "./ProblemDetails.ts";
10+
import type { RateLimitMiddlewareOptions } from "./RateLimitMiddleware.ts";
1011
import type { GetRequestOptions, RequestOptions } from "./RequestOptions.ts";
1112

1213
let getCurrentProviderFunc: () => FetchClientProvider | null = () => null;
@@ -164,3 +165,23 @@ export function useMiddleware(middleware: FetchClientMiddleware) {
164165
export function setRequestOptions(options: RequestOptions) {
165166
getCurrentProvider().applyOptions({ defaultRequestOptions: options });
166167
}
168+
169+
/**
170+
* Enables rate limiting for any FetchClient instances created by the current provider.
171+
* @param options - The rate limiting configuration options.
172+
*/
173+
export function useRateLimit(
174+
options: RateLimitMiddlewareOptions,
175+
) {
176+
getCurrentProvider().useRateLimit(options);
177+
}
178+
179+
/**
180+
* Enables per-domain rate limiting for any FetchClient instances created by the current provider.
181+
* @param options - The rate limiting configuration options.
182+
*/
183+
export function usePerDomainRateLimit(
184+
options: Omit<RateLimitMiddlewareOptions, "getGroupFunc">,
185+
) {
186+
getCurrentProvider().usePerDomainRateLimit(options);
187+
}

src/FetchClient.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import {
1616
} from "../mod.ts";
1717
import { FetchClientProvider } from "./FetchClientProvider.ts";
1818
import { z, type ZodTypeAny } from "zod";
19+
import {
20+
buildRateLimitHeader,
21+
buildRateLimitPolicyHeader,
22+
} from "./RateLimiter.ts";
1923

2024
export const TodoSchema = z.object({
2125
userId: z.number(),
@@ -970,6 +974,122 @@ Deno.test("handles 400 response with non-JSON text", async () => {
970974
);
971975
});
972976

977+
Deno.test("can use per-domain rate limiting with auto-update from headers", async () => {
978+
const provider = new FetchClientProvider();
979+
980+
const groupTracker = new Map<string, number>();
981+
982+
const startTime = Date.now();
983+
984+
groupTracker.set("api.example.com", 100);
985+
groupTracker.set("slow-api.example.com", 5);
986+
987+
provider.usePerDomainRateLimit({
988+
maxRequests: 50, // Default limit
989+
windowSeconds: 60, // 1 minute default window
990+
autoUpdateFromHeaders: true,
991+
groups: {
992+
"api.example.com": {
993+
maxRequests: 75, // API will override this with headers
994+
windowSeconds: 60,
995+
},
996+
"slow-api.example.com": {
997+
maxRequests: 30, // API will override this with headers
998+
windowSeconds: 30,
999+
},
1000+
},
1001+
});
1002+
1003+
provider.fetch = (
1004+
input: RequestInfo | URL,
1005+
_init?: RequestInit,
1006+
): Promise<Response> => {
1007+
let url: URL;
1008+
if (input instanceof Request) {
1009+
url = new URL(input.url);
1010+
} else {
1011+
url = new URL(input.toString());
1012+
}
1013+
1014+
const headers = new Headers({
1015+
"Content-Type": "application/json",
1016+
});
1017+
1018+
// Simulate different rate limits for different domains
1019+
if (url.hostname === "api.example.com") {
1020+
headers.set("X-RateLimit-Limit", "100");
1021+
let remaining = groupTracker.get("api.example.com") ?? 0;
1022+
remaining = remaining > 0 ? remaining - 2 : 0;
1023+
groupTracker.set("api.example.com", remaining);
1024+
headers.set("X-RateLimit-Remaining", String(remaining));
1025+
} else if (url.hostname === "slow-api.example.com") {
1026+
let remaining = groupTracker.get("slow-api.example.com") ?? 0;
1027+
remaining = remaining > 0 ? remaining - 2 : 0;
1028+
groupTracker.set("slow-api.example.com", remaining);
1029+
1030+
headers.set(
1031+
"RateLimit-Policy",
1032+
buildRateLimitPolicyHeader({
1033+
policy: "slow-api.example.com",
1034+
limit: 5,
1035+
windowSeconds: 30,
1036+
}),
1037+
);
1038+
headers.set(
1039+
"RateLimit",
1040+
buildRateLimitHeader({
1041+
policy: "slow-api.example.com",
1042+
remaining: remaining,
1043+
resetSeconds: 30 - ((Date.now() - startTime) / 1000),
1044+
}),
1045+
);
1046+
}
1047+
// other-api.example.com gets no rate limit headers
1048+
1049+
return Promise.resolve(
1050+
new Response(JSON.stringify({ success: true }), {
1051+
status: 200,
1052+
statusText: "OK",
1053+
headers,
1054+
}),
1055+
);
1056+
};
1057+
1058+
assert(provider.rateLimiter);
1059+
1060+
const client = provider.getFetchClient();
1061+
1062+
// check API rate limit
1063+
let apiOptions = provider.rateLimiter.getGroupOptions("api.example.com");
1064+
assertEquals(apiOptions.maxRequests, 75);
1065+
assertEquals(apiOptions.windowSeconds, 60);
1066+
1067+
const response1 = await client.getJSON(
1068+
"https://api.example.com/data",
1069+
);
1070+
assertEquals(response1.status, 200);
1071+
1072+
apiOptions = provider.rateLimiter.getGroupOptions("api.example.com");
1073+
assertEquals(apiOptions.maxRequests, 100); // Updated from headers
1074+
1075+
// check slow API rate limit
1076+
let slowApiOptions = provider.rateLimiter.getGroupOptions(
1077+
"slow-api.example.com",
1078+
);
1079+
assertEquals(slowApiOptions.maxRequests, 30);
1080+
assertEquals(slowApiOptions.windowSeconds, 30);
1081+
1082+
const response2 = await client.getJSON(
1083+
"https://slow-api.example.com/data",
1084+
);
1085+
assertEquals(response2.status, 200);
1086+
1087+
slowApiOptions = provider.rateLimiter.getGroupOptions(
1088+
"slow-api.example.com",
1089+
);
1090+
assertEquals(slowApiOptions.maxRequests, 5); // Updated from headers
1091+
});
1092+
9731093
function delay(time: number): Promise<void> {
9741094
return new Promise((resolve) => setTimeout(resolve, time));
9751095
}

src/FetchClientProvider.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type { ProblemDetails } from "./ProblemDetails.ts";
55
import { FetchClientCache } from "./FetchClientCache.ts";
66
import type { FetchClientOptions } from "./FetchClientOptions.ts";
77
import { type IObjectEvent, ObjectEvent } from "./ObjectEvent.ts";
8+
import {
9+
RateLimitMiddleware,
10+
type RateLimitMiddlewareOptions,
11+
} from "./RateLimitMiddleware.ts";
12+
import { groupByDomain, type RateLimiter } from "./RateLimiter.ts";
813

914
type Fetch = typeof globalThis.fetch;
1015

@@ -15,6 +20,7 @@ export class FetchClientProvider {
1520
#options: FetchClientOptions = {};
1621
#fetch?: Fetch;
1722
#cache: FetchClientCache;
23+
#rateLimitMiddleware?: RateLimitMiddleware;
1824
#counter = new Counter();
1925
#onLoading = new ObjectEvent<boolean>();
2026

@@ -187,6 +193,47 @@ export class FetchClientProvider {
187193
],
188194
};
189195
}
196+
197+
/**
198+
* Enables rate limiting for all FetchClient instances created by this provider.
199+
* @param options - The rate limiting configuration options.
200+
*/
201+
public useRateLimit(options: RateLimitMiddlewareOptions) {
202+
this.#rateLimitMiddleware = new RateLimitMiddleware(options);
203+
this.useMiddleware(this.#rateLimitMiddleware.middleware());
204+
}
205+
206+
/**
207+
* Enables rate limiting for all FetchClient instances created by this provider.
208+
* @param options - The rate limiting configuration options.
209+
*/
210+
public usePerDomainRateLimit(
211+
options: Omit<RateLimitMiddlewareOptions, "getGroupFunc">,
212+
) {
213+
this.#rateLimitMiddleware = new RateLimitMiddleware({
214+
...options,
215+
getGroupFunc: groupByDomain,
216+
});
217+
this.useMiddleware(this.#rateLimitMiddleware.middleware());
218+
}
219+
220+
/**
221+
* Gets the rate limiter instance used for rate limiting.
222+
* @returns The rate limiter instance, or undefined if rate limiting is not enabled.
223+
*/
224+
public get rateLimiter(): RateLimiter | undefined {
225+
return this.#rateLimitMiddleware?.rateLimiter;
226+
}
227+
228+
/**
229+
* Removes the rate limiting middleware from all FetchClient instances created by this provider.
230+
*/
231+
public removeRateLimit() {
232+
this.#rateLimitMiddleware = undefined;
233+
this.#options.middleware = this.#options.middleware?.filter(
234+
(m) => !(m instanceof RateLimitMiddleware),
235+
);
236+
}
190237
}
191238

192239
const provider = new FetchClientProvider();

0 commit comments

Comments
 (0)