project-nomad/admin/app/middleware/rate_limit_middleware.ts
Claude 912f1780ac
feat: add rate limiting middleware for expensive API endpoints
Add an in-memory sliding window rate limiter as AdonisJS named
middleware. Applied to expensive endpoints to prevent abuse:
- POST /api/system/services/install: 5 req/min
- POST /api/benchmark/run{,/system,/ai}: 3 req/min
- POST /api/rag/upload: 10 req/min
- POST /api/ollama/models (pull): 5 req/min

Returns 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining
headers. Expired entries are cleaned up periodically.

https://claude.ai/code/session_01JFvpTYgm8GiE4vJ4cJKsFx
2026-03-24 09:28:46 +00:00

87 lines
2.8 KiB
TypeScript

import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* In-memory sliding window rate limiter.
*
* Tracks requests by IP + route pattern and enforces a configurable
* maximum number of requests per time window. Returns 429 Too Many
* Requests with standard rate-limit headers when the limit is exceeded.
*/
interface RateLimitEntry {
timestamps: number[]
}
const store = new Map<string, RateLimitEntry>()
// Periodically clean up expired entries every 60 seconds
const CLEANUP_INTERVAL_MS = 60_000
setInterval(() => {
const now = Date.now()
for (const [key, entry] of store) {
// Remove entries whose newest timestamp is older than 5 minutes
// (the longest practical window we'd configure)
if (entry.timestamps.length === 0 || entry.timestamps[entry.timestamps.length - 1] < now - 5 * 60_000) {
store.delete(key)
}
}
}, CLEANUP_INTERVAL_MS).unref()
export default class RateLimitMiddleware {
/**
* @param limit - Maximum number of requests allowed in the window
* @param windowSeconds - Length of the sliding window in seconds
*/
async handle(
{ request, response }: HttpContext,
next: NextFn,
options: { limit: number; window: number }
) {
const limit = options.limit ?? 10
const windowSeconds = options.window ?? 60
const windowMs = windowSeconds * 1000
const now = Date.now()
const ip = request.ip()
const routePattern = request.ctx?.route?.pattern ?? request.url()
const key = `${ip}:${routePattern}`
let entry = store.get(key)
if (!entry) {
entry = { timestamps: [] }
store.set(key, entry)
}
// Slide the window: remove timestamps outside the current window
const windowStart = now - windowMs
entry.timestamps = entry.timestamps.filter((ts) => ts > windowStart)
if (entry.timestamps.length >= limit) {
const oldestInWindow = entry.timestamps[0]
const retryAfterMs = oldestInWindow + windowMs - now
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000)
response.header('Retry-After', String(retryAfterSeconds))
response.header('X-RateLimit-Limit', String(limit))
response.header('X-RateLimit-Remaining', '0')
response.header('X-RateLimit-Reset', String(Math.ceil((oldestInWindow + windowMs) / 1000)))
return response.status(429).send({
error: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${retryAfterSeconds} seconds.`,
})
}
// Record this request
entry.timestamps.push(now)
// Set informational rate-limit headers on successful requests
response.header('X-RateLimit-Limit', String(limit))
response.header('X-RateLimit-Remaining', String(limit - entry.timestamps.length))
return next()
}
}