Skip to content

Commit 14672e9

Browse files
sansyroxclaude
andauthored
perf: skip Python Response wrapping for bare dict/list/str/bytes returns (#1384)
* perf: skip Python Response wrapping for bare dict/list/str/bytes returns Handlers that return a dict/list/str/bytes no longer allocate a Python-side `Response(...)` or fresh `Headers({...})` per request. The router now lets those values flow through to the Rust executor, which: - downcasts `#[pyclass] Response` directly (0 getattr calls) when the user did return one, - serializes bare dicts/lists via a cached `orjson.dumps`, - wraps bare str/bytes with prebuilt content-type headers, - falls back to the existing `FromPyObject<Response>` chain for subclasses. Also skips the per-request `contextvars.Context()` allocation when no middleware is registered — the #1380 cross-phase context is only needed when `before_request`/`after_request` hooks can mutate `ContextVar`s. Local micro-benchmark on `/json` (workers=1, processes=1, single-threaded urllib client): 4013 rps -> 5728 rps (+43%). All 339 integration tests and 23 unit tests pass (6 pre-existing `test_openapi_schema.py` failures unrelated to this change). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: address CodeRabbit feedback - cargo fmt: wrap the `get_description_from_pyobject` call that rustfmt flagged on the PyResponse fast path. - `downcast_exact::<PyResponse>()` so user Response subclasses with overridden properties fall through to the getattr slow path, preserving their custom read semantics. - `downcast` (not `downcast_exact`) for PyDict/PyList/PyString/PyBytes so primitive subclasses match the Python router's `isinstance(...)` gate instead of falling through to the FromPyObject chain (which would fail with TypeError). - Revert the conditional per-request `contextvars.Context()`: with it skipped, sync handlers calling `ContextVar.set(...)` would leak values into the next request on the same worker thread, weakening the #1380 isolation guarantee. The J1-J4 fast paths alone still deliver the win (~+43% locally on /json with workers=1, processes=1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fdd45ef commit 14672e9

3 files changed

Lines changed: 167 additions & 41 deletions

File tree

robyn/router.py

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@
2525
_logger = logging.getLogger(__name__)
2626

2727

28+
# Prebuilt Headers singletons reused across every request so the hot path
29+
# doesn't allocate a fresh DashMap per response. These objects are passed
30+
# into `Response(...)` as-is; the Rust side clones them into an independent
31+
# `Headers` struct during extraction, so server-side header injection never
32+
# mutates the singleton. Handlers that return a bare dict/list/str/bytes
33+
# never receive the Response and can't mutate it either.
34+
_JSON_HEADERS = Headers({"Content-Type": "application/json"})
35+
_TEXT_HEADERS = Headers({"Content-Type": "text/plain"})
36+
37+
2838
def lower_http_method(method: HttpMethod):
2939
return (str(method))[11:].lower()
3040

@@ -80,7 +90,7 @@ def _format_tuple_response(self, res: tuple) -> Response:
8090
def _format_response(
8191
self,
8292
res: Union[Dict, List, Response, StreamingResponse, bytes, tuple, str],
83-
) -> Union[Response, StreamingResponse]:
93+
) -> Union[Response, StreamingResponse, dict, list, str, bytes]:
8494
if isinstance(res, Response):
8595
return res
8696

