-
Notifications
You must be signed in to change notification settings - Fork 3
add a global rate limiter for WFS requests to avoid overloading upstream services #111
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
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
4161ef9
feat(rate-limit): add RateLimiter helper (refs #94)
mborne b616316
feat: add rate limit for the WFS requests (refs #94)
mborne a89ed8c
typo fix
esgn b72f228
fix(tests): clarify test descriptions for RateLimiter behavior
esgn 772e7a1
Merge branch 'master' into 94-rate-limiting
esgn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| /** | ||
| * Parameters for the RateLimiter class inspired by https://pypi.org/project/ratelimiter/ | ||
| */ | ||
| interface RateLimiterOptions { | ||
| /** | ||
| * The name of the rate limiter, used for error reporting and debugging purposes. | ||
| */ | ||
| name: string; | ||
| /** | ||
| * The maximum number of calls allowed during one period. | ||
| */ | ||
| maxCalls: number; | ||
| /** | ||
| * The period duration in seconds. | ||
| */ | ||
| period: number; | ||
| } | ||
|
|
||
| /** | ||
| * An helper class to limit the number of requests over a configurable period. | ||
| * It is intended to avoid over loading backend services with too many requests | ||
| * in a short period of time. | ||
| * | ||
| * When the limit is reached, an Error is thrown with the message "Rate limit exceeded". | ||
| */ | ||
| export class RateLimiter { | ||
| private name: string; | ||
| private maxCalls: number; | ||
| private periodMs: number; | ||
| private periodStartMs: number | null; | ||
| private requestsInPeriod: number; | ||
|
|
||
| constructor(options: RateLimiterOptions) { | ||
| if (!Number.isFinite(options.maxCalls) || !Number.isInteger(options.maxCalls) || options.maxCalls <= 0) { | ||
| throw new Error(`Invalid maxCalls for limiter ${options.name}`); | ||
| } | ||
|
|
||
| if (!Number.isFinite(options.period) || options.period <= 0) { | ||
| throw new Error(`Invalid period for limiter ${options.name}`); | ||
| } | ||
|
|
||
| this.name = options.name; | ||
| this.maxCalls = options.maxCalls; | ||
| this.periodMs = options.period * 1000; | ||
| this.periodStartMs = null; | ||
| this.requestsInPeriod = 0; | ||
| } | ||
|
|
||
| public async limit(): Promise<void> { | ||
| const now = Date.now(); | ||
|
|
||
| // Reset the counter when entering a new period window. | ||
| if (this.periodStartMs === null || now - this.periodStartMs >= this.periodMs) { | ||
| this.periodStartMs = now; | ||
| this.requestsInPeriod = 0; | ||
| } | ||
|
|
||
| if (this.requestsInPeriod >= this.maxCalls) { | ||
| throw new Error(`[${this.name}] Rate limit exceeded`); | ||
|
esgn marked this conversation as resolved.
|
||
| } | ||
|
|
||
| this.requestsInPeriod += 1; | ||
| } | ||
|
|
||
| } | ||
|
|
||
| /** | ||
| * Helper function to create a RateLimiter instance with the given parameters. | ||
| * This is just a convenience function to avoid having to import the RateLimiter class. | ||
| * | ||
| * @param name the name of the rate limiter, used for error reporting and debugging purposes | ||
| * @param maxCalls the maximum number of calls allowed during one period | ||
| * @param period the period duration in seconds | ||
| * @returns | ||
| */ | ||
| export function createRateLimiter(name: string, maxCalls: number, period: number): RateLimiter { | ||
| return new RateLimiter({ name, maxCalls, period }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| import { RateLimiter } from "../../src/helpers/RateLimiter.js"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| describe("Test RateLimiter", () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers(); | ||
| vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| it("should allow up to N requests in the same second", async () => { | ||
| const limiter = new RateLimiter({ | ||
| name: "test", | ||
| maxCalls: 2, | ||
| period: 1, | ||
| }); | ||
|
|
||
| await expect(limiter.limit()).resolves.toBeUndefined(); | ||
| await expect(limiter.limit()).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it("should throw on request N+1 in the same limiter window", async () => { | ||
| const limiter = new RateLimiter({ | ||
| name: "test", | ||
| maxCalls: 2, | ||
| period: 1, | ||
| }); | ||
|
|
||
| await limiter.limit(); | ||
| await limiter.limit(); | ||
|
|
||
| await expect(limiter.limit()).rejects.toThrow("[test] Rate limit exceeded"); | ||
| }); | ||
|
|
||
| it("should allow a new request after the one-second window resets", async () => { | ||
| const limiter = new RateLimiter({ | ||
| name: "test", | ||
| maxCalls: 2, | ||
| period: 1, | ||
| }); | ||
|
|
||
| await limiter.limit(); | ||
| await limiter.limit(); | ||
|
|
||
| vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z")); | ||
|
|
||
| await expect(limiter.limit()).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it("should reject invalid maxCalls values", () => { | ||
| expect(() => new RateLimiter({ | ||
| name: "zero", | ||
| maxCalls: 0, | ||
| period: 1, | ||
| })).toThrow(); | ||
|
|
||
| expect(() => new RateLimiter({ | ||
| name: "negative", | ||
| maxCalls: -1, | ||
| period: 1, | ||
| })).toThrow(); | ||
|
|
||
| expect(() => new RateLimiter({ | ||
| name: "nan", | ||
| maxCalls: Number.NaN, | ||
| period: 1, | ||
| })).toThrow(); | ||
|
|
||
| expect(() => new RateLimiter({ | ||
| name: "infinite", | ||
| maxCalls: Number.POSITIVE_INFINITY, | ||
| period: 1, | ||
| })).toThrow(); | ||
|
|
||
| expect(() => new RateLimiter({ | ||
| name: "fractional", | ||
| maxCalls: 1.5, | ||
| period: 1, | ||
| })).toThrow(); | ||
| }); | ||
|
|
||
| it("should reject invalid period values", () => { | ||
| expect(() => new RateLimiter({ | ||
| name: "zero", | ||
| maxCalls: 1, | ||
| period: 0, | ||
| })).toThrow(); | ||
|
|
||
| expect(() => new RateLimiter({ | ||
| name: "negative", | ||
| maxCalls: 1, | ||
| period: -1, | ||
| })).toThrow(); | ||
|
|
||
| expect(() => new RateLimiter({ | ||
| name: "nan", | ||
| maxCalls: 1, | ||
| period: Number.NaN, | ||
| })).toThrow(); | ||
|
|
||
| expect(() => new RateLimiter({ | ||
| name: "infinite", | ||
| maxCalls: 1, | ||
| period: Number.POSITIVE_INFINITY, | ||
| })).toThrow(); | ||
| }); | ||
|
|
||
| it("should enforce boundary at exactly one second", async () => { | ||
| const limiter = new RateLimiter({ | ||
| name: "test", | ||
| maxCalls: 1, | ||
| period: 1, | ||
| }); | ||
|
|
||
| await limiter.limit(); | ||
|
|
||
| vi.setSystemTime(new Date("2026-01-01T00:00:00.999Z")); | ||
| await expect(limiter.limit()).rejects.toThrow("[test] Rate limit exceeded"); | ||
|
|
||
| vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z")); | ||
| await expect(limiter.limit()).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it("should honor a custom period in seconds", async () => { | ||
| const limiter = new RateLimiter({ | ||
| name: "test", | ||
| maxCalls: 2, | ||
| period: 2, | ||
| }); | ||
|
|
||
| await limiter.limit(); | ||
| await limiter.limit(); | ||
|
|
||
| vi.setSystemTime(new Date("2026-01-01T00:00:01.999Z")); | ||
| await expect(limiter.limit()).rejects.toThrow("[test] Rate limit exceeded"); | ||
|
|
||
| vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z")); | ||
| await expect(limiter.limit()).resolves.toBeUndefined(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.