feat(gateway): add websocket ingress rate limiting

This commit is contained in:
William Valentin
2026-02-15 21:56:13 -08:00
parent 948d589ac3
commit 63d645bd87
10 changed files with 249 additions and 0 deletions
+74
View File
@@ -503,3 +503,77 @@ describe('GatewayServer request body limits', () => {
expect(gmailHandler.handlePushNotification).not.toHaveBeenCalled();
});
});
describe('GatewayServer WebSocket ingress rate limiting', () => {
const RATE_PORT = 18895;
let rateServer: GatewayServer;
beforeAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
rateServer = new GatewayServer({
port: RATE_PORT,
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
modelClient: mockModelClient,
systemPrompt: 'Test prompt',
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
uiDir: resolve(import.meta.dirname, 'ui'),
wsRateLimit: {
enabled: true,
capacity: 2,
refillPerSec: 1,
maxViolations: 3,
violationWindowMs: 10_000,
},
});
await rateServer.start();
});
afterAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
await rateServer.stop();
});
it('throttles bursts and closes repeated offenders', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const ws = await new Promise<WebSocket>((resolve, reject) => {
const c = new WebSocket(`ws://127.0.0.1:${RATE_PORT}`);
c.on('open', () => resolve(c));
c.on('error', reject);
});
try {
const first = await sendAndReceive(ws, { id: 1, method: 'system.health' });
expect((first as GatewayResponse).id).toBe(1);
const second = await sendAndReceive(ws, { id: 2, method: 'system.health' });
expect((second as GatewayResponse).id).toBe(2);
const third = await sendAndReceive(ws, { id: 3, method: 'system.health' });
const rateErr = third as GatewayError;
expect(rateErr.error.code).toBe(ErrorCode.InternalError);
expect(rateErr.error.message).toContain('Rate limit exceeded');
// Trigger additional violations; server should close on max violation threshold.
ws.send(JSON.stringify({ id: 4, method: 'system.health' }));
ws.send(JSON.stringify({ id: 5, method: 'system.health' }));
const close = await new Promise<{ code: number; reason: string }>((resolve) => {
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
});
expect(close.code).toBe(4008);
expect(close.reason).toContain('Rate limit exceeded');
} finally {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
});
});