@@ -91,16 +101,15 @@ def _format_response(
91101
if pydantic_json is not None:
92102
return Response(
93103
status_code=status_codes.HTTP_200_OK,
94-
headers=Headers({"Content-Type": "application/json"}),
104+
headers=_JSON_HEADERS,
95105
description=pydantic_json,
96106
)
97107

98-
if isinstance(res, (dict, list)):
99-
return Response(
100-
status_code=status_codes.HTTP_200_OK,
101-
headers=Headers({"Content-Type": "application/json"}),
102-
description=jsonify(res),
103-
)
108+
# Bare dict/list/str/bytes are handled natively in the Rust executor:
109+
# it serializes dicts/lists via orjson and wraps str/bytes directly,
110+
# skipping the per-request Python-side Response/Headers construction.
111+
if isinstance(res, (dict, list, str, bytes)):
112+
return res
104113

105114
if isinstance(res, FileResponse):
106115
response: Response = Response(
@@ -111,19 +120,12 @@ def _format_response(
111120
response.file_path = res.file_path
112121
return response
113122

114-
if isinstance(res, bytes):
115-
return Response(
116-
status_code=status_codes.HTTP_200_OK,
117-
headers=Headers({"Content-Type": "application/octet-stream"}),
118-
description=res,
119-
)
120-
121123
if isinstance(res, tuple):
122124
return self._format_tuple_response(tuple(res))
123125

124126
return Response(
125127
status_code=status_codes.HTTP_200_OK,
126-
headers=Headers({"Content-Type": "text/plain"}),
128+
headers=_TEXT_HEADERS,
127129
description=str(res).encode("utf-8"),
128130
)
129131

@@ -206,7 +208,7 @@ def wrapped_handler(*args, **kwargs):
206208
except ValueError as e:
207209
return Response(
208210
status_code=status_codes.HTTP_400_BAD_REQUEST,
209-
headers=Headers({"Content-Type": "application/json"}),
211+
headers=_JSON_HEADERS,
210212
description=jsonify({"error": f"Invalid JSON body: {e}"}),
211213
)
212214
elif issubclass(handler_param_type, Body):
@@ -219,7 +221,7 @@ def wrapped_handler(*args, **kwargs):
219221
except ValueError as e:
220222
return Response(
221223
status_code=status_codes.HTTP_400_BAD_REQUEST,
222-
headers=Headers({"Content-Type": "application/json"}),
224+
headers=_JSON_HEADERS,
223225
description=jsonify({"error": f"Invalid JSON body: {e}"}),
224226
)
225227

@@ -280,13 +282,13 @@ async def async_inner_handler(*args, **kwargs):
280282
except QueryParamValidationError as err:
281283
response = Response(
282284
status_code=status_codes.HTTP_400_BAD_REQUEST,
283-
headers=Headers({"Content-Type": "text/plain"}),
285+
headers=_TEXT_HEADERS,
284286
description=str(err),
285287
)
286288
except PydanticBodyValidationError as err:
287289
response = Response(
288290
status_code=status_codes.HTTP_422_UNPROCESSABLE_ENTITY,
289-
headers=Headers({"Content-Type": "application/json"}),
291+
headers=_JSON_HEADERS,
290292
description=jsonify(err.error_detail),
291293
)
292294
except Exception as err:
@@ -306,13 +308,13 @@ def inner_handler(*args, **kwargs):
306308
except QueryParamValidationError as err:
307309
response = Response(
308310
status_code=status_codes.HTTP_400_BAD_REQUEST,
309-
headers=Headers({"Content-Type": "text/plain"}),
311+
headers=_TEXT_HEADERS,
310312
description=str(err),
311313
)
312314
except PydanticBodyValidationError as err:
313315
response = Response(
314316
status_code=status_codes.HTTP_422_UNPROCESSABLE_ENTITY,
315-
headers=Headers({"Content-Type": "application/json"}),
317+
headers=_JSON_HEADERS,
316318
description=jsonify(err.error_detail),
317319
)
318320
except Exception as err:

src/executors/mod.rs

Lines changed: 138 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,153 @@ use std::sync::Arc;
55

66
use anyhow::Result;
77
use pyo3::prelude::*;
8+
use pyo3::sync::PyOnceLock;
9+
use pyo3::types::{PyBytes, PyDict, PyList, PyString};
810
use pyo3::{BoundObject, IntoPyObject};
911
use pyo3_async_runtimes::TaskLocals;
1012

1113
use crate::asyncio::run_in_context_helper;
14+
use crate::types::cookie::Cookies;
15+
use crate::types::headers::Headers;
16+
use crate::types::response::PyResponse;
1217
use crate::types::{
1318
function_info::FunctionInfo,
1419
request::Request,
1520
response::{Response, ResponseType, StreamingResponse},
1621
MiddlewareReturn,
1722
};
1823

24+
/// Cached `orjson.dumps` callable. Resolved once at first use, reused across
25+
/// every request that returns a bare `dict`/`list` from its handler.
26+
static ORJSON_DUMPS: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
27+
28+
fn orjson_dumps<'py>(py: Python<'py>) -> PyResult<&'py Bound<'py, PyAny>> {
29+
Ok(ORJSON_DUMPS
30+
.get_or_try_init(py, || -> PyResult<Py<PyAny>> {
31+
Ok(py.import("orjson")?.getattr("dumps")?.unbind())
32+
})?
33+
.bind(py))
34+
}
35+
36+
#[inline]
37+
fn json_headers() -> Headers {
38+
let h = Headers::default();
39+
h.headers
40+
.entry("content-type".to_string())
41+
.or_default()
42+
.push("application/json".to_string());
43+
h
44+
}
45+
46+
#[inline]
47+
fn text_plain_headers() -> Headers {
48+
let h = Headers::default();
49+
h.headers
50+
.entry("content-type".to_string())
51+
.or_default()
52+
.push("text/plain".to_string());
53+
h
54+
}
55+
56+
#[inline]
57+
fn octet_stream_headers() -> Headers {
58+
let h = Headers::default();
59+
h.headers
60+
.entry("content-type".to_string())
61+
.or_default()
62+
.push("application/octet-stream".to_string());
63+
h
64+
}
65+
66+
#[inline]
67+
fn response_from_bytes(body: Vec<u8>, headers: Headers) -> Response {
68+
Response {
69+
status_code: 200,
70+
response_type: "text".to_string(),
71+
headers,
72+
description: body,
73+
file_path: None,
74+
cookies: Cookies::default(),
75+
}
76+
}
77+
78+
/// Try every fast path before falling back to the generic `FromPyObject for
79+
/// Response` conversion. Order matters:
80+
/// 1. `#[pyclass] Response` — read fields from the Rust struct directly.
81+
/// 2. `dict`/`list` — hand to orjson, skip Python-side wrapping.
82+
/// 3. `str`/`bytes` — wrap in a preset Response.
83+
/// 4. `StreamingResponse` — existing extract path.
84+
/// 5. Fallback — `FromPyObject` chain (handles subclasses).
85+
#[inline]
86+
fn extract_response_type_fast(output: &Bound<'_, PyAny>) -> PyResult<ResponseType> {
87+
let py = output.py();
88+
89+
// 1. PyResponse pyclass downcast — zero getattr calls.
90+
// `downcast_exact` (not `downcast`) so that user `Response` subclasses
91+
// with overridden properties fall through to the getattr-based slow
92+
// path below, preserving their custom read semantics.
93+
if let Ok(py_resp) = output.downcast_exact::<PyResponse>() {
94+
let borrowed = py_resp.borrow();
95+
let description =
96+
crate::types::get_description_from_pyobject(borrowed.description.bind(py))?;
97+
let headers = borrowed.headers.borrow(py).clone();
98+
let cookies = borrowed.cookies.borrow(py).clone();
99+
return Ok(ResponseType::Standard(Response {
100+
status_code: borrowed.status_code,
101+
response_type: borrowed.response_type.clone(),
102+
headers,
103+
description,
104+
file_path: borrowed.file_path.clone(),
105+
cookies,
106+
}));
107+
}
108+
109+
// 2. Bare dict/list — serialize via orjson directly in Rust.
110+
// Uses subclass-aware `downcast` to match the Python router's
111+
// `isinstance(res, (dict, list, ...))` check; orjson serializes
112+
// dict/list subclasses as their base type by default.
113+
if output.downcast::<PyDict>().is_ok() || output.downcast::<PyList>().is_ok() {
114+
let dumps = orjson_dumps(py)?;
115+
let encoded = dumps.call1((output,))?;
116+
let bytes = encoded.downcast::<PyBytes>()?.as_bytes().to_vec();
117+
return Ok(ResponseType::Standard(response_from_bytes(
118+
bytes,
119+
json_headers(),
120+
)));
121+
}
122+
123+
// 3. Bare str/bytes — `downcast` (subclass-aware) to match the Python
124+
// router's `isinstance` gate.
125+
if let Ok(s) = output.downcast::<PyString>() {
126+
let bytes = s.to_string().into_bytes();
127+
return Ok(ResponseType::Standard(response_from_bytes(
128+
bytes,
129+
text_plain_headers(),
130+
)));
131+
}
132+
if let Ok(b) = output.downcast::<PyBytes>() {
133+
let bytes = b.as_bytes().to_vec();
134+
return Ok(ResponseType::Standard(response_from_bytes(
135+
bytes,
136+
octet_stream_headers(),
137+
)));
138+
}
139+
140+
// 4. StreamingResponse (duck-typed via `content`/`media_type` attrs).
141+
if let Ok(streaming) = output.extract::<StreamingResponse>() {
142+
return Ok(ResponseType::Streaming(streaming));
143+
}
144+
145+
// 5. Slow-path fallback: anything that implements the Response protocol
146+
// (e.g. user subclasses) still works through the getattr chain.
147+
match output.extract::<Response>() {
148+
Ok(response) => Ok(ResponseType::Standard(response)),
149+
Err(_) => Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
150+
"Function must return a Response, StreamingResponse, dict, list, str, or bytes",
151+
)),
152+
}
153+
}
154+
19155
#[inline]
20156
fn get_function_output<'a, T>(
21157
function: &'a FunctionInfo,
@@ -410,29 +546,12 @@ pub async fn execute_http_function(
410546

411547
#[inline]
412548
fn extract_response_type(output: Py<PyAny>, py: Python) -> PyResult<ResponseType> {
413-
// Try Response first (most common case), then StreamingResponse
414-
match output.extract::<Response>(py) {
415-
Ok(response) => Ok(ResponseType::Standard(response)),
416-
Err(_) => match output.extract::<StreamingResponse>(py) {
417-
Ok(streaming_response) => Ok(ResponseType::Streaming(streaming_response)),
418-
Err(_) => Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
419-
"Function must return a Response or StreamingResponse",
420-
)),
421-
},
422-
}
549+
extract_response_type_fast(output.bind(py))
423550
}
424551

425552
#[inline]
426553
fn extract_response_type_bound(output: pyo3::Bound<'_, pyo3::PyAny>) -> PyResult<ResponseType> {
427-
match output.extract::<Response>() {
428-
Ok(response) => Ok(ResponseType::Standard(response)),
429-
Err(_) => match output.extract::<StreamingResponse>() {
430-
Ok(streaming_response) => Ok(ResponseType::Streaming(streaming_response)),
431-
Err(_) => Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
432-
"Function must return a Response or StreamingResponse",
433-
)),
434-
},
435-
}
554+
extract_response_type_fast(&output)
436555
}
437556

438557
pub async fn execute_startup_handler(

src/server.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,11 @@ async fn index(
507507
// handler and to `after_request` hooks. asyncio copies the current context
508508
// for each task it creates, so without this shared object each phase would
509509
// see its own isolated snapshot (see issue #1380).
510+
//
511+
// This also provides per-request ContextVar isolation for sync handlers
512+
// (which run via `ctx.run(...)`): without a fresh context, a `ContextVar`
513+
// written inside a handler would persist in the worker thread's current
514+
// context and leak into the next request on that thread.
510515
let request_context: Py<PyAny> = match Python::with_gil(crate::asyncio::new_context) {
511516
Ok(ctx) => ctx,
512517
Err(e) => {

0 commit comments

Comments
 (0)