mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-30 05:29:25 +02:00
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
87 lines
2.8 KiB
TypeScript
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()
|
|
}
|
|
}
|