diff --git a/src/dispatch.rs b/src/dispatch.rs index 5dbb04c..fec5c40 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -238,41 +238,6 @@ pub async fn run_rsgi( return send_handler_map(&protocol, is_head, mapped).await; } } - let read_fut = Python::with_gil(|py| { - let p = protocol.bind(py); - let aw: Bound = p.call0()?; - pyo3_async_runtimes::tokio::into_future(aw) - })?; - let body_obj: PyObject = read_fut.await?; - let body_bytes: Vec = Python::with_gil(|py| -> PyResult> { - let b = body_obj.bind(py); - if let Ok(x) = b.extract::>() { - return Ok(x); - } - if let Ok(s) = b.str() { - return Ok(s.to_string().into_bytes()); - } - Ok(Vec::new()) - })?; - let max = form::max_body_bytes(); - if (body_bytes.len() as u64) > max { - return response::send_text( - &protocol, - 413, - r#"{"error":"payload too large"}"#, - "application/json; charset=utf-8", - ) - .await; - } - let (auth, cookie_raw) = - Python::with_gil(|py| -> PyResult<(Option, Option)> { - let s = scope.bind(py); - let headers = s.getattr("headers")?; - Ok(( - header_get_lax(&headers, "authorization"), - header_get_lax(&headers, "cookie"), - )) - })?; let route_out: Option<(usize, HashMap)> = { let st = state.read(); match match_route(&st, &method, &path) { @@ -343,6 +308,41 @@ pub async fn run_rsgi( e.handler_varkw, )) })?; + let read_fut = Python::with_gil(|py| { + let p = protocol.bind(py); + let aw: Bound = p.call0()?; + pyo3_async_runtimes::tokio::into_future(aw) + })?; + let body_obj: PyObject = read_fut.await?; + let body_bytes: Vec = Python::with_gil(|py| -> PyResult> { + let b = body_obj.bind(py); + if let Ok(x) = b.extract::>() { + return Ok(x); + } + if let Ok(s) = b.str() { + return Ok(s.to_string().into_bytes()); + } + Ok(Vec::new()) + })?; + let max = form::max_body_bytes(); + if (body_bytes.len() as u64) > max { + return response::send_text( + &protocol, + 413, + r#"{"error":"payload too large"}"#, + "application/json; charset=utf-8", + ) + .await; + } + let (auth, cookie_raw) = + Python::with_gil(|py| -> PyResult<(Option, Option)> { + let s = scope.bind(py); + let headers = s.getattr("headers")?; + Ok(( + header_get_lax(&headers, "authorization"), + header_get_lax(&headers, "cookie"), + )) + })?; let mut claims_val: Option = None; if require_jwt { let key = match jwt_secret { diff --git a/tests/test_dispatch_fast_fail.py b/tests/test_dispatch_fast_fail.py new file mode 100644 index 0000000..d6cfc37 --- /dev/null +++ b/tests/test_dispatch_fast_fail.py @@ -0,0 +1,72 @@ +"""Issue #71: 404/405 should fail before request body read on RSGI path.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any + +from oxyroute import App + + +class _ProbeProtocol: + def __init__(self) -> None: + self.body_calls = 0 + self.sent: list[tuple[str, int, Any]] = [] + + async def __call__(self) -> bytes: + self.body_calls += 1 + return b'{"large":"payload"}' + + def response_str(self, status: int, _headers: list, body: str) -> None: + self.sent.append(("str", int(status), body)) + + def response_bytes(self, status: int, _headers: list, body: bytes) -> None: + self.sent.append(("bytes", int(status), body)) + + def response_empty(self, status: int, _headers: list) -> None: + self.sent.append(("empty", int(status), b"")) + + +def _scope(method: str, path: str) -> Any: + return SimpleNamespace( + proto="http", + method=method, + path=path, + query_string="", + headers={}, + ) + + +def test_rsgi_404_does_not_consume_body() -> None: + app = App() + + @app.get("/exists") + def exists() -> str: + return "ok" + + async def _run() -> None: + proto = _ProbeProtocol() + await app.__rsgi__(_scope("POST", "/missing"), proto) + assert proto.body_calls == 0 + assert proto.sent + assert proto.sent[-1][1] == 404 + + asyncio.run(_run()) + + +def test_rsgi_405_does_not_consume_body() -> None: + app = App() + + @app.post("/x") + def x() -> str: + return "ok" + + async def _run() -> None: + proto = _ProbeProtocol() + await app.__rsgi__(_scope("GET", "/x"), proto) + assert proto.body_calls == 0 + assert proto.sent + assert proto.sent[-1][1] == 405 + + asyncio.run(_run())