fix: replace sort-based rate-limit eviction with Map insertion-order iteration (BUG-S9)

O(k) oldest-first eviction instead of O(n log n) sort. Added 10k benchmark test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-12 23:56:41 +00:00
co-authored by Claude Opus 4.6
parent f18b04ba20
commit 2576221e24
2 changed files with 38 additions and 8 deletions
+26
View File
@@ -91,6 +91,32 @@ describe('rateLimit', () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
}) })
it('evicts oldest entries when map exceeds MAX_MAP_SIZE (10k benchmark)', async () => {
vi.useFakeTimers()
const app = new Hono()
// Window of 60s so entries don't expire during test
app.get('/test', rateLimit(60_000, 100), (c) => c.json({ ok: true }))
// Fill with 10,001 unique IPs to exceed MAX_MAP_SIZE (10,000)
for (let i = 0; i < 10_001; i++) {
const ip = `${(i >> 24) & 0xff}.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i & 0xff}`
await app.request('/test', { headers: { 'x-real-ip': ip } })
}
// Trigger cleanup interval (every 5 min)
vi.advanceTimersByTime(5 * 60 * 1000)
// First IP (0.0.0.0) should have been evicted — gets a fresh counter
const res = await app.request('/test', { headers: { 'x-real-ip': '0.0.0.0' } })
expect(res.status).toBe(200)
// Last IP (0.0.39.17) should still be tracked — counter increments
const last = await app.request('/test', { headers: { 'x-real-ip': '0.0.39.17' } })
expect(last.status).toBe(200) // still under limit (count now 2)
vi.useRealTimers()
})
it('poll endpoint config: 429 after 30 requests in 1s window', async () => { it('poll endpoint config: 429 after 30 requests in 1s window', async () => {
const app = new Hono() const app = new Hono()
// Matches the actual /poll endpoint configuration // Matches the actual /poll endpoint configuration
+12 -8
View File
@@ -14,18 +14,22 @@ export const cleanupInterval = setInterval(() => {
for (const [key, entry] of map) { for (const [key, entry] of map) {
if (now > entry.resetAt) map.delete(key) if (now > entry.resetAt) map.delete(key)
} }
// Evict oldest if over limit // Evict oldest entries (Map iterates in insertion order)
if (map.size > MAX_MAP_SIZE) { if (map.size > MAX_MAP_SIZE) {
const sorted = [...map.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt) let toRemove = map.size - MAX_MAP_SIZE
const toRemove = sorted.slice(0, map.size - MAX_MAP_SIZE) for (const key of map.keys()) {
for (const [key] of toRemove) map.delete(key) if (toRemove-- <= 0) break
map.delete(key)
}
} }
} }
// Also clean bot hit counts // Also clean bot hit counts (Map iterates in insertion order)
if (botHitCounts.size > MAX_MAP_SIZE) { if (botHitCounts.size > MAX_MAP_SIZE) {
const sorted = [...botHitCounts.entries()].sort((a, b) => a[1] - b[1]) let toRemove = botHitCounts.size - MAX_MAP_SIZE
const toRemove = sorted.slice(0, botHitCounts.size - MAX_MAP_SIZE) for (const key of botHitCounts.keys()) {
for (const [key] of toRemove) botHitCounts.delete(key) if (toRemove-- <= 0) break
botHitCounts.delete(key)
}
} }
}, 5 * 60 * 1000) }, 5 * 60 * 1000)