Add RATE_LIMITER_MULTIPLIER configuration for self-hosted instances (#12226)

* Add RATE_LIMIT_MULTIPLIER configuration for self-hosted instances

* PR feedback
This commit is contained in:
Tom Moor
2026-04-30 07:49:45 -04:00
committed by GitHub
parent 6763ecbd5f
commit 773750d470
4 changed files with 69 additions and 1 deletions
+6
View File
@@ -218,6 +218,12 @@ RATE_LIMITER_ENABLED=true
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Multiplier applied to the hardcoded per-endpoint API rate limits. Use values
# greater than 1 to make the limits more lenient (e.g. 2 doubles the allowed
# requests), or less than 1 to make them stricter. Effective limits are rounded
# to the nearest integer with a minimum of 1. Defaults to 1.
RATE_LIMITER_MULTIPLIER=1
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– INTEGRATIONS –––––––––––
+18
View File
@@ -580,6 +580,20 @@ export class Environment {
public RATE_LIMITER_DURATION_WINDOW =
this.toOptionalNumber(environment.RATE_LIMITER_DURATION_WINDOW) ?? 60;
/**
* Multiplier applied to the per-endpoint API rate limits. Allows operators to
* uniformly scale the hard-coded route-level limits up or down without
* touching code. A value of 1 (the default) preserves the built-in limits.
* Effective per-endpoint limits are scaled by this value, rounded to the
* nearest integer, and clamped to a minimum of 1.
*/
@IsOptional()
@IsNumber()
@Min(0)
@CannotUseWithout("RATE_LIMITER_ENABLED")
public RATE_LIMITER_MULTIPLIER =
this.toOptionalFloat(environment.RATE_LIMITER_MULTIPLIER) ?? 1;
/**
* Set max allowed upload size for file attachments.
* @deprecated Use FILE_STORAGE_UPLOAD_MAX_SIZE instead
@@ -881,6 +895,10 @@ export class Environment {
return value ? parseInt(value, 10) : undefined;
}
protected toOptionalFloat(value: string | undefined) {
return value ? parseFloat(value) : undefined;
}
/**
* Convert a string to a boolean. Supports the following:
*
+39
View File
@@ -7,14 +7,17 @@ import { defaultRateLimiter, rateLimiter } from "./rateLimiter";
describe("rateLimiter middleware", () => {
const originalRateLimiterEnabled = env.RATE_LIMITER_ENABLED;
const originalApiMultiplier = env.RATE_LIMITER_MULTIPLIER;
beforeEach(() => {
env.RATE_LIMITER_ENABLED = true;
env.RATE_LIMITER_MULTIPLIER = 1;
RateLimiter.rateLimiterMap.clear();
});
afterEach(() => {
env.RATE_LIMITER_ENABLED = originalRateLimiterEnabled;
env.RATE_LIMITER_MULTIPLIER = originalApiMultiplier;
jest.restoreAllMocks();
});
@@ -62,6 +65,42 @@ describe("rateLimiter middleware", () => {
expect(limiter.points).toBe(5);
});
it("scales the per-route limit by RATE_LIMITER_MULTIPLIER", async () => {
env.RATE_LIMITER_MULTIPLIER = 2;
const registerMiddleware = rateLimiter({ duration: 60, requests: 5 });
const mockCtx = {
path: "/documents.export",
mountPath: undefined,
ip: "127.0.0.1",
set: jest.fn(),
request: {},
} as unknown as Context;
await registerMiddleware(mockCtx, jest.fn());
const limiter = RateLimiter.getRateLimiter("/documents.export");
expect(limiter.points).toBe(10);
});
it("rounds fractional multiplier results and never drops below 1", async () => {
env.RATE_LIMITER_MULTIPLIER = 0.1;
const registerMiddleware = rateLimiter({ duration: 60, requests: 5 });
const mockCtx = {
path: "/shares.subscribe",
mountPath: undefined,
ip: "127.0.0.1",
set: jest.fn(),
request: {},
} as unknown as Context;
await registerMiddleware(mockCtx, jest.fn());
const limiter = RateLimiter.getRateLimiter("/shares.subscribe");
expect(limiter.points).toBe(1);
});
it("should use default rate limiter when no custom rate limiter is registered", async () => {
const fullPath = "/some/random/path";
expect(RateLimiter.hasRateLimiter(fullPath)).toBe(false);
+6 -1
View File
@@ -118,12 +118,17 @@ export function rateLimiter(config: RateLimiterConfig) {
const fullPath = `${ctx.mountPath ?? ""}${ctx.path}`;
if (!RateLimiter.hasRateLimiter(fullPath)) {
const points = Math.max(
1,
Math.round(config.requests * env.RATE_LIMITER_MULTIPLIER)
);
RateLimiter.setRateLimiter(
fullPath,
defaults(
{
...config,
points: config.requests,
points,
},
{
duration: 60,