Skip to content

Add rate limiting support #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 18, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"--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
}
Expand Down
9 changes: 8 additions & 1 deletion .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"problemMatcher": [
"$deno"
],
"group": {
"kind": "build",
"isDefault": true
},
"label": "deno: check",
"detail": "$ deno check scripts/*.ts *.ts src/*.ts"
},
Expand All @@ -43,7 +47,10 @@
"problemMatcher": [
"$deno-test"
],
"group": "test",
"group": {
"kind": "test",
"isDefault": true
},
"label": "deno: test"
}
]
Expand Down
35 changes: 29 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -130,6 +136,23 @@ const response = await client.getJSON<Products>(
);
```

### 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)
Expand Down
21 changes: 21 additions & 0 deletions src/DefaultHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<RateLimitMiddlewareOptions, "getGroupFunc">,
) {
getCurrentProvider().usePerDomainRateLimit(options);
}
120 changes: 120 additions & 0 deletions src/FetchClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
} from "../mod.ts";
import { FetchClientProvider } from "./FetchClientProvider.ts";
import { z, type ZodTypeAny } from "zod";
import {
buildRateLimitHeader,
buildRateLimitPolicyHeader,
} from "./RateLimiter.ts";

export const TodoSchema = z.object({
userId: z.number(),
Expand Down Expand Up @@ -970,6 +974,122 @@ 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<string, number>();

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: 75, // API will override this with headers
windowSeconds: 60,
},
"slow-api.example.com": {
maxRequests: 30, // API will override this with headers
windowSeconds: 30,
},
},
});

provider.fetch = (
input: RequestInfo | URL,
_init?: RequestInit,
): Promise<Response> => {
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 - 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 - 2 : 0;
groupTracker.set("slow-api.example.com", remaining);

headers.set(
"RateLimit-Policy",
buildRateLimitPolicyHeader({
policy: "slow-api.example.com",
limit: 5,
windowSeconds: 30,
}),
);
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,
}),
);
};

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);

slowApiOptions = provider.rateLimiter.getGroupOptions(
"slow-api.example.com",
);
assertEquals(slowApiOptions.maxRequests, 5); // Updated from headers
});

function delay(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
47 changes: 47 additions & 0 deletions src/FetchClientProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
RateLimitMiddleware,
type RateLimitMiddlewareOptions,
} from "./RateLimitMiddleware.ts";
import { groupByDomain, type RateLimiter } from "./RateLimiter.ts";

type Fetch = typeof globalThis.fetch;

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

Expand Down Expand Up @@ -187,6 +193,47 @@ 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.#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<RateLimitMiddlewareOptions, "getGroupFunc">,
) {
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),
);
}
}

const provider = new FetchClientProvider();
Expand Down
Loading
Loading