Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/body-adapter-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Static body adapter reuse

The default Pydantic decorator path prepares one body `TypeAdapter` during
decoration. The adapter is private to that decorator instance and reused only
for the identical model object. Direct adapter calls and explicitly supplied
custom adapters keep their existing `parse_body(req, model)` contract. Query
validation continues to use `model_validate`; no global cache is introduced.
Failed preparation retains the prior request-time error behavior. Dynamic
model rebuilds after decoration are not a static contract and require redecorating.

Reproduce with `hatch run python scripts/benchmark_body_adapter.py` after environment
setup. Five repeats of 10,000 validations compare construction plus validation
against a prebuilt adapter, reporting the minimum per-call time. Local Python
3.11.15 / Pydantic 2.14.1 observations:

| Annotation | Construct each call | Reuse | Saved per validation |
| --- | ---: | ---: | ---: |
| Small model | 5.638 μs | 0.728 μs | 4.910 μs |
| Nested model | 6.223 μs | 1.225 μs | 4.998 μs |
| Small-or-nested union | 75.183 μs | 1.000 μs | 74.183 μs |

These are adapter-only microbenchmarks, not HTTP latency promises. The cost moves
to decoration and retains one adapter/model reference per default decorator.
Custom adapters receive no new required keyword arguments or mutable shared cache.
24 changes: 24 additions & 0 deletions scripts/benchmark_body_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from timeit import repeat
from typing import Any

from pydantic import BaseModel, TypeAdapter


class Small(BaseModel):
value: int


class Nested(BaseModel):
items: list[Small]


cases: tuple[tuple[Any, Any], ...] = (
(Small, {"value": 1}),
(Nested, {"items": [{"value": 1}]}),
(Small | Nested, {"value": 1}),
)
for model, data in cases:
adapter = TypeAdapter(model)
fresh = min(repeat(lambda: TypeAdapter(model).validate_python(data), number=10000, repeat=5))
cached = min(repeat(lambda: adapter.validate_python(data), number=10000, repeat=5))
print(f"{model}: fresh={fresh * 100:.3f} us cached={cached * 100:.3f} us")
7 changes: 6 additions & 1 deletion src/azure_functions_validation/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ def __init__(self, *, legacy_loc: bool = False) -> None:
hatch and will be removed in a future release.
"""
self._legacy_loc = legacy_loc
self._prepared_body: tuple[Any, TypeAdapter[Any]] | None = None

@staticmethod
def _to_adapter_error(
Expand Down Expand Up @@ -412,7 +413,11 @@ def parse_body(self, req: HttpRequest, model: Any) -> Any:

# Validate with Pydantic
try:
return TypeAdapter(model).validate_python(data)
prepared = self._prepared_body
adapter = (
prepared[1] if prepared is not None and prepared[0] is model else TypeAdapter(model)
)
return adapter.validate_python(data)
except PydanticValidationError as exc:
raise self._source_error(exc, "body") from exc

Expand Down
7 changes: 7 additions & 0 deletions src/azure_functions_validation/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def validate_http(
body = request_model

# Use default adapter if none provided
default_adapter = adapter is None
if adapter is None:
adapter = PydanticAdapter(legacy_loc=legacy_loc)

Expand Down Expand Up @@ -159,6 +160,12 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:

is_async = inspect.iscoroutinefunction(func)

if default_adapter and isinstance(adapter, PydanticAdapter) and body is not None:
try:
adapter._prepared_body = (body, TypeAdapter(body))
except Exception:
adapter._prepared_body = None

func_sig = inspect.signature(func)
func_params = func_sig.parameters

Expand Down
39 changes: 39 additions & 0 deletions tests/test_body_adapter_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import json
from unittest.mock import patch

from azure.functions import HttpRequest
from pydantic import BaseModel

from azure_functions_validation import PydanticAdapter, validate_http


class Body(BaseModel):
value: int


def test_static_body_adapter_is_prepared_before_requests() -> None:
@validate_http(body=Body)
def handler(req: HttpRequest, body: Body) -> dict[str, int]:
return {"value": body.value}

request = HttpRequest("POST", "https://example.test", body=b'{"value":1}')
with patch(
"azure_functions_validation.adapter.TypeAdapter", side_effect=AssertionError("rebuilt")
):
assert handler(request).status_code == 200
assert handler(request).status_code == 200


def test_custom_adapter_parse_signature_and_shared_models_remain_independent() -> None:
class Custom(PydanticAdapter):
def parse_body(self, req: HttpRequest, model: type[BaseModel]) -> BaseModel:
return model.model_validate({"value": 9})

adapter = Custom()

@validate_http(body=Body, query=Body, adapter=adapter)
def handler(req: HttpRequest, body: Body, query: Body) -> dict[str, int]:
return {"value": body.value + query.value}

request = HttpRequest("POST", "https://example.test", params={"value": "2"}, body=b"{}")
assert json.loads(handler(request).get_body()) == {"value": 11}
Loading