From 6803e3b10b7ea95e6d918ff97d4ef51a25b79abf Mon Sep 17 00:00:00 2001 From: vinzee Date: Wed, 23 Sep 2026 11:03:21 -0700 Subject: [PATCH] Allow Overriding the rate limit using env var --- .../configurable-external-api-rate-limit.md | 5 ++ packages/api/src/__tests__/config.test.ts | 64 +++++++++++++++++++ packages/api/src/config.ts | 5 ++ .../api/src/routers/external-api/v2/index.ts | 3 +- scripts/ci/ratchet-baseline.json | 8 +-- 5 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 .changeset/configurable-external-api-rate-limit.md diff --git a/.changeset/configurable-external-api-rate-limit.md b/.changeset/configurable-external-api-rate-limit.md new file mode 100644 index 0000000000..51ef43339a --- /dev/null +++ b/.changeset/configurable-external-api-rate-limit.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': minor +--- + +Make the external API v2 rate limit (`/api/v2/*`) configurable via `EXTERNAL_API_RATE_LIMIT_MAX`. Defaults to 100 requests/minute, matching the previous hardcoded value. \ No newline at end of file diff --git a/packages/api/src/__tests__/config.test.ts b/packages/api/src/__tests__/config.test.ts index fedf486e0d..6375b969ac 100644 --- a/packages/api/src/__tests__/config.test.ts +++ b/packages/api/src/__tests__/config.test.ts @@ -59,4 +59,68 @@ describe('config', () => { }); }); }); + + describe('EXTERNAL_API_RATE_LIMIT_MAX', () => { + const ORIGINAL = process.env.EXTERNAL_API_RATE_LIMIT_MAX; + + afterEach(() => { + if (ORIGINAL === undefined) { + delete process.env.EXTERNAL_API_RATE_LIMIT_MAX; + } else { + process.env.EXTERNAL_API_RATE_LIMIT_MAX = ORIGINAL; + } + jest.resetModules(); + }); + + it('defaults to 100 when unset', () => { + delete process.env.EXTERNAL_API_RATE_LIMIT_MAX; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/no-missing-require + const config = require('@/config'); + expect(config.EXTERNAL_API_RATE_LIMIT_MAX).toBe(100); + }); + }); + + it('accepts a valid positive integer', () => { + process.env.EXTERNAL_API_RATE_LIMIT_MAX = '1000'; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/no-missing-require + const config = require('@/config'); + expect(config.EXTERNAL_API_RATE_LIMIT_MAX).toBe(1000); + }); + }); + + it('accepts exponential notation', () => { + process.env.EXTERNAL_API_RATE_LIMIT_MAX = '1e3'; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/no-missing-require + const config = require('@/config'); + expect(config.EXTERNAL_API_RATE_LIMIT_MAX).toBe(1000); + }); + }); + + it.each([ + ['garbage string (NaN)', 'abc'], + ['zero (express-rate-limit v6 treats 0 as unlimited)', '0'], + ['negative', '-5'], + ['trailing garbage', '100/min'], + ['empty string', ''], + ['fractional (would block every request at max=0.5)', '0.5'], + ['Infinity (would disable the limit entirely)', 'Infinity'], + ])( + 'falls back to the default of 100 and does not silently disable the limit: %s (%p)', + (_desc, raw) => { + process.env.EXTERNAL_API_RATE_LIMIT_MAX = raw; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/no-missing-require + const config = require('@/config'); + expect(config.EXTERNAL_API_RATE_LIMIT_MAX).toBe(100); + }); + }, + ); + }); }); diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index cdb50bd3bf..60045ffa75 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -62,6 +62,11 @@ export const DEFAULT_SOURCES = env.DEFAULT_SOURCES; export const IS_PROMQL_ENABLED = env.ENABLE_PROMQL === 'true'; +export const EXTERNAL_API_RATE_LIMIT_MAX = (() => { + const parsed = Number(env.EXTERNAL_API_RATE_LIMIT_MAX); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 100; +})(); + // FOR CI ONLY export const CLICKHOUSE_HOST = env.CLICKHOUSE_HOST as string; export const CLICKHOUSE_USER = env.CLICKHOUSE_USER as string; diff --git a/packages/api/src/routers/external-api/v2/index.ts b/packages/api/src/routers/external-api/v2/index.ts index e6b152188a..6ef19d6d34 100644 --- a/packages/api/src/routers/external-api/v2/index.ts +++ b/packages/api/src/routers/external-api/v2/index.ts @@ -1,5 +1,6 @@ import express from 'express'; +import { EXTERNAL_API_RATE_LIMIT_MAX } from '@/config'; import { validateUserAccessKey } from '@/middleware/auth'; import alertsRouter from '@/routers/external-api/v2/alerts'; import chartsRouter from '@/routers/external-api/v2/charts'; @@ -16,7 +17,7 @@ const router = express.Router(); const defaultRateLimiter = rateLimiter({ windowMs: 60 * 1000, // 1 minute - max: 100, // Limit each API key to 100 requests per `window` + max: EXTERNAL_API_RATE_LIMIT_MAX, // Limit each API key to this many requests per `window` standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers keyGenerator: rateLimiterKeyGenerator, diff --git a/scripts/ci/ratchet-baseline.json b/scripts/ci/ratchet-baseline.json index 20acd65bf3..4d7e1f1fba 100644 --- a/scripts/ci/ratchet-baseline.json +++ b/scripts/ci/ratchet-baseline.json @@ -1,21 +1,21 @@ { "api": { - "as-any": 91, + "as-any": 89, "ts-ignore": 0, - "eslint-disable": 32, + "eslint-disable": 36, "@source": 0 }, "app": { "as-any": 213, "ts-ignore": 0, - "eslint-disable": 145, + "eslint-disable": 143, "@source": 0 }, "cli": { "as-any": 0, "ts-ignore": 0, "eslint-disable": 1, - "@source": 37 + "@source": 38 }, "common-utils": { "as-any": 116,