Skip to content

[BUG] Rate Limiter Unbounded Memory Growth — Only Queried Bucket Is Cleaned #2058

Description

@ionfwsrijan

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

Metadata

Metadata

Assignees

Labels

area:backendBackend API, database, or service worklevel:intermediate35 pts difficulty label for moderate contributor PRspriority:mediumImportant issue with normal urgencytype:bugBug fix work category bonus labeltype:performancePerformance work category bonus label

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions