From 21cafad78fd7eac26ad7f9d806f389dd155a755d Mon Sep 17 00:00:00 2001
From: Rach Pradhan <54503978+justrach@users.noreply.github.com>
Date: Fri, 12 Jun 2026 21:58:40 +0800
Subject: [PATCH 1/2] fix: FastAPI-compatible structured 422s for Pydantic
model validation errors
Pydantic models already work as request bodies via the model_validate
duck-typing path, but validation failures were stringified into a
{"error": "Bad Request"} 400 blob instead of FastAPI's structured shape.
dhi models get proper 422s from the Zig-native validator, so the two
model layers disagreed on error contracts.
RequestParsingError now optionally carries structured error details,
extracted from the failing exception:
- pydantic.ValidationError -> e.errors() dicts mapped to {loc, msg, type}
with loc prefixed by ["body", ] (FastAPI convention)
- dhi.ValidationErrors -> .errors list of (field, message) objects
(covers the pure-Python dhi fallback path)
Catch sites return 422 {"detail": [...]} when structured details exist,
matching the Zig validator's output; everything else keeps the legacy
400 shape for backward compatibility.
Zero hot-path cost: detail extraction only runs after validation has
already failed. Verified no new failures in tests/test_post_body_parsing.py,
test_fastapi_parity.py, test_fastapi_compatibility.py (92 passed; the one
failure, test_dhi_model_validation, pre-exists on main with dhi 1.3.7 and
1.4.1 alike).
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
python/turboapi/request_handler.py | 104 ++++++++++++++++++++++++-----
1 file changed, 86 insertions(+), 18 deletions(-)
diff --git a/python/turboapi/request_handler.py b/python/turboapi/request_handler.py
index fc903c1..0c26766 100644
--- a/python/turboapi/request_handler.py
+++ b/python/turboapi/request_handler.py
@@ -41,7 +41,66 @@ def _returns_model(handler) -> bool | None:
return None
class RequestParsingError(ValueError):
- """Raised when request data cannot be parsed into handler parameters."""
+ """Raised when request data cannot be parsed into handler parameters.
+
+ ``errors`` optionally carries FastAPI-style structured validation details
+ (``[{"loc": [...], "msg": ..., "type": ...}, ...]``). It is only populated
+ on the error path, so successful requests pay zero extra cost.
+ """
+
+ def __init__(self, message: str, errors: list | None = None):
+ super().__init__(message)
+ self.errors = errors
+
+
+def _validation_error_details(exc, param_name: str) -> list | None:
+ """Extract FastAPI-style error details from a validation exception.
+
+ Supports both Pydantic (``exc.errors()`` returning dicts) and dhi
+ (``exc.errors`` list of objects with ``field``/``message``). Returns None
+ for anything else so callers fall back to the legacy string detail.
+ Only ever invoked after validation has already failed (error path).
+ """
+ try:
+ errors_attr = getattr(exc, "errors", None)
+ if callable(errors_attr): # pydantic.ValidationError
+ details = []
+ for err in errors_attr():
+ loc = ["body", param_name, *(str(p) for p in err.get("loc", ()))]
+ details.append({
+ "loc": loc,
+ "msg": err.get("msg", "Invalid value"),
+ "type": err.get("type", "value_error"),
+ })
+ return details or None
+ if isinstance(errors_attr, (list, tuple)): # dhi.ValidationErrors
+ details = []
+ for err in errors_attr:
+ field = getattr(err, "field", None)
+ msg = getattr(err, "message", None)
+ if field is None and msg is None:
+ return None
+ details.append({
+ "loc": ["body", param_name] + ([str(field)] if field else []),
+ "msg": str(msg) if msg is not None else "Invalid value",
+ "type": "value_error",
+ })
+ return details or None
+ except Exception:
+ return None
+ return None
+
+
+def _parsing_error_response(e: RequestParsingError) -> tuple[int, dict]:
+ """(status_code, payload) for a RequestParsingError.
+
+ Structured validation failures use FastAPI-compatible 422 responses
+ (matching the Zig-native dhi validator); everything else keeps the
+ legacy 400 shape.
+ """
+ if getattr(e, "errors", None):
+ return 422, {"detail": e.errors}
+ return 400, {"error": "Bad Request", "detail": str(e)}
class DependencyResolver:
@@ -427,7 +486,10 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
parsed_params[param_name] = validated_model
return parsed_params
except Exception as e:
- raise RequestParsingError(f"Validation error for {param_name}: {e}")
+ raise RequestParsingError(
+ f"Validation error for {param_name}: {e}",
+ errors=_validation_error_details(e, param_name),
+ )
# If annotated as dict or list, pass entire body
elif param.annotation in (dict, list) or param.annotation == inspect.Parameter.empty:
@@ -448,7 +510,10 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
parsed_params[param_name] = validated_model
return parsed_params
except Exception as e:
- raise RequestParsingError(f"Validation error for {param_name}: {e}")
+ raise RequestParsingError(
+ f"Validation error for {param_name}: {e}",
+ errors=_validation_error_details(e, param_name),
+ )
# Unknown class annotation with single param — try direct construction
if inspect.isclass(param.annotation):
@@ -481,14 +546,20 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
validated_model = param.annotation.model_validate(json_data)
parsed_params[param_name] = validated_model
except Exception as e:
- raise RequestParsingError(f"Validation error for {param_name}: {e}")
+ raise RequestParsingError(
+ f"Validation error for {param_name}: {e}",
+ errors=_validation_error_details(e, param_name),
+ )
# Check for Pydantic-like model (model_validate but not Satya)
elif inspect.isclass(param.annotation) and hasattr(param.annotation, "model_validate"):
try:
parsed_params[param_name] = param.annotation.model_validate(json_data)
except Exception as e:
- raise RequestParsingError(f"Validation error for {param_name}: {e}")
+ raise RequestParsingError(
+ f"Validation error for {param_name}: {e}",
+ errors=_validation_error_details(e, param_name),
+ )
# Check if parameter name exists in JSON data
elif param_name in json_data:
value = json_data[param_name]
@@ -1040,9 +1111,8 @@ async def enhanced_handler(**kwargs):
)
except RequestParsingError as e:
- return ResponseHandler.format_json_response(
- {"error": "Bad Request", "detail": str(e)}, 400
- )
+ status, payload = _parsing_error_response(e)
+ return ResponseHandler.format_json_response(payload, status)
except Exception as e:
from turboapi.security import HTTPException
@@ -1223,9 +1293,8 @@ def enhanced_handler(**kwargs):
)
except RequestParsingError as e:
- return ResponseHandler.format_json_response(
- {"error": "Bad Request", "detail": str(e)}, 400
- )
+ status, payload = _parsing_error_response(e)
+ return ResponseHandler.format_json_response(payload, status)
except Exception as e:
from turboapi.security import HTTPException
@@ -1415,7 +1484,8 @@ def fast_handler(**kwargs):
return (result[1], "application/json", _dumps(result[0]))
return (200, "application/json", _dumps(result))
except RequestParsingError as e:
- return (400, "application/json", _dumps({"error": "Bad Request", "detail": str(e)}))
+ status, payload = _parsing_error_response(e)
+ return (status, "application/json", _dumps(payload))
except Exception as e:
if isinstance(e, HTTPException):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
@@ -1494,11 +1564,8 @@ def fast_handler_eager(**kwargs):
try:
return _run_eager(original_handler(**build_call_kwargs(kwargs)))
except RequestParsingError as e:
- return (
- 400,
- "application/json",
- _dumps({"error": "Bad Request", "detail": str(e)}),
- )
+ status, payload = _parsing_error_response(e)
+ return (status, "application/json", _dumps(payload))
except Exception as e:
if isinstance(e, HTTPException):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
@@ -1524,7 +1591,8 @@ async def fast_handler(**kwargs):
return (result[1], "application/json", _dumps(result[0]))
return (200, "application/json", _dumps(result))
except RequestParsingError as e:
- return (400, "application/json", _dumps({"error": "Bad Request", "detail": str(e)}))
+ status, payload = _parsing_error_response(e)
+ return (status, "application/json", _dumps(payload))
except Exception as e:
if isinstance(e, HTTPException):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
From 0bf3a41e20efcb894d181944c72870da50528e3b Mon Sep 17 00:00:00 2001
From: Rach Pradhan <54503978+justrach@users.noreply.github.com>
Date: Fri, 12 Jun 2026 22:17:32 +0800
Subject: [PATCH 2/2] fix: byte-exact FastAPI 422 parity (loc convention,
input/ctx fields)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verified against a live FastAPI app with the identical pydantic model:
the 422 payload is now byte-for-byte identical.
- Single body model: loc is ["body", ] (no param name), matching
FastAPI's non-embedded convention; multi-param bodies keep
["body", , ] via embed=True
- Include "input" and "ctx" from pydantic error dicts when their values
are JSON-safe (FastAPI runs them through jsonable_encoder; we skip
exotic values instead of risking a serialization crash on the error path)
Interleaved load test (12 threads, keep-alive): patched vs unpatched
identical within noise on both model layers (~90k req/s dhi, ~85k req/s
pydantic) — the change remains error-path-only.
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
python/turboapi/request_handler.py | 35 +++++++++++++++++++++---------
1 file changed, 25 insertions(+), 10 deletions(-)
diff --git a/python/turboapi/request_handler.py b/python/turboapi/request_handler.py
index 0c26766..b5d63cf 100644
--- a/python/turboapi/request_handler.py
+++ b/python/turboapi/request_handler.py
@@ -53,25 +53,40 @@ def __init__(self, message: str, errors: list | None = None):
self.errors = errors
-def _validation_error_details(exc, param_name: str) -> list | None:
+_JSON_SAFE = (str, int, float, bool, type(None), list, dict)
+
+
+def _validation_error_details(exc, param_name: str, embed: bool = False) -> list | None:
"""Extract FastAPI-style error details from a validation exception.
Supports both Pydantic (``exc.errors()`` returning dicts) and dhi
(``exc.errors`` list of objects with ``field``/``message``). Returns None
for anything else so callers fall back to the legacy string detail.
Only ever invoked after validation has already failed (error path).
+
+ ``embed`` mirrors FastAPI's loc convention: a single body model maps to
+ ["body", ], while embedded/multi-param bodies include the
+ parameter name: ["body", , ].
"""
+ prefix = ["body", param_name] if embed else ["body"]
try:
errors_attr = getattr(exc, "errors", None)
if callable(errors_attr): # pydantic.ValidationError
details = []
for err in errors_attr():
- loc = ["body", param_name, *(str(p) for p in err.get("loc", ()))]
- details.append({
- "loc": loc,
- "msg": err.get("msg", "Invalid value"),
+ detail = {
"type": err.get("type", "value_error"),
- })
+ "loc": [*prefix, *(str(p) for p in err.get("loc", ()))],
+ "msg": err.get("msg", "Invalid value"),
+ }
+ # Match FastAPI's payload where safely JSON-serializable
+ if isinstance(err.get("input"), _JSON_SAFE):
+ detail["input"] = err["input"]
+ if isinstance(err.get("ctx"), dict) and all(
+ isinstance(v, _JSON_SAFE) for v in err["ctx"].values()
+ ):
+ detail["ctx"] = err["ctx"]
+ details.append(detail)
return details or None
if isinstance(errors_attr, (list, tuple)): # dhi.ValidationErrors
details = []
@@ -81,9 +96,9 @@ def _validation_error_details(exc, param_name: str) -> list | None:
if field is None and msg is None:
return None
details.append({
- "loc": ["body", param_name] + ([str(field)] if field else []),
- "msg": str(msg) if msg is not None else "Invalid value",
"type": "value_error",
+ "loc": prefix + ([str(field)] if field else []),
+ "msg": str(msg) if msg is not None else "Invalid value",
})
return details or None
except Exception:
@@ -548,7 +563,7 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
except Exception as e:
raise RequestParsingError(
f"Validation error for {param_name}: {e}",
- errors=_validation_error_details(e, param_name),
+ errors=_validation_error_details(e, param_name, embed=True),
)
# Check for Pydantic-like model (model_validate but not Satya)
@@ -558,7 +573,7 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
except Exception as e:
raise RequestParsingError(
f"Validation error for {param_name}: {e}",
- errors=_validation_error_details(e, param_name),
+ errors=_validation_error_details(e, param_name, embed=True),
)
# Check if parameter name exists in JSON data
elif param_name in json_data: