Description
RateLimiter.task_history is a defaultdict(list) mapping bucket keys to timestamp lists. The cleanup logic in can_execute() (lines 51–54) only purges stale entries from the specific bucket being checked. All other buckets accumulate timestamps indefinitely. Over a long-running SecuScan instance with many (client_id, plugin_id) combinations, this causes unbounded memory growth since old entries in unchecked buckets are never evicted.
Affected Files
backend/secuscan/ratelimit.py (lines 18, 51–54)
Expected Behaviour
Periodic sweep of all buckets, or LRU-style eviction when total memory exceeds a threshold. No bucket should accumulate timestamps beyond the configured retention window.
Actual Behaviour
Only the queried bucket is cleaned; all other buckets leak timestamps forever. Over days/weeks of operation with diverse client/plugin combinations, the task_history dict grows without bound, eventually consuming significant memory and degrading performance.
Proposed Fix
# ratelimit.py — Add a periodic full sweep to RateLimiter
import time
from datetime import datetime, timedelta
from collections import defaultdict
from typing import Dict, List, Tuple
class RateLimiter:
_FULL_SWEEP_INTERVAL = 60 # seconds between full sweeps
def __init__(self):
self.task_history: Dict[str, List[datetime]] = defaultdict(list)
self.lock = asyncio.Lock()
self._last_full_sweep: float = time.time()
async def can_execute(
self,
plugin_id: str,
max_per_hour: int = 50,
client_id: str = "global",
) -> Tuple[bool, str]:
bucket = f"{client_id}:{plugin_id}"
async with self.lock:
now = datetime.now()
hour_ago = now - timedelta(hours=1)
# Clean old entries for this bucket
self.task_history[bucket] = [
ts for ts in self.task_history[bucket]
if ts > hour_ago
]
# Periodic full sweep of all buckets
if time.time() - self._last_full_sweep > self._FULL_SWEEP_INTERVAL:
stale_buckets = [
k for k, v in self.task_history.items()
if not v or all(ts <= hour_ago for ts in v)
]
for k in stale_buckets:
del self.task_history[k]
self._last_full_sweep = time.time()
recent_count = len(self.task_history[bucket])
if recent_count >= max_per_hour:
return (
False,
f"Rate limit exceeded: "
f"{recent_count}/{max_per_hour} per hour",
)
self.task_history[bucket].append(now)
return True, ""
Additionally, add a safety cap on total bucket count to prevent pathological cases:
MAX_BUCKETS = 10_000
async def can_execute(self, plugin_id, max_per_hour=50, client_id="global"):
bucket = f"{client_id}:{plugin_id}"
async with self.lock:
# ... existing cleanup logic ...
# Safety cap: if too many buckets, force full sweep
if len(self.task_history) > self.MAX_BUCKETS:
stale_buckets = [
k for k, v in self.task_history.items()
if not v or all(ts <= hour_ago for ts in v)
]
for k in stale_buckets:
del self.task_history[k]
# ... rest of logic
Description
RateLimiter.task_historyis adefaultdict(list)mapping bucket keys to timestamp lists. The cleanup logic incan_execute()(lines 51–54) only purges stale entries from the specific bucket being checked. All other buckets accumulate timestamps indefinitely. Over a long-running SecuScan instance with many (client_id, plugin_id) combinations, this causes unbounded memory growth since old entries in unchecked buckets are never evicted.Affected Files
backend/secuscan/ratelimit.py(lines 18, 51–54)Expected Behaviour
Periodic sweep of all buckets, or LRU-style eviction when total memory exceeds a threshold. No bucket should accumulate timestamps beyond the configured retention window.
Actual Behaviour
Only the queried bucket is cleaned; all other buckets leak timestamps forever. Over days/weeks of operation with diverse client/plugin combinations, the
task_historydict grows without bound, eventually consuming significant memory and degrading performance.Proposed Fix
Additionally, add a safety cap on total bucket count to prevent pathological cases: