feat(api): enforce per-minute request rate limits
This commit is contained in:
@@ -173,3 +173,79 @@ Deno.test('ApiServer metrics expose 5xx counts for failing endpoints', async ()
|
|||||||
await server.stop();
|
await server.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('ApiServer enforces api rate limits while exempting health and metrics', async () => {
|
||||||
|
const port = 19200 + Math.floor(Math.random() * 1000);
|
||||||
|
const server = new ApiServer(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
apiKeys: ['valid-key'],
|
||||||
|
rateLimit: 2,
|
||||||
|
cors: false,
|
||||||
|
corsOrigins: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
async getAllStatus() {
|
||||||
|
return new Map();
|
||||||
|
},
|
||||||
|
async getAllAvailableModels() {
|
||||||
|
return new Map();
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
async getAllModels() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
localNode: null,
|
||||||
|
nodes: [],
|
||||||
|
models: {},
|
||||||
|
desiredDeployments: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
(server as unknown as {
|
||||||
|
gpuDetector: { detectGpus: () => Promise<unknown[]> };
|
||||||
|
}).gpuDetector = {
|
||||||
|
async detectGpus() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestHeaders = {
|
||||||
|
Authorization: 'Bearer valid-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await fetch(`http://127.0.0.1:${port}/v1/models`, { headers: requestHeaders });
|
||||||
|
assertEquals(first.status, 200);
|
||||||
|
await first.text();
|
||||||
|
|
||||||
|
const second = await fetch(`http://127.0.0.1:${port}/v1/models`, { headers: requestHeaders });
|
||||||
|
assertEquals(second.status, 200);
|
||||||
|
await second.text();
|
||||||
|
|
||||||
|
const third = await fetch(`http://127.0.0.1:${port}/v1/models`, { headers: requestHeaders });
|
||||||
|
assertEquals(third.status, 429);
|
||||||
|
assertEquals((await third.json()).error.type, 'rate_limit_exceeded');
|
||||||
|
|
||||||
|
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
||||||
|
assertEquals(health.status, 200);
|
||||||
|
await health.text();
|
||||||
|
|
||||||
|
const metrics = await fetch(`http://127.0.0.1:${port}/metrics`);
|
||||||
|
assertEquals(metrics.status, 200);
|
||||||
|
await metrics.text();
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class ApiServer {
|
|||||||
private requestCounts = new Map<string, number>();
|
private requestCounts = new Map<string, number>();
|
||||||
private authFailureCounts = new Map<string, number>();
|
private authFailureCounts = new Map<string, number>();
|
||||||
private serverErrorCounts = new Map<string, number>();
|
private serverErrorCounts = new Map<string, number>();
|
||||||
|
private rateLimitBuckets = new Map<string, { count: number; windowStart: number }>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: IApiConfig,
|
config: IApiConfig,
|
||||||
@@ -152,6 +153,12 @@ export class ApiServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isRequestWithinRateLimit(req)) {
|
||||||
|
this.sendError(res, 429, 'Rate limit exceeded', 'rate_limit_exceeded');
|
||||||
|
this.recordRequest(path, res.statusCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Route request
|
// Route request
|
||||||
try {
|
try {
|
||||||
await this.router.route(req, res, path);
|
await this.router.route(req, res, path);
|
||||||
@@ -352,6 +359,41 @@ export class ApiServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isRequestWithinRateLimit(req: http.IncomingMessage): boolean {
|
||||||
|
const configuredLimit = this.config.rateLimit;
|
||||||
|
if (!configuredLimit || configuredLimit <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.getRateLimitKey(req);
|
||||||
|
const now = Date.now();
|
||||||
|
const windowMs = 60 * 1000;
|
||||||
|
const bucket = this.rateLimitBuckets.get(key);
|
||||||
|
|
||||||
|
if (!bucket || now - bucket.windowStart >= windowMs) {
|
||||||
|
this.rateLimitBuckets.set(key, { count: 1, windowStart: now });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucket.count >= configuredLimit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.count += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRateLimitKey(req: http.IncomingMessage): string {
|
||||||
|
if (typeof req.headers.authorization === 'string') {
|
||||||
|
const match = req.headers.authorization.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
return `api_key:${match[1]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `ip:${req.socket.remoteAddress || 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
private incrementMetric(metric: Map<string, number>, path: string): void {
|
private incrementMetric(metric: Map<string, number>, path: string): void {
|
||||||
metric.set(path, (metric.get(path) || 0) + 1);
|
metric.set(path, (metric.get(path) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user