Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/api/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default [
'src/coverage/**',
'migrations/**',
'migrate-mongo-config.ts',
'scripts/**',
'**/*.config.js',
'**/*.config.mjs',
'jest.config.js',
Expand Down
4 changes: 2 additions & 2 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@
},
"scripts": {
"start": "node ./build/index.js",
"dev": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/index.ts",
"dev": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r ./scripts/env-local-preload.js -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/index.ts",
"dev:mcp": "npx @modelcontextprotocol/inspector",
"dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts",
"dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r ./scripts/env-local-preload.js -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts",
"build": "rimraf ./build && tsc && tsc-alias && cp -r ./src/opamp/proto ./build/opamp/",
"lint": "npx eslint --quiet . --ext .ts",
"lint:fix": "npx eslint . --ext .ts --fix",
Expand Down
16 changes: 16 additions & 0 deletions packages/api/scripts/env-local-preload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Preload .env.development.local (if it exists) before dotenv-expand loads
// .env.development. Because dotenv never overwrites existing vars, values
// from the .local file take precedence — matching the Next.js convention.
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');

const localPath = path.resolve(
__dirname,
'..',
(process.env.DOTENV_CONFIG_PATH || '.env.development') + '.local',
);

if (fs.existsSync(localPath)) {
dotenv.config({ path: localPath });
}
111 changes: 111 additions & 0 deletions packages/api/src/middleware/__tests__/rateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { NextFunction, Request, Response } from 'express';

jest.mock('@/utils/logger', () => ({
__esModule: true,
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));

// Dynamic user id per test so we can simulate multiple callers
let mockUserId = 'user-a';
jest.mock('@/middleware/auth', () => ({
getNonNullUserWithTeam: () => ({
teamId: 't',
userId: mockUserId,
email: 'e',
}),
}));

import { createRateLimiter } from '../rateLimit';

function makeReq(): Request {
return {} as Request;
}
function makeRes(): Response {
return {} as Response;
}

// Invoke the middleware and capture what it passes to next()
function invoke(mw: ReturnType<typeof createRateLimiter>): {
error?: unknown;
passed: boolean;
} {
let captured: unknown;
let passed = false;
const next: NextFunction = err => {
if (err) captured = err;
else passed = true;
};
mw(makeReq(), makeRes(), next);
return { error: captured, passed };
}

describe('createRateLimiter', () => {
beforeEach(() => {
mockUserId = 'user-a';
});

it('allows requests under the limit', () => {
const mw = createRateLimiter({ windowMs: 60_000, max: 3, name: 'test' });
expect(invoke(mw).passed).toBe(true);
expect(invoke(mw).passed).toBe(true);
expect(invoke(mw).passed).toBe(true);
});

it('rejects the (max+1)th request with a 429', () => {
const mw = createRateLimiter({ windowMs: 60_000, max: 2, name: 'test' });
invoke(mw);
invoke(mw);
const { passed, error } = invoke(mw);
expect(passed).toBe(false);
expect(error).toBeDefined();
// Api429Error has statusCode 429. The project's BaseError pattern puts
// the detail message in `.name` and a generic description in `.message`.
expect((error as { statusCode?: number }).statusCode).toBe(429);
expect((error as Error).name).toContain('Rate limit');
});

it('resets after the window elapses', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const mw = createRateLimiter({ windowMs: 1000, max: 1, name: 'test' });
expect(invoke(mw).passed).toBe(true);
expect(invoke(mw).passed).toBe(false); // over
jest.setSystemTime(new Date('2026-01-01T00:00:01.500Z')); // past window
expect(invoke(mw).passed).toBe(true);
jest.useRealTimers();
});

it('tracks users independently', () => {
const mw = createRateLimiter({ windowMs: 60_000, max: 1, name: 'test' });
mockUserId = 'user-a';
expect(invoke(mw).passed).toBe(true);
expect(invoke(mw).passed).toBe(false); // a is over

mockUserId = 'user-b';
expect(invoke(mw).passed).toBe(true); // b is fresh
});

it('returns independent buckets per limiter instance', () => {
const mwA = createRateLimiter({ windowMs: 60_000, max: 1, name: 'A' });
const mwB = createRateLimiter({ windowMs: 60_000, max: 1, name: 'B' });
expect(invoke(mwA).passed).toBe(true);
expect(invoke(mwA).passed).toBe(false);
// different limiter — still fresh
expect(invoke(mwB).passed).toBe(true);
});

it('includes the limiter name and retry seconds in the error message', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const mw = createRateLimiter({
windowMs: 30_000,
max: 1,
name: 'summarize',
});
invoke(mw);
const { error } = invoke(mw);
expect((error as Error).name).toContain('summarize');
expect((error as Error).name).toMatch(/Try again in \d+s/);
jest.useRealTimers();
});
});
72 changes: 72 additions & 0 deletions packages/api/src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Per-user in-memory rate limiter. Intentionally simple: a sliding window
// counter per user id. Swap for a Redis-backed limiter if the API scales
// beyond a single process — the interface stays the same.
//
// Not a DDoS defense. Purpose: protect shared LLM API budget from runaway
// callers (scripts, bugs, honest-mistake retry loops).

import type { NextFunction, Request, Response } from 'express';

import { getNonNullUserWithTeam } from '@/middleware/auth';
import { Api429Error } from '@/utils/errors';
import logger from '@/utils/logger';

interface RateLimitOptions {
windowMs: number;
max: number;
name: string; // shown in logs, helps identify which limiter fired
}

interface WindowState {
count: number;
resetAt: number;
}

export function createRateLimiter({ windowMs, max, name }: RateLimitOptions) {
const buckets = new Map<string, WindowState>();

// Opportunistic cleanup — runs on every request, O(1) amortized.
function gc(now: number) {
if (buckets.size < 1000) return;
for (const [k, v] of buckets) {
if (v.resetAt <= now) buckets.delete(k);
}
}

return function rateLimitMiddleware(
req: Request,
_res: Response,
next: NextFunction,
) {
try {
const { userId } = getNonNullUserWithTeam(req);
const key = String(userId);
const now = Date.now();
gc(now);

let state = buckets.get(key);
if (!state || state.resetAt <= now) {
state = { count: 0, resetAt: now + windowMs };
buckets.set(key, state);
}

state.count++;
if (state.count > max) {
logger.warn({
message: 'rate limit exceeded',
limiter: name,
userId: key,
count: state.count,
max,
});
throw new Api429Error(
`Rate limit exceeded for ${name}. Try again in ${Math.ceil((state.resetAt - now) / 1000)}s.`,
);
}

next();
} catch (err) {
next(err);
}
};
}
Loading
Loading