Files
responder/tests/test_concurrency_hardening.py
kennethreitz 428181a096 Add JWT/OAuth2 auth, Problem exception, function middleware, and a broad feature sweep for 8.1.0
Flagship: JWTAuth (HS256/RS256/ES256, exp/aud/iss, JWKS with off-loop
fetch, scope claims -> requires()) and OAuth2Auth flow descriptions that
light up Swagger UI's Authorize button, behind a new responder[jwt] extra.

Also additive: responder.Problem (raisable RFC 9457) + RFC 9457 abort();
@api.middleware function-style HTTP middleware; Pydantic bodies and
Query/Header/Cookie/Path markers on class-based and WebSocket handlers;
Form-model binding; write-side preconditions (If-Match/If-Unmodified-Since
-> 412); q-ranked content negotiation; get_list for repeated headers;
status_code= route option; OpenAPI document caching; rate-limiter key=/
fail-open/X-RateLimit-Reset; configurable metrics buckets + in-flight
gauge; RFC 8288 pagination Link headers; Redis session serializer;
unified request-ID policy; CLI --host/--port/--server/--reload; optional
orjson encode backend; AsyncTestClient + SSE test helpers; fakeredis
backend coverage. Five DeprecationWarnings start the 9.0 clock.

Every feature was implemented test-first, then the combined diff went
through a 64-agent adversarial review that caught 19 issues including two
criticals (an auth scope-claim regression that flipped authorization
decisions, and a sync-middleware threadpool deadlock) -- all fixed with
regression tests before release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:08:42 -04:00

141 lines
4.6 KiB
Python

"""Concurrency & resource-exhaustion hardening:
- MetricsCollector is lock-guarded (no crash / lost counts under concurrency)
- rate-limit MemoryBackend evicts keys (bounded memory)
- SSE heartbeat uses a bounded queue (backpressure, correct delivery)
- Templates use a dedicated async env (no shared is_async toggle race)
"""
import asyncio
import threading
from responder.ext.metrics import MetricsCollector
from responder.ext.ratelimit import MemoryBackend
from responder.models import _sse_with_heartbeat
from responder.templates import Templates
# --------------------------------------------------------------------------
# Metrics collector — concurrent record() / render()
# --------------------------------------------------------------------------
def test_metrics_collector_concurrent_record_and_render():
collector = MetricsCollector()
errors = []
stop = threading.Event()
def writer():
while not stop.is_set():
collector.record("GET", "/x", 200, 0.01)
def reader():
try:
while not stop.is_set():
collector.render()
except Exception as exc: # a lock-free version raised "changed size"
errors.append(exc)
threads = [threading.Thread(target=writer) for _ in range(4)]
threads += [threading.Thread(target=reader) for _ in range(2)]
for t in threads:
t.start()
threading.Event().wait(0.2)
stop.set()
for t in threads:
t.join()
assert not errors
# No increments were lost to races.
assert collector.requests[("GET", "/x", "200")] > 0
def test_metrics_record_counts_are_exact_under_threads():
collector = MetricsCollector()
n = 2000
threads = [
threading.Thread(
target=lambda: [collector.record("GET", "/y", 200, 0.01) for _ in range(n)]
)
for _ in range(4)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert collector.requests[("GET", "/y", "200")] == n * 4
# --------------------------------------------------------------------------
# Rate-limit MemoryBackend — bounded keys
# --------------------------------------------------------------------------
def test_memory_backend_evicts_least_recently_used_keys():
backend = MemoryBackend(max_keys=3)
for i in range(10):
backend.hit(f"ip-{i}", max_requests=5, period=60)
assert len(backend._buckets) <= 3
# The most recent keys are retained.
assert "ip-9" in backend._buckets
assert "ip-0" not in backend._buckets
def test_memory_backend_still_limits_within_cap():
backend = MemoryBackend(max_keys=100)
results = [backend.hit("client", max_requests=2, period=60) for _ in range(3)]
assert [allowed for allowed, *_ in results] == [True, True, False]
# --------------------------------------------------------------------------
# SSE heartbeat — bounded queue delivers all items in order
# --------------------------------------------------------------------------
def test_sse_heartbeat_bounded_queue_delivers_in_order():
async def produce():
for i in range(50):
yield {"data": str(i)}
async def drain():
seen = []
# Tiny queue + a long heartbeat interval so no keepalives fire.
async for item in _sse_with_heartbeat(produce(), interval=100, maxsize=2):
seen.append(item["data"])
return seen
seen = asyncio.run(drain())
assert seen == [str(i) for i in range(50)]
# --------------------------------------------------------------------------
# Templates — dedicated async environment, no shared toggle
# --------------------------------------------------------------------------
def test_templates_use_separate_async_env(tmp_path):
(tmp_path / "t.html").write_text("{{ var }}")
templates = Templates(directory=tmp_path)
assert templates._env.is_async is False
assert templates._async_env.is_async is True
async def render_it():
return await templates.render_async("t.html", var="hi")
assert asyncio.run(render_it()) == "hi"
# The sync env's is_async was never toggled by the async render.
assert templates._env.is_async is False
# Sync render still works afterward.
assert templates.render("t.html", var="yo") == "yo"
def test_templates_share_globals_across_envs(tmp_path):
(tmp_path / "t.html").write_text("{{ g }}")
templates = Templates(directory=tmp_path, context={"g": "global"})
assert templates.render("t.html") == "global"
async def render_it():
return await templates.render_async("t.html")
assert asyncio.run(render_it()) == "global"