mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
efb05827b7
- Mounted sub-app dispatch inferred WSGI-vs-ASGI from the *text* of a
TypeError raised while calling the app ("argument"/"positional"), so a
genuine TypeError inside a mounted ASGI app was misclassified as WSGI and
its real traceback destroyed. Decide from the call signature up front
(_is_wsgi_app): coroutine / async __call__ / __asgi_app__ => ASGI, a
two-positional (environ, start_response) signature => WSGI.
- The lifespan handler was called with scope.get("app"), but Responder never
set scope["app"], so a standard `async def lifespan(app)` always received
None. Set scope["app"] = self.api before invoking it.
- auto_etag hashed the response body with MD5 on the event loop; for a large
auto-etagged body that blocks the loop. Offload the hash to a thread above
64 KiB; small bodies stay inline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99 lines
2.9 KiB
Python
99 lines
2.9 KiB
Python
"""Mount, lifespan, and auto-etag fixes:
|
|
|
|
- a user lifespan context manager receives the API as ``app`` (was None)
|
|
- WSGI-vs-ASGI mount dispatch is decided by signature, so a genuine TypeError
|
|
inside a mounted ASGI sub-app keeps its real traceback
|
|
- auto_etag still tags large bodies (hashing offloaded off the event loop)
|
|
"""
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
import responder
|
|
|
|
|
|
def test_lifespan_receives_the_api_as_app():
|
|
seen = {}
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
seen["app"] = app
|
|
yield
|
|
|
|
api = responder.API(lifespan=lifespan, allowed_hosts=[";"])
|
|
|
|
@api.route("/")
|
|
def index(req, resp):
|
|
resp.text = "ok"
|
|
|
|
with api.requests as session:
|
|
session.get("http://;/")
|
|
|
|
assert seen["app"] is api
|
|
|
|
|
|
def test_mounted_asgi_app_real_error_is_not_masked():
|
|
api = responder.API(allowed_hosts=["localhost"])
|
|
|
|
async def broken_asgi(scope, receive, send):
|
|
# A genuine bug inside the ASGI app — NOT a call-signature error.
|
|
raise TypeError("boom: a real bug with the word argument in it")
|
|
|
|
api.mount("/sub", broken_asgi)
|
|
|
|
@api.route("/")
|
|
def index(req, resp):
|
|
resp.text = "root"
|
|
|
|
client = TestClient(api, base_url="http://localhost", raise_server_exceptions=True)
|
|
# The real TypeError must propagate, not be swallowed by a WSGI fallback.
|
|
with pytest.raises(TypeError, match="boom"):
|
|
client.get("/sub")
|
|
|
|
|
|
def test_mounted_asgi_app_still_works():
|
|
api = responder.API(allowed_hosts=["localhost"])
|
|
|
|
async def asgi_ok(scope, receive, send):
|
|
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
await send({"type": "http.response.body", "body": b"asgi-ok"})
|
|
|
|
api.mount("/sub", asgi_ok)
|
|
r = TestClient(api, base_url="http://localhost").get("/sub")
|
|
assert r.status_code == 200
|
|
assert r.text == "asgi-ok"
|
|
|
|
|
|
def test_mounted_wsgi_app_still_works():
|
|
flask = pytest.importorskip("flask")
|
|
wsgi = flask.Flask(__name__)
|
|
|
|
@wsgi.route("/")
|
|
def home():
|
|
return "wsgi-ok"
|
|
|
|
api = responder.API(allowed_hosts=["localhost"])
|
|
api.mount("/wsgi", wsgi)
|
|
r = TestClient(api, base_url="http://localhost").get("/wsgi/")
|
|
assert r.status_code == 200
|
|
assert "wsgi-ok" in r.text
|
|
|
|
|
|
def test_auto_etag_large_body_is_tagged():
|
|
api = responder.API(allowed_hosts=[";"], auto_etag=True, session_https_only=False)
|
|
|
|
@api.route("/big")
|
|
def big(req, resp):
|
|
resp.text = "x" * 200_000 # exceeds the off-loop hashing threshold
|
|
|
|
r = TestClient(api, base_url="http://;").get("/big")
|
|
assert r.status_code == 200
|
|
assert "ETag" in r.headers
|
|
# And the ETag actually validates (conditional 304).
|
|
r2 = TestClient(api, base_url="http://;").get(
|
|
"/big", headers={"If-None-Match": r.headers["ETag"]}
|
|
)
|
|
assert r2.status_code == 304
|