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>
70 lines
2.7 KiB
Python
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)
|