Files
responder/tests/test_sync_handlers_offloop.py
kennethreitz 9c08451904 Run sync lifecycle and exception handlers off the event loop (#611)
## Summary

Follow-up to #610 from the same audit. Two remaining spots invoked
**synchronous** handlers directly on the event loop, so a blocking call
in any of them stalled the whole server:

- Sync `startup`/`shutdown` event handlers — `Router.trigger_event`
(`routes.py`)
- Sync exception handlers — `_wrap_exc_handler`'s adapter (`api.py`)

Both now route through `run_in_threadpool`, matching how the rest of the
framework dispatches sync callables (views, hooks, auth, dependency
providers).

## Tests

New `tests/test_sync_handlers_offloop.py` proves each path runs off the
loop, using the fact that `asyncio.get_running_loop()` succeeds on the
loop thread but raises `RuntimeError` inside a threadpool worker:

- sync startup handler runs off-loop
- sync shutdown handler runs off-loop
- sync exception handler runs off-loop (and still produces its response)

Full suite: 743 passed, 1 skipped (php not installed); ruff clean; mypy
clean.

## Notes

Changelog entry added under `[Unreleased]` — no version bump in this PR;
cut the release when you're ready.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:50:53 -04:00

66 lines
1.6 KiB
Python

"""Sync lifecycle and exception handlers must run off the event loop.
A blocking sync handler executed directly on the loop stalls the whole
server. Both paths now route through ``run_in_threadpool``. We detect this
by calling ``asyncio.get_running_loop()`` inside the handler: it succeeds on
the loop thread but raises ``RuntimeError`` inside a threadpool worker.
"""
import asyncio
import responder
def _ran_off_loop():
try:
asyncio.get_running_loop()
return False
except RuntimeError:
return True
def test_sync_startup_handler_runs_off_event_loop():
api = responder.API(allowed_hosts=[";"], session_https_only=False)
seen = {}
@api.on_event("startup")
def startup():
seen["off_loop"] = _ran_off_loop()
with api.requests:
pass
assert seen["off_loop"] is True
def test_sync_shutdown_handler_runs_off_event_loop():
api = responder.API(allowed_hosts=[";"], session_https_only=False)
seen = {}
@api.on_event("shutdown")
def shutdown():
seen["off_loop"] = _ran_off_loop()
with api.requests:
pass
assert seen["off_loop"] is True
def test_sync_exception_handler_runs_off_event_loop():
api = responder.API(allowed_hosts=[";"], session_https_only=False)
seen = {}
@api.exception_handler(ValueError)
def handle(req, resp, exc):
seen["off_loop"] = _ran_off_loop()
resp.media = {"error": "handled"}
@api.route("/boom")
def boom(req, resp):
raise ValueError("nope")
r = api.requests.get("/boom")
assert seen["off_loop"] is True
assert r.json() == {"error": "handled"}