From 4161ef905c8ea23d8230c0bcea88a787ab5f8a44 Mon Sep 17 00:00:00 2001 From: mborne Date: Fri, 1 May 2026 01:31:09 +0200 Subject: [PATCH 1/4] feat(rate-limit): add RateLimiter helper (refs #94) --- src/helpers/RateLimiter.ts | 78 +++++++++++++++++ test/helpers/RateLimiter.test.ts | 143 +++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 src/helpers/RateLimiter.ts create mode 100644 test/helpers/RateLimiter.test.ts diff --git a/src/helpers/RateLimiter.ts b/src/helpers/RateLimiter.ts new file mode 100644 index 0000000..f0e0391 --- /dev/null +++ b/src/helpers/RateLimiter.ts @@ -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 intented 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 { + 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`); + } + + 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 + * @param maxCalls + * @param period + * @returns + */ +export function createRateLimiter(name: string, maxCalls: number, period: number): RateLimiter { + return new RateLimiter({ name, maxCalls, period }); +} diff --git a/test/helpers/RateLimiter.test.ts b/test/helpers/RateLimiter.test.ts new file mode 100644 index 0000000..33d61c7 --- /dev/null +++ b/test/helpers/RateLimiter.test.ts @@ -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 rolling second", 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 slides", 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(); + }); +}); From b616316bb88f9e09da8fe29bae3e5788e7418044 Mon Sep 17 00:00:00 2001 From: MBorne Date: Tue, 5 May 2026 11:21:45 +0200 Subject: [PATCH 2/4] feat: add rate limit for the WFS requests (refs #94) --- README.md | 2 ++ src/helpers/RateLimiter.ts | 6 +++--- src/helpers/wfs_engine/execution.ts | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9bd790c..96e491b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Serveur MCP expérimental fournissant du contexte spatial pour les LLM sur la ba - [Avec Docker en local](#avec-docker-en-local) - [Debug de la version locale](#debug-de-la-version-locale) - [Paramétrage](#paramétrage) + - [Tests](#tests) - [Fonctionnalités (Tools)](#fonctionnalités-tools) - [Utiliser des services spatiaux](#utiliser-des-services-spatiaux) - [Recherche d'informations pour un lieu](#recherche-dinformations-pour-un-lieu) @@ -163,6 +164,7 @@ Pour une utilisation avancée : | `HTTP_PORT` | Port d'écoute du MCP | 3000 | | `HTTP_MCP_ENDPOINT` | Chemin d'exposition du MCP en HTTP | "/mcp" | | `HTTP_TIMEOUT` | Délai maximal, en secondes, pour les appels HTTP sortants vers les services amont IGN. Au-delà, la requête est interrompue et l'outil renvoie une erreur de timeout structurée. | `15` | +| `GPF_WFS_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le WFS de la Géoplateforme | 30 | | `GPF_WFS_MINISEARCH_OPTIONS` | Chaîne JSON optionnelle pour ajuster les options MiniSearch utilisées par `gpf_wfs_search_types` (`fields`, `combineWith`, `fuzzy`, `boost.namespace`, `boost.name`, `boost.title`, `boost.description`, `boost.properties`, `boost.enums`, `boost.identifierTokens`). | options par défaut de `@ignfab/gpf-schema-store` | | `LOG_FORMAT` | Le format d'écriture des logs : "json" ou "simple". | "simple" | | `LOG_LEVEL` | Le niveau d'écriture des logs : ["error", "info", ou "debug"](https://github.com/winstonjs/winston#logging-levels) | "debug" | diff --git a/src/helpers/RateLimiter.ts b/src/helpers/RateLimiter.ts index f0e0391..980669f 100644 --- a/src/helpers/RateLimiter.ts +++ b/src/helpers/RateLimiter.ts @@ -68,9 +68,9 @@ export class RateLimiter { * 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 - * @param maxCalls - * @param period + * @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 { diff --git a/src/helpers/wfs_engine/execution.ts b/src/helpers/wfs_engine/execution.ts index 547ef0b..181d5dc 100644 --- a/src/helpers/wfs_engine/execution.ts +++ b/src/helpers/wfs_engine/execution.ts @@ -11,6 +11,17 @@ import type { CompiledRequest } from "./request.js"; import { buildMultiTypenameRequest } from "./request.js"; import { wfsClient } from "../../gpf/wfs-schema-catalog.js"; import { fetchJSONPost } from "../http.js"; +import { createRateLimiter } from "../RateLimiter.js"; + +/** + * Default rate limit for WFS requests, in requests per second. + * https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/limites-d-usage/#valeur-de-la-limite-d-usage-pour-chaque-api-concernee + * + * TODO in #33 : move/call fetchFeatureCollection and fetchWfsMultiTypename in WfsClient to allow rateLimiter as a property? + */ +const gpfWfsRateLimit = parseInt(process.env.GPF_WFS_RATE_LIMIT || "30", 10); +const gpfWfsRateLimiter = createRateLimiter("GPF_WFS", gpfWfsRateLimit, 1); + // --- Response Types --- @@ -51,6 +62,9 @@ export async function getFeatureType(typename: string) { * @returns The parsed JSON response returned by the WFS endpoint. */ export async function fetchFeatureCollection(request: CompiledRequest): Promise { + // Enforce a global rate limit on all WFS requests to the GPF. + await gpfWfsRateLimiter.limit(); + const url = `${request.url}?${new URLSearchParams(request.query).toString()}`; return fetchJSONPost(url, request.body, { "Content-Type": "application/x-www-form-urlencoded", @@ -109,6 +123,9 @@ export type MultiTypenameExecutionInput = { export async function fetchWfsMultiTypename( input: MultiTypenameExecutionInput, ): Promise { + // Enforce a global rate limit on all WFS requests to the GPF. + await gpfWfsRateLimiter.limit(); + const request = buildMultiTypenameRequest({ typenames: input.typenames, cqlFilter: input.cqlFilter, From a89ed8c89dc5af55b16882f13a9af8625e3d2fc5 Mon Sep 17 00:00:00 2001 From: "Emmanuel S." <5435148+esgn@users.noreply.github.com> Date: Tue, 5 May 2026 12:12:28 +0200 Subject: [PATCH 3/4] typo fix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/helpers/RateLimiter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/RateLimiter.ts b/src/helpers/RateLimiter.ts index 980669f..500a898 100644 --- a/src/helpers/RateLimiter.ts +++ b/src/helpers/RateLimiter.ts @@ -18,7 +18,7 @@ interface RateLimiterOptions { /** * An helper class to limit the number of requests over a configurable period. - * It is intented to avoid over loading backend services with too many requests + * 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". From b72f22855809767c9a565c85ee4fc74ba906adbd Mon Sep 17 00:00:00 2001 From: esgn <5435148+esgn@users.noreply.github.com> Date: Tue, 5 May 2026 13:50:11 +0200 Subject: [PATCH 4/4] fix(tests): clarify test descriptions for RateLimiter behavior --- test/helpers/RateLimiter.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/RateLimiter.test.ts b/test/helpers/RateLimiter.test.ts index 33d61c7..1b49f38 100644 --- a/test/helpers/RateLimiter.test.ts +++ b/test/helpers/RateLimiter.test.ts @@ -22,7 +22,7 @@ describe("Test RateLimiter", () => { await expect(limiter.limit()).resolves.toBeUndefined(); }); - it("should throw on request N+1 in the same rolling second", async () => { + it("should throw on request N+1 in the same limiter window", async () => { const limiter = new RateLimiter({ name: "test", maxCalls: 2, @@ -35,7 +35,7 @@ describe("Test RateLimiter", () => { await expect(limiter.limit()).rejects.toThrow("[test] Rate limit exceeded"); }); - it("should allow a new request after the one-second window slides", async () => { + it("should allow a new request after the one-second window resets", async () => { const limiter = new RateLimiter({ name: "test", maxCalls: 2,