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:
co-authored by
Claude Opus 4.6
parent
f18b04ba20
commit
2576221e24
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user