Spaces:
Sleeping
Sleeping
File size: 11,156 Bytes
9f8362f 392b662 9f8362f 392b662 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | """Unit tests for src/api/middleware.py: RateLimiter token-bucket behavior in isolation from the live app."""
from __future__ import annotations
import json
import pytest
from src.api.middleware import RateLimiter, new_request_id
class TestNewRequestId:
def test_returns_unique_hex_strings(self):
ids = {new_request_id() for _ in range(100)}
assert len(ids) == 100
assert all(len(i) == 32 for i in ids) # uuid4().hex
class TestRateLimiter:
def test_allows_up_to_capacity(self):
limiter = RateLimiter(rpm=5)
results = [limiter.allow("1.2.3.4") for _ in range(5)]
assert all(results)
def test_rejects_beyond_capacity_within_the_same_instant(self):
limiter = RateLimiter(rpm=5)
for _ in range(5):
assert limiter.allow("1.2.3.4")
assert limiter.allow("1.2.3.4") is False
def test_buckets_are_independent_per_client(self):
limiter = RateLimiter(rpm=1)
assert limiter.allow("1.2.3.4") is True
assert limiter.allow("1.2.3.4") is False
# A different IP has its own untouched bucket.
assert limiter.allow("5.6.7.8") is True
def test_refills_over_time(self, monkeypatch):
import src.api.middleware as mw
t = [1000.0]
monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])
limiter = RateLimiter(rpm=60) # 1 token/sec
for _ in range(60):
assert limiter.allow("1.2.3.4")
assert limiter.allow("1.2.3.4") is False
t[0] += 5.0 # 5 seconds later -> 5 tokens refilled
allowed = [limiter.allow("1.2.3.4") for _ in range(5)]
assert all(allowed)
assert limiter.allow("1.2.3.4") is False
def test_distinct_clients_are_capped_not_retained_forever(self):
# Without a bound, a client that can vary its apparent source IP (trivial over IPv6) has an unbounded memory-exhaustion vector: one dict entry per distinct IP ever seen, forever.
limiter = RateLimiter(rpm=5, max_clients=10)
for i in range(1000):
limiter.allow(f"10.0.0.{i}")
assert len(limiter._buckets) == 10
def test_least_recently_seen_client_is_evicted_first(self):
limiter = RateLimiter(rpm=5, max_clients=2)
limiter.allow("a")
limiter.allow("b")
limiter.allow("a") # touches "a" again, so "b" is now the least-recently-seen
limiter.allow("c") # forces an eviction
assert set(limiter._buckets) == {"a", "c"}
def test_partially_drained_bucket_is_retained_across_calls(self):
limiter = RateLimiter(rpm=5)
limiter.allow("1.2.3.4")
limiter.allow("1.2.3.4")
assert "1.2.3.4" in limiter._buckets
tokens, _ = limiter._buckets["1.2.3.4"]
assert tokens == pytest.approx(3.0)
class TestRateLimiterPersistence:
"""state_path=None (every test above) is unaffected by any of this — persistence is opt-in, needed because supervisord's autorestart silently resets rate-limit state on every crash-restart."""
def test_no_state_path_means_no_persistence(self, tmp_path):
limiter = RateLimiter(rpm=5)
limiter.allow("1.2.3.4")
assert list(tmp_path.iterdir()) == []
def test_persists_state_and_reloads_across_instances(self, tmp_path, monkeypatch):
import src.api.middleware as mw
t = [1000.0]
monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])
state_path = tmp_path / "state.json"
limiter1 = RateLimiter(rpm=5, state_path=state_path)
assert limiter1.allow("1.2.3.4")
t[0] += 2.0 # past the persist-throttle interval, so each call below actually hits disk
assert limiter1.allow("1.2.3.4")
t[0] += 2.0
assert limiter1.allow("1.2.3.4")
# A fresh instance sharing state_path should see the drained bucket (2 of 5 tokens left), not the full 5 a reset-on-restart would give it.
limiter2 = RateLimiter(rpm=5, state_path=state_path)
assert limiter2.allow("1.2.3.4")
assert limiter2.allow("1.2.3.4")
assert limiter2.allow("1.2.3.4") is False
def test_persistence_is_throttled_within_the_interval(self, tmp_path, monkeypatch):
import src.api.middleware as mw
t = [1000.0]
monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])
state_path = tmp_path / "state.json"
limiter = RateLimiter(rpm=5, state_path=state_path)
limiter.allow("1.2.3.4")
first_write = state_path.read_text()
limiter.allow("1.2.3.4") # same instant — still inside the throttle window
assert state_path.read_text() == first_write
t[0] += 2.0
limiter.allow("1.2.3.4")
assert state_path.read_text() != first_write
def test_rejection_forces_a_persist_sooner_than_the_normal_throttle_window(self, tmp_path, monkeypatch):
# Verified against a real burst against a live uvicorn process first: a rapid burst can exhaust a bucket in-memory well before the next throttled write reaches disk, so a crash right after would recover a stale, under-depleted snapshot and hand out free requests. A rejection reaches disk within _FORCE_PERSIST_INTERVAL_S, well inside the normal 1s window — but still throttled, not unconditional (see the bounded-write-rate test below for why: cost scales with total tracked clients, not just the rejected one).
import src.api.middleware as mw
t = [1000.0]
monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])
state_path = tmp_path / "state.json"
limiter = RateLimiter(rpm=1, state_path=state_path)
assert limiter.allow("1.2.3.4") is True # first-ever persist, unconditional
first_write = state_path.read_text()
t[0] += 0.5 # past the force interval, still well inside the normal 1s throttle window
assert limiter.allow("1.2.3.4") is False # rejected — must still reach disk well before the normal window
assert state_path.read_text() != first_write
def test_rapid_repeated_rejections_do_not_each_force_a_write(self, tmp_path, monkeypatch):
# A client retrying immediately after a 429 with no backoff — malicious or just buggy — must not force a full-dict write on every single request: write cost scales with the *total* tracked-client count (every persist serializes the whole dict), not just the rejected client. Measured against a real RateLimiter before this fix: ~2.5ms/request at 5,000 tracked clients, ~66ms/request at the 100k cap, run synchronously on the single event-loop thread (allow() is called with no `await` before it in `async def observability`) — an unthrottled force write per rejection stalls every other visitor too.
import src.api.middleware as mw
t = [1000.0]
monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])
state_path = tmp_path / "state.json"
limiter = RateLimiter(rpm=1, state_path=state_path)
limiter.allow("1.2.3.4") # consumes the only token
write_calls = []
real_replace = mw.Path.replace
def counting_replace(self, target):
write_calls.append(1)
return real_replace(self, target)
monkeypatch.setattr(mw.Path, "replace", counting_replace)
for _ in range(500):
assert limiter.allow("1.2.3.4") is False # every call rejected, same instant — no time advances
assert len(write_calls) <= 1 # at most the throttle allows within a single instant, never one per rejection
def test_missing_state_file_starts_fresh(self, tmp_path):
limiter = RateLimiter(rpm=5, state_path=tmp_path / "does_not_exist.json")
assert limiter._buckets == {}
def test_corrupted_state_file_starts_fresh_instead_of_raising(self, tmp_path):
state_path = tmp_path / "state.json"
state_path.write_text("not valid json{{{")
limiter = RateLimiter(rpm=5, state_path=state_path)
assert limiter._buckets == {}
def test_refuses_to_persist_through_a_symlink(self, tmp_path):
# `not target.exists()` alone is a false-confidence assertion here: the write is a temp-file + rename(), which POSIX guarantees never dereferences a symlink at the destination — target would stay untouched even with the is_symlink() guard deleted entirely (verified by mutation). What the guard actually prevents is silently clobbering state_path's own symlink into a plain file; that's the assertion that actually distinguishes guarded from unguarded behavior.
target = tmp_path / "real_target.json"
state_path = tmp_path / "state.json"
state_path.symlink_to(target)
limiter = RateLimiter(rpm=5, state_path=state_path)
limiter.allow("1.2.3.4")
assert state_path.is_symlink()
assert not target.exists()
def test_refuses_to_load_through_a_symlink(self, tmp_path):
target = tmp_path / "real_target.json"
target.write_text('{"1.2.3.4": [0.0, 1000.0]}')
state_path = tmp_path / "state.json"
state_path.symlink_to(target)
limiter = RateLimiter(rpm=5, state_path=state_path)
assert limiter._buckets == {}
def test_oversized_state_file_starts_fresh_instead_of_loading(self, tmp_path):
# Content must be valid JSON, not just large bytes: a giant invalid-JSON blob (e.g. "x" * N) gets rejected by the parse-error path regardless of the size guard, so that version of this test passed even with the size check deleted entirely (found by mutation). Building real, valid, oversized JSON is the only way to prove the size guard itself is load-bearing.
import src.api.middleware as mw
entries = {str(i): [1.0, 2.0] for i in range(600_000)}
content = json.dumps(entries)
assert len(content) > mw._MAX_STATE_BYTES
state_path = tmp_path / "state.json"
state_path.write_text(content)
limiter = RateLimiter(rpm=5, state_path=state_path)
assert limiter._buckets == {}
@pytest.mark.parametrize(
"content",
[
"[1, 2, 3]", # valid JSON, but a list, not an object
'{"1.2.3.4": "oops"}', # value isn't a [tokens, last] pair at all
'{"1.2.3.4": ["not-a-number", "also-not"]}', # pair present, values aren't numeric
'{"1.2.3.4": [1.0]}', # pair present but wrong length
],
)
def test_malformed_but_valid_json_state_file_starts_fresh_instead_of_crashing(self, tmp_path, content):
# Demonstrated exploitable before this guard: each of these crashed __init__ with an unhandled AttributeError/ValueError/TypeError. Because `_rate_limiter = RateLimiter(state_path=...)` runs at src/api/main.py's module import time, that crash takes down the whole app on every single startup — turning a supervisord crash-restart into a permanent crash loop instead of recovering it, the opposite of what persistence was built for.
state_path = tmp_path / "state.json"
state_path.write_text(content)
limiter = RateLimiter(rpm=5, state_path=state_path)
assert limiter._buckets == {}
|