perfect-postcode/finder/test_http_client.py
2026-07-03 18:01:10 +01:00

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