Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@ Pour les tests d'intégration et les tests E2E agent, voir [la documentation dé

Pour une utilisation avancée :

<<<<<<< 94-rate-limiting
| Nom | Description | Valeur par défaut |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `TRANSPORT_TYPE` | [Transport](https://mcp-framework.com/docs/Transports/transports-overview) permet de choisir entre "stdio" et "http" | "stdio" |
| `HTTP_HOST` | Adresse d'écoute en mode HTTP. Utile avec Docker pour exposer le service via `0.0.0.0`. | "127.0.0.1" |
| `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" |
=======
| Nom | Description | Valeur par défaut |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `TRANSPORT_TYPE` | [Transport](https://mcp-framework.com/docs/Transports/transports-overview) permet de choisir entre "stdio" et "http" | "stdio" |
Expand All @@ -168,6 +181,7 @@ Pour une utilisation avancée :
| `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" |
>>>>>>> master

Exemple :

Expand Down
78 changes: 78 additions & 0 deletions src/helpers/RateLimiter.ts
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;
Comment thread
esgn marked this conversation as resolved.
}

if (this.requestsInPeriod >= this.maxCalls) {
throw new Error(`[${this.name}] Rate limit exceeded`);
Comment thread
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 });
}
17 changes: 17 additions & 0 deletions src/helpers/wfs_engine/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
esgn marked this conversation as resolved.
const gpfWfsRateLimiter = createRateLimiter("GPF_WFS", gpfWfsRateLimit, 1);


// --- Response Types ---

Expand Down Expand Up @@ -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<WfsFeatureCollectionResponse> {
// 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",
Expand Down Expand Up @@ -109,6 +123,9 @@ export type MultiTypenameExecutionInput = {
export async function fetchWfsMultiTypename(
input: MultiTypenameExecutionInput,
): Promise<WfsFeatureCollectionResponse> {
// Enforce a global rate limit on all WFS requests to the GPF.
await gpfWfsRateLimiter.limit();

const request = buildMultiTypenameRequest({
typenames: input.typenames,
cqlFilter: input.cqlFilter,
Expand Down
143 changes: 143 additions & 0 deletions test/helpers/RateLimiter.test.ts
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();
});
});
Loading