Skip to content

Commit f80016e

Browse files
authored
feat: rate limit all ai requests (#3081)
* chore: cleanup types for metering * fix: error responses * wip: rate limit refactor * rate-limit for some drivers * fix: ai driver limits * tests: rate limiting
1 parent c8fdbdf commit f80016e

31 files changed

Lines changed: 2770 additions & 214 deletions
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/**
2+
* Copyright (C) 2024-present Puter Technologies Inc.
3+
*
4+
* This file is part of Puter.
5+
*
6+
* Puter is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
20+
/* eslint-disable @typescript-eslint/no-explicit-any */
21+
import { EventEmitter } from 'node:events';
22+
import type { Request, RequestHandler, Response } from 'express';
23+
import { beforeEach, describe, expect, it } from 'vitest';
24+
import { runWithContext } from '../../core/context.js';
25+
import { isHttpError } from '../../core/http/HttpError.js';
26+
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
27+
import { DriverController } from './DriverController.js';
28+
29+
// Focused integration coverage for the concurrent-acquire path inside
30+
// `#handleCall`. The main DriverController.test.ts boots the full
31+
// PuterServer, but exercising concurrent-slot behaviour through real
32+
// drivers requires real provider machinery — too much for a unit test.
33+
// Here we instantiate the controller directly with a synthetic driver
34+
// that carries the only field we care about (`concurrent`), then drive
35+
// `#handleCall` through the same captureRoutes trick the main file uses.
36+
37+
// ── Test harness ────────────────────────────────────────────────────
38+
39+
const captureCallHandler = (controller: DriverController): RequestHandler => {
40+
let handler: RequestHandler | undefined;
41+
const fakeRouter = {
42+
post: (path: string, _opts: unknown, h: RequestHandler) => {
43+
if (path === '/call') handler = h;
44+
return fakeRouter;
45+
},
46+
get: () => fakeRouter,
47+
use: () => fakeRouter,
48+
};
49+
controller.registerRoutes(
50+
fakeRouter as unknown as Parameters<
51+
typeof controller.registerRoutes
52+
>[0],
53+
);
54+
if (!handler) throw new Error('failed to capture POST /call handler');
55+
return handler;
56+
};
57+
58+
// `res.once('finish'|'close')` is the trigger for slot release, so the
59+
// stub must actually be an EventEmitter — that's the contract the
60+
// controller relies on.
61+
class StubRes extends EventEmitter {
62+
statusCode = 200;
63+
body: unknown = undefined;
64+
headers: Record<string, string> = {};
65+
sentBody: string | undefined;
66+
contentType: string | undefined;
67+
status(code: number) {
68+
this.statusCode = code;
69+
return this;
70+
}
71+
json(body: unknown) {
72+
this.body = body;
73+
return this;
74+
}
75+
setHeader(key: string, value: string) {
76+
this.headers[key.toLowerCase()] = value;
77+
return this;
78+
}
79+
type(t: string) {
80+
this.contentType = t;
81+
return this;
82+
}
83+
send(body: string) {
84+
this.sentBody = body;
85+
return this;
86+
}
87+
}
88+
89+
// Memory-backend counters are module-level state that persists across
90+
// tests. To avoid one test's held slot leaking into the next we vary
91+
// the request fingerprint per test (the helper keys off
92+
// `req.actor?.user?.uuid || fingerprint(req)`, where fingerprint mixes
93+
// IP + UA + accept headers).
94+
const makeReq = (
95+
body: Record<string, unknown> = {},
96+
fingerprintTag = 'default',
97+
): Request =>
98+
({
99+
body,
100+
// Anonymous — `#handleCall` skips the permission gate when there's no
101+
// actor, which keeps this test focused on the concurrent path.
102+
actor: undefined,
103+
headers: { 'user-agent': fingerprintTag },
104+
query: {},
105+
ip: '127.0.0.1',
106+
socket: { remoteAddress: '127.0.0.1' },
107+
}) as unknown as Request;
108+
109+
// Synthetic driver with a single-slot `concurrent` cap. `ping` doesn't
110+
// touch the network; it just returns a string so the controller can
111+
// json-respond.
112+
const makeSyntheticDriver = () => ({
113+
driverInterface: 'test-iface',
114+
driverName: 'test-driver',
115+
isDefault: true,
116+
concurrent: {
117+
default: { limit: 1 },
118+
},
119+
onServerStart() {},
120+
onServerPrepareShutdown() {},
121+
onServerShutdown() {},
122+
ping: async () => 'pong',
123+
});
124+
125+
const buildController = (driver: ReturnType<typeof makeSyntheticDriver>) => {
126+
// The controller reads `this.services?.permission` only when an actor
127+
// is on the request; otherwise the services bag is unused. Empty
128+
// objects are sufficient for this path.
129+
return new DriverController(
130+
{} as any,
131+
{} as any,
132+
{} as any,
133+
{} as any,
134+
{ syntheticDriver: driver } as any,
135+
);
136+
};
137+
138+
// ── Tests ───────────────────────────────────────────────────────────
139+
140+
describe('DriverController — concurrent acquire/release', () => {
141+
beforeEach(() => {
142+
// Memory backend is sufficient and avoids cross-test redis state.
143+
configureRateLimit();
144+
});
145+
146+
// `#handleCall` writes to Context (e.g. `driverName`) which requires
147+
// a request scope — same as the main test file does.
148+
const callInScope = (
149+
handler: RequestHandler,
150+
req: Request,
151+
res: Response,
152+
) => runWithContext({}, () => handler(req, res, () => {}));
153+
154+
it('admits a call up to the per-method concurrent limit', async () => {
155+
const controller = buildController(makeSyntheticDriver());
156+
const handler = captureCallHandler(controller);
157+
158+
const res = new StubRes();
159+
await callInScope(
160+
handler,
161+
makeReq({ interface: 'test-iface', method: 'ping' }, 'admit'),
162+
res as unknown as Response,
163+
);
164+
// Slot freed before next test runs.
165+
res.emit('finish');
166+
167+
// Driver method ran and produced the wrapped envelope.
168+
expect(res.body).toMatchObject({
169+
success: true,
170+
result: 'pong',
171+
service: { name: 'test-driver' },
172+
});
173+
});
174+
175+
it("rejects a second concurrent call with 429 while the first slot is still held", async () => {
176+
const controller = buildController(makeSyntheticDriver());
177+
const handler = captureCallHandler(controller);
178+
179+
// First call admits and finishes synchronously — but we DO NOT
180+
// emit `finish`/`close`, so its slot stays held.
181+
const res1 = new StubRes();
182+
await callInScope(
183+
handler,
184+
makeReq({ interface: 'test-iface', method: 'ping' }, 'reject'),
185+
res1 as unknown as Response,
186+
);
187+
188+
try {
189+
// Second call must throw a 429 — slot is full.
190+
await expect(
191+
callInScope(
192+
handler,
193+
makeReq(
194+
{ interface: 'test-iface', method: 'ping' },
195+
'reject',
196+
),
197+
new StubRes() as unknown as Response,
198+
),
199+
).rejects.toSatisfy(
200+
(e) =>
201+
isHttpError(e) &&
202+
(e as { statusCode: number }).statusCode === 429,
203+
);
204+
} finally {
205+
// Clean up so the held slot doesn't pin the bucket on subsequent
206+
// suites that reuse the same fingerprint.
207+
res1.emit('finish');
208+
await new Promise((r) => setImmediate(r));
209+
}
210+
});
211+
212+
it("releases the slot on res 'finish' so the next caller is admitted", async () => {
213+
const controller = buildController(makeSyntheticDriver());
214+
const handler = captureCallHandler(controller);
215+
216+
const res1 = new StubRes();
217+
await callInScope(
218+
handler,
219+
makeReq({ interface: 'test-iface', method: 'ping' }, 'finish'),
220+
res1 as unknown as Response,
221+
);
222+
// Emulate response completion.
223+
res1.emit('finish');
224+
// The release path uses `Promise.resolve().then(...)`, so flush
225+
// the microtask queue before re-attempting.
226+
await new Promise((r) => setImmediate(r));
227+
228+
// A fresh call must now succeed.
229+
const res2 = new StubRes();
230+
await callInScope(
231+
handler,
232+
makeReq({ interface: 'test-iface', method: 'ping' }, 'finish'),
233+
res2 as unknown as Response,
234+
);
235+
res2.emit('finish');
236+
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
237+
});
238+
239+
it("releases on 'close' too — aborted requests don't pin the slot", async () => {
240+
const controller = buildController(makeSyntheticDriver());
241+
const handler = captureCallHandler(controller);
242+
243+
const res1 = new StubRes();
244+
await callInScope(
245+
handler,
246+
makeReq({ interface: 'test-iface', method: 'ping' }, 'abort'),
247+
res1 as unknown as Response,
248+
);
249+
// Simulate the client closing the connection mid-flight.
250+
res1.emit('close');
251+
await new Promise((r) => setImmediate(r));
252+
253+
const res2 = new StubRes();
254+
await callInScope(
255+
handler,
256+
makeReq({ interface: 'test-iface', method: 'ping' }, 'abort'),
257+
res2 as unknown as Response,
258+
);
259+
res2.emit('finish');
260+
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
261+
});
262+
263+
it('does not attach release listeners when the driver declares no concurrent config', async () => {
264+
// The optimisation that lets the existing test stubs in
265+
// DriverController.test.ts get away without an EventEmitter-shaped
266+
// `res`: skip the once() wiring entirely when there's no spec.
267+
const driver = makeSyntheticDriver();
268+
(driver as { concurrent?: unknown }).concurrent = undefined;
269+
const controller = buildController(driver);
270+
const handler = captureCallHandler(controller);
271+
272+
// Bare object with no event-emitter surface — exposes the bug
273+
// case where the gate would try to call `res.once`.
274+
const bareRes = {
275+
statusCode: 200,
276+
body: undefined as unknown,
277+
status(code: number) {
278+
this.statusCode = code;
279+
return this;
280+
},
281+
json(body: unknown) {
282+
this.body = body;
283+
return this;
284+
},
285+
setHeader() {
286+
return this;
287+
},
288+
type() {
289+
return this;
290+
},
291+
send() {
292+
return this;
293+
},
294+
};
295+
296+
await callInScope(
297+
handler,
298+
makeReq({ interface: 'test-iface', method: 'ping' }, 'no-spec'),
299+
bareRes as unknown as Response,
300+
);
301+
expect(bareRes.body).toMatchObject({ success: true, result: 'pong' });
302+
});
303+
});

0 commit comments

Comments
 (0)