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
5 changes: 5 additions & 0 deletions .changeset/configurable-external-api-rate-limit.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions packages/api/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,68 @@ describe('config', () => {
});
});
});

describe('EXTERNAL_API_RATE_LIMIT_MAX', () => {
Comment thread
vinzee marked this conversation as resolved.
Resolved
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
Comment thread
vinzee marked this conversation as resolved.
Resolved
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);
});
});

Comment thread
vinzee marked this conversation as resolved.
Resolved
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);
});
},
);
});
});
5 changes: 5 additions & 0 deletions packages/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
vinzee marked this conversation as resolved.
Resolved
Comment thread
vinzee marked this conversation as resolved.
Resolved
Comment thread
vinzee marked this conversation as resolved.
Resolved
})();

// FOR CI ONLY
export const CLICKHOUSE_HOST = env.CLICKHOUSE_HOST as string;
export const CLICKHOUSE_USER = env.CLICKHOUSE_USER as string;
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/routers/external-api/v2/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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`
Comment thread
vinzee marked this conversation as resolved.
Resolved
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
keyGenerator: rateLimiterKeyGenerator,
Expand Down
8 changes: 4 additions & 4 deletions scripts/ci/ratchet-baseline.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading