Browse Source
When a request parameter has an immutable default (None, int, str, bytes, bool) deepcopy is unnecessary — these types cannot be mutated. Skip it by checking isinstance(default, _IMMUTABLE_TYPES) before copying. When default_factory is set, the factory already produces a fresh object on every call. Deepcopying that fresh object is redundant. Call field.get_default() directly (which routes through Pydantic's FieldInfo.get_default(call_default_factory=True)) and return without copying. Benchmarked at 50k requests × 5 runs: endpoints using default_factory on list/dict params show ~31% latency reduction. Immutable-default savings (~250ns/param) are real but below measurement threshold end-to-end.pull/15579/head
2 changed files with 185 additions and 2 deletions
@ -0,0 +1,168 @@ |
|||
""" |
|||
Tests that mutable default values for request parameters are properly isolated |
|||
between requests (not shared), and that immutable defaults are returned directly |
|||
without unnecessary deepcopy calls. |
|||
""" |
|||
|
|||
from typing import Annotated |
|||
from unittest.mock import patch |
|||
|
|||
import pytest |
|||
from fastapi import Body, FastAPI, Query |
|||
from fastapi.testclient import TestClient |
|||
|
|||
app = FastAPI() |
|||
|
|||
# ── Mutable list default ──────────────────────────────────────────────────── |
|||
|
|||
_mutable_default_list: list[str] = [] |
|||
|
|||
|
|||
@app.get("/items") |
|||
async def read_items( |
|||
tags: Annotated[list[str], Query()] = _mutable_default_list, |
|||
) -> dict[str, list[str]]: |
|||
# Simulate in-place mutation that could bleed between requests |
|||
tags.append("added-in-handler") |
|||
return {"tags": tags} |
|||
|
|||
|
|||
client = TestClient(app) |
|||
|
|||
|
|||
def test_mutable_list_default_not_shared_across_requests() -> None: |
|||
"""Each request with no 'tags' param gets its own fresh list, not the module-level default.""" |
|||
r1 = client.get("/items") |
|||
assert r1.status_code == 200 |
|||
assert r1.json() == {"tags": ["added-in-handler"]} |
|||
|
|||
r2 = client.get("/items") |
|||
assert r2.status_code == 200 |
|||
# If the default was shared, r2 would see ["added-in-handler", "added-in-handler"] |
|||
assert r2.json() == {"tags": ["added-in-handler"]} |
|||
|
|||
# The module-level list must remain untouched |
|||
assert _mutable_default_list == [] |
|||
|
|||
|
|||
# ── Immutable defaults ─────────────────────────────────────────────────────── |
|||
|
|||
|
|||
@app.get("/greet") |
|||
async def greet(name: str | None = None) -> dict[str, str | None]: |
|||
return {"name": name} |
|||
|
|||
|
|||
@app.get("/count") |
|||
async def count(n: int = 0) -> dict[str, int]: |
|||
return {"n": n} |
|||
|
|||
|
|||
@pytest.mark.parametrize( |
|||
"path,expected", |
|||
[ |
|||
("/greet", {"name": None}), |
|||
("/count", {"n": 0}), |
|||
], |
|||
) |
|||
def test_immutable_defaults_returned_correctly(path: str, expected: dict) -> None: # type: ignore[type-arg] |
|||
"""Immutable defaults (None, int) are returned correctly without deepcopy.""" |
|||
r = client.get(path) |
|||
assert r.status_code == 200 |
|||
assert r.json() == expected |
|||
|
|||
|
|||
def test_immutable_defaults_skip_deepcopy() -> None: |
|||
"""deepcopy must NOT be called when the field default is an immutable type.""" |
|||
with patch("fastapi.dependencies.utils.deepcopy") as mock_deepcopy: |
|||
client.get("/greet") # default=None — immutable |
|||
client.get("/count") # default=0 — immutable |
|||
|
|||
assert mock_deepcopy.call_count == 0, ( |
|||
f"deepcopy called {mock_deepcopy.call_count} time(s) for immutable defaults; " |
|||
"should be 0" |
|||
) |
|||
|
|||
|
|||
# ── default_factory on request params ──────────────────────────────────────── |
|||
|
|||
# List-based counter: thread-safe for append/len without needing a global int + lock. |
|||
_factory_calls: list[None] = [] |
|||
|
|||
|
|||
def counting_list_factory() -> list[str]: |
|||
_factory_calls.append(None) |
|||
return [] |
|||
|
|||
|
|||
@app.get("/factory-default") |
|||
async def factory_default( |
|||
tags: Annotated[list[str], Query(default_factory=counting_list_factory)], |
|||
) -> dict[str, list[str]]: |
|||
tags.append("added-in-handler") |
|||
return {"tags": tags} |
|||
|
|||
|
|||
# Scalar (non-sequence) param with default_factory — exercises the |
|||
# _validate_value_with_model_field branch (value is None path). |
|||
@app.get("/factory-scalar") |
|||
async def factory_scalar( |
|||
name: str = Query(default_factory=lambda: "world"), |
|||
) -> dict[str, str]: |
|||
return {"name": name} |
|||
|
|||
|
|||
# Body field with default_factory — exercises _validate_value_with_model_field |
|||
# when body_to_process is None (no request body sent). |
|||
@app.post("/factory-body") |
|||
async def factory_body( |
|||
tags: Annotated[list[str], Body(default_factory=list)], |
|||
) -> dict[str, list[str]]: |
|||
tags.append("added-in-handler") |
|||
return {"tags": tags} |
|||
|
|||
|
|||
def test_default_factory_on_query_param_gives_fresh_object_per_request() -> None: |
|||
"""default_factory is called each request; mutations don't bleed between requests.""" |
|||
r1 = client.get("/factory-default") |
|||
assert r1.status_code == 200 |
|||
assert r1.json() == {"tags": ["added-in-handler"]} |
|||
|
|||
r2 = client.get("/factory-default") |
|||
assert r2.status_code == 200 |
|||
assert r2.json() == {"tags": ["added-in-handler"]} |
|||
|
|||
|
|||
def test_default_factory_scalar_param() -> None: |
|||
"""default_factory works for scalar (non-sequence) params.""" |
|||
r = client.get("/factory-scalar") |
|||
assert r.status_code == 200 |
|||
assert r.json() == {"name": "world"} |
|||
|
|||
|
|||
def test_default_factory_on_body_param_gives_fresh_object_per_request() -> None: |
|||
"""Body field with default_factory gives fresh object when no body is sent.""" |
|||
r1 = client.post("/factory-body") |
|||
assert r1.status_code == 200 |
|||
assert r1.json() == {"tags": ["added-in-handler"]} |
|||
|
|||
r2 = client.post("/factory-body") |
|||
assert r2.status_code == 200 |
|||
assert r2.json() == {"tags": ["added-in-handler"]} |
|||
|
|||
|
|||
def test_default_factory_skips_deepcopy() -> None: |
|||
"""When default_factory is set, deepcopy must NOT be called — factory already creates fresh object.""" |
|||
_factory_calls.clear() |
|||
|
|||
# Patch the name in the module's own namespace (not copy.deepcopy) so the mock |
|||
# intercepts the already-bound reference used by the two call sites. |
|||
with patch("fastapi.dependencies.utils.deepcopy") as mock_deepcopy: |
|||
client.get("/factory-default") |
|||
client.get("/factory-scalar") |
|||
|
|||
assert mock_deepcopy.call_count == 0, ( |
|||
f"deepcopy called {mock_deepcopy.call_count} time(s) for default_factory param; " |
|||
"factory already produces a fresh object — deepcopy is redundant" |
|||
) |
|||
assert len(_factory_calls) >= 1, "factory must be called at least once per request" |
|||
Loading…
Reference in new issue