Skip to content

Commit aa6fac8

Browse files
committed
More progress
1 parent d491edc commit aa6fac8

File tree

6 files changed

+274
-60
lines changed

6 files changed

+274
-60
lines changed

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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "../mod.ts";
1717
import { FetchClientProvider } from "./FetchClientProvider.ts";
1818
import { z, type ZodTypeAny } from "zod";
19+
import { buildRateLimitHeader } from "./RateLimiter.ts";
1920

2021
export const TodoSchema = z.object({
2122
userId: z.number(),
@@ -970,6 +971,123 @@ Deno.test("handles 400 response with non-JSON text", async () => {
970971
);
971972
});
972973

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

0 commit comments

Comments
 (0)