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: