90 lines
2.6 KiB
Python
90 lines
2.6 KiB
Python
"""Tests for the shared request rate limiter (http_client.RateLimiter).
|
|
|
|
Uses a fake clock so the spacing logic is asserted deterministically rather than
|
|
by sleeping for real."""
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
import http_client
|
|
import shutdown
|
|
from http_client import RateLimiter
|
|
|
|
|
|
class _FakeClock:
|
|
"""A monotonic clock that only advances when sleep() is called."""
|
|
|
|
def __init__(self, start=1000.0):
|
|
self.t = start
|
|
self.sleeps = []
|
|
|
|
def monotonic(self):
|
|
return self.t
|
|
|
|
def sleep(self, seconds):
|
|
self.sleeps.append(seconds)
|
|
self.t += seconds
|
|
|
|
|
|
def _install(monkeypatch):
|
|
clock = _FakeClock()
|
|
monkeypatch.setattr(http_client.time, "monotonic", clock.monotonic)
|
|
# The limiter waits via the interruptible shutdown.sleep so a Ctrl+C wakes a
|
|
# worker parked on a reserved rate-limit slot instead of stalling the exit.
|
|
monkeypatch.setattr(shutdown, "sleep", clock.sleep)
|
|
return clock
|
|
|
|
|
|
def test_first_acquire_does_not_sleep(monkeypatch):
|
|
clock = _install(monkeypatch)
|
|
RateLimiter(10).acquire()
|
|
assert clock.sleeps == []
|
|
|
|
|
|
def test_subsequent_acquires_are_spaced_by_the_interval(monkeypatch):
|
|
clock = _install(monkeypatch)
|
|
rl = RateLimiter(10) # 0.1s minimum interval
|
|
rl.acquire() # no wait
|
|
rl.acquire() # waits ~0.1
|
|
rl.acquire() # waits ~0.1
|
|
assert clock.sleeps == pytest.approx([0.1, 0.1])
|
|
|
|
|
|
def test_acquire_after_idle_gap_does_not_sleep(monkeypatch):
|
|
clock = _install(monkeypatch)
|
|
rl = RateLimiter(10)
|
|
rl.acquire()
|
|
clock.t += 5.0 # plenty of idle time passes
|
|
rl.acquire()
|
|
assert clock.sleeps == [] # the slot was already free
|
|
|
|
|
|
def test_zero_rate_disables_limiting(monkeypatch):
|
|
def _boom(_seconds): # pragma: no cover - must never be called
|
|
raise AssertionError("disabled limiter must not sleep")
|
|
|
|
monkeypatch.setattr(shutdown, "sleep", _boom)
|
|
rl = RateLimiter(0)
|
|
for _ in range(100):
|
|
rl.acquire()
|
|
|
|
|
|
def test_concurrent_acquires_are_all_spaced(monkeypatch):
|
|
# Real clock, tiny rate: N threads hitting acquire() at once must be
|
|
# serialised so the total wall time is at least (N-1) * interval.
|
|
rl = RateLimiter(200) # 5ms interval — fast but measurable
|
|
barrier = threading.Barrier(8)
|
|
|
|
def worker():
|
|
barrier.wait()
|
|
rl.acquire()
|
|
|
|
start = http_client.time.monotonic()
|
|
threads = [threading.Thread(target=worker) for _ in range(8)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
elapsed = http_client.time.monotonic() - start
|
|
assert elapsed >= 7 * 0.005 * 0.5 # allow scheduling slack, but not ~0
|