Files
hermes-webui/tests/test_rate_limit_map_prune.py
T
7a1a122e6e Release Batch C: hidden-tab dashboard poll + SSE write-deadline + rate-limit map bound (#5840, #5841, #5842) (#5847)
Phase 2 low-risk backend/perf batch (ai-ag2026 reliability cluster). Each byte-verified
vs PR head, Codex SAFE, 33 own + 720 regression tests pass, disjoint routes.py regions.

- #5840: skip dashboard status poll while tab hidden (perf; forced polls unaffected)
- #5841: arm write deadline on session-events invalidation SSE (stuck-client teardown)
- #5842: bound client rate-limit maps by sweeping stale keys (long-uptime memory)

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 10:38:47 -07:00

70 lines
2.7 KiB
Python

"""Regression test — the client rate-limit maps don't grow without bound.
`_csp_report_rate_limited` / `_client_event_rate_limited` key a timestamp list
by client IP and always re-store `[now]` for the calling key, so a key is only
ever revisited when that same IP calls again. An IP that hits the endpoint once
and never returns left its entry in the map forever. Behind a reverse proxy the
map holds a single key (the proxy IP), but a directly-exposed deployment would
accumulate one entry per distinct IP.
The functions now sweep keys whose newest timestamp has aged past the window,
size-gated so the common few-key path stays O(1).
"""
import api.routes as routes
def test_prune_drops_only_fully_stale_keys(monkeypatch):
monkeypatch.setattr(routes, "_RATE_LIMIT_MAP_SWEEP_THRESHOLD", 2)
now = 1_000_000.0
window = 60.0
cutoff = now - window
mapping = {
"stale-1": [now - 120.0], # aged out
"stale-2": [now - 90.0, now - 61.0], # newest still older than cutoff
"fresh": [now - 10.0], # within window
"empty": [], # degenerate, drop
}
routes._prune_stale_rate_limit_keys(mapping, cutoff)
assert set(mapping) == {"fresh"}, "prune must keep only keys active in the window"
def test_prune_is_size_gated_noop_below_threshold(monkeypatch):
monkeypatch.setattr(routes, "_RATE_LIMIT_MAP_SWEEP_THRESHOLD", 100)
mapping = {"stale": [0.0], "also-stale": [1.0]}
routes._prune_stale_rate_limit_keys(mapping, cutoff=1_000_000.0)
# Under the threshold nothing is swept — keeps the hot path O(1).
assert set(mapping) == {"stale", "also-stale"}
def test_map_bounded_across_many_one_shot_ips(monkeypatch):
"""End-to-end: many distinct IPs each calling once must not grow the map
without bound once it crosses the sweep threshold."""
routes._CSP_REPORT_RATE_LIMIT.clear()
monkeypatch.setattr(routes, "_RATE_LIMIT_MAP_SWEEP_THRESHOLD", 50)
base = 2_000_000.0
# 200 distinct IPs, each a single hit far enough apart that older ones age out.
for i in range(200):
# Advance time by 2s per IP so the first entries fall outside the 60s window.
routes._csp_report_rate_limited(
_ip_handler(f"198.51.100.{i % 256}.{i}"), now=base + i * 2.0
)
# Without the sweep this would hold ~200 keys; with it, only the recent window.
assert len(routes._CSP_REPORT_RATE_LIMIT) <= 60, (
f"rate-limit map grew unbounded: {len(routes._CSP_REPORT_RATE_LIMIT)} keys"
)
routes._CSP_REPORT_RATE_LIMIT.clear()
class _IpHandler:
def __init__(self, ip):
self.client_address = (ip, 12345)
self.headers = {}
def _ip_handler(ip):
return _IpHandler(ip)