pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
370 lines
11 KiB
370 lines
11 KiB
"""
|
|
Test for potential race conditions in async dependencies with BackgroundTasks.
|
|
|
|
These tests specifically look for:
|
|
1. Race conditions under concurrent requests
|
|
2. Cleanup order issues with nested dependencies
|
|
3. Exception handling during cleanup
|
|
4. Resource access after cleanup starts
|
|
"""
|
|
|
|
import asyncio
|
|
import threading
|
|
from collections import defaultdict
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
# Test 1: Verify cleanup timing under simulated high concurrency
|
|
def test_cleanup_timing_consistency():
|
|
"""
|
|
Test that cleanup timing is consistent across multiple requests.
|
|
The cleanup should always happen at the same point relative to background tasks.
|
|
"""
|
|
results: list[dict] = []
|
|
lock = threading.Lock()
|
|
|
|
app = FastAPI()
|
|
|
|
async def get_resource():
|
|
resource = {"state": "active", "cleanup_happened": False}
|
|
yield resource
|
|
resource["cleanup_happened"] = True
|
|
resource["state"] = "cleaned"
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
resource: dict = Depends(get_resource),
|
|
):
|
|
request_id = len(results)
|
|
|
|
async def bg_task(res: dict, req_id: int):
|
|
# Record state when background task runs
|
|
with lock:
|
|
results.append({
|
|
"request_id": req_id,
|
|
"state_in_bg": res["state"],
|
|
"cleanup_in_bg": res["cleanup_happened"],
|
|
})
|
|
|
|
background_tasks.add_task(bg_task, resource, request_id)
|
|
return {"request_id": request_id}
|
|
|
|
client = TestClient(app)
|
|
|
|
# Run multiple requests
|
|
for _ in range(20):
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# All results should be consistent - with default scope, cleanup should
|
|
# NOT have happened when background task runs
|
|
for result in results:
|
|
assert result["state_in_bg"] == "active", f"Inconsistent state: {result}"
|
|
assert result["cleanup_in_bg"] is False, f"Cleanup happened too early: {result}"
|
|
|
|
|
|
# Test 2: Test for cleanup happening during background task execution
|
|
def test_cleanup_during_background_task_execution():
|
|
"""
|
|
Test if cleanup can start while a background task is still running.
|
|
This shouldn't happen with proper request-scoped dependencies.
|
|
"""
|
|
events: list[str] = []
|
|
lock = threading.Lock()
|
|
|
|
app = FastAPI()
|
|
|
|
async def get_slow_resource():
|
|
with lock:
|
|
events.append("resource_start")
|
|
yield "resource"
|
|
with lock:
|
|
events.append("resource_cleanup")
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
resource: str = Depends(get_slow_resource),
|
|
):
|
|
async def slow_bg_task():
|
|
with lock:
|
|
events.append("bg_start")
|
|
await asyncio.sleep(0.05) # Simulate slow work
|
|
with lock:
|
|
events.append("bg_end")
|
|
|
|
background_tasks.add_task(slow_bg_task)
|
|
return {"status": "ok"}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# Verify order: resource_start -> bg_start -> bg_end -> resource_cleanup
|
|
assert events == ["resource_start", "bg_start", "bg_end", "resource_cleanup"]
|
|
|
|
|
|
# Test 3: Multiple yield dependencies with different cleanup times
|
|
def test_multiple_yield_deps_cleanup_order():
|
|
"""
|
|
Test that multiple yield dependencies clean up in the correct order.
|
|
"""
|
|
cleanup_order: list[str] = []
|
|
|
|
app = FastAPI()
|
|
|
|
async def dep_a():
|
|
yield "A"
|
|
cleanup_order.append("A")
|
|
|
|
async def dep_b():
|
|
yield "B"
|
|
cleanup_order.append("B")
|
|
|
|
async def dep_c(a: str = Depends(dep_a)):
|
|
yield f"C({a})"
|
|
cleanup_order.append("C")
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
a: str = Depends(dep_a),
|
|
b: str = Depends(dep_b),
|
|
c: str = Depends(dep_c),
|
|
):
|
|
async def bg_task():
|
|
cleanup_order.append("BG")
|
|
|
|
background_tasks.add_task(bg_task)
|
|
return {"a": a, "b": b, "c": c}
|
|
|
|
client = TestClient(app)
|
|
cleanup_order.clear()
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# Background task should run before cleanups
|
|
bg_idx = cleanup_order.index("BG")
|
|
# C depends on A, so C cleanup should happen before A
|
|
c_idx = cleanup_order.index("C")
|
|
a_idx = cleanup_order.index("A")
|
|
|
|
assert bg_idx < c_idx, "Background task should run before dependency cleanup"
|
|
assert c_idx < a_idx, "C should cleanup before A (dependency order)"
|
|
|
|
|
|
# Test 4: Exception in background task should not affect cleanup
|
|
def test_exception_in_background_task():
|
|
"""
|
|
Test that exceptions in background tasks don't prevent cleanup.
|
|
"""
|
|
events: list[str] = []
|
|
|
|
app = FastAPI()
|
|
|
|
async def get_resource():
|
|
events.append("setup")
|
|
try:
|
|
yield "resource"
|
|
finally:
|
|
events.append("cleanup")
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
resource: str = Depends(get_resource),
|
|
):
|
|
async def failing_bg_task():
|
|
events.append("bg_task")
|
|
raise ValueError("Background task error")
|
|
|
|
background_tasks.add_task(failing_bg_task)
|
|
return {"status": "ok"}
|
|
|
|
# Test with raise_server_exceptions=True to see the actual exception
|
|
client = TestClient(app, raise_server_exceptions=True)
|
|
events.clear()
|
|
|
|
# Single task failure: original exception is re-raised (backward compatible)
|
|
with pytest.raises(ValueError, match="Background task error"):
|
|
client.get("/test")
|
|
|
|
# Cleanup should still happen even if background task fails
|
|
assert "setup" in events
|
|
assert "bg_task" in events
|
|
assert "cleanup" in events
|
|
|
|
|
|
# Test 5: Test with generator (sync) dependencies under async workload
|
|
def test_sync_generator_with_async_background_task():
|
|
"""
|
|
Test sync generator dependencies with async background tasks.
|
|
"""
|
|
events: list[str] = []
|
|
|
|
app = FastAPI()
|
|
|
|
def sync_resource():
|
|
events.append("sync_setup")
|
|
yield "sync_resource"
|
|
events.append("sync_cleanup")
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
resource: str = Depends(sync_resource),
|
|
):
|
|
async def async_bg_task():
|
|
events.append("async_bg_start")
|
|
await asyncio.sleep(0.01)
|
|
events.append("async_bg_end")
|
|
|
|
background_tasks.add_task(async_bg_task)
|
|
return {"resource": resource}
|
|
|
|
client = TestClient(app)
|
|
events.clear()
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# Sync resource cleanup should happen after async background task
|
|
assert events == [
|
|
"sync_setup",
|
|
"async_bg_start",
|
|
"async_bg_end",
|
|
"sync_cleanup",
|
|
]
|
|
|
|
|
|
# Test 6: Test with concurrent modification of shared state
|
|
def test_concurrent_state_modification():
|
|
"""
|
|
Test behavior when background task modifies shared state that
|
|
dependency cleanup also accesses.
|
|
"""
|
|
shared_state = {"value": 0, "history": []}
|
|
|
|
app = FastAPI()
|
|
|
|
async def get_state():
|
|
shared_state["history"].append(f"setup:{shared_state['value']}")
|
|
shared_state["value"] = 1
|
|
yield shared_state
|
|
shared_state["history"].append(f"cleanup:{shared_state['value']}")
|
|
shared_state["value"] = 0
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
state: dict = Depends(get_state),
|
|
):
|
|
async def bg_task(s: dict):
|
|
s["history"].append(f"bg:{s['value']}")
|
|
s["value"] = 2
|
|
|
|
background_tasks.add_task(bg_task, state)
|
|
return {"value": state["value"]}
|
|
|
|
client = TestClient(app)
|
|
shared_state["value"] = 0
|
|
shared_state["history"] = []
|
|
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# History should show: setup (value 0) -> bg (value 1) -> cleanup (value 2)
|
|
assert shared_state["history"] == ["setup:0", "bg:1", "cleanup:2"]
|
|
|
|
|
|
# Test 7: Test deeply nested dependencies with background tasks
|
|
def test_deeply_nested_dependencies():
|
|
"""
|
|
Test deeply nested dependencies (4+ levels) with background tasks.
|
|
"""
|
|
cleanup_order: list[str] = []
|
|
|
|
app = FastAPI()
|
|
|
|
async def level1():
|
|
yield "L1"
|
|
cleanup_order.append("L1")
|
|
|
|
async def level2(l1: str = Depends(level1)):
|
|
yield f"L2({l1})"
|
|
cleanup_order.append("L2")
|
|
|
|
async def level3(l2: str = Depends(level2)):
|
|
yield f"L3({l2})"
|
|
cleanup_order.append("L3")
|
|
|
|
async def level4(l3: str = Depends(level3)):
|
|
yield f"L4({l3})"
|
|
cleanup_order.append("L4")
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
l4: str = Depends(level4),
|
|
):
|
|
async def bg_task(value: str):
|
|
cleanup_order.append(f"BG:{value}")
|
|
|
|
background_tasks.add_task(bg_task, l4)
|
|
return {"value": l4}
|
|
|
|
client = TestClient(app)
|
|
cleanup_order.clear()
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
assert response.json()["value"] == "L4(L3(L2(L1)))"
|
|
|
|
# BG should run first, then cleanups in reverse order (L4 -> L3 -> L2 -> L1)
|
|
assert cleanup_order[0] == "BG:L4(L3(L2(L1)))"
|
|
assert cleanup_order[1:] == ["L4", "L3", "L2", "L1"]
|
|
|
|
|
|
# Test 8: Test that function-scoped deps with BackgroundTasks logs warning or error
|
|
def test_function_scope_logs_potential_issue():
|
|
"""
|
|
Document that function scope with background tasks can cause issues.
|
|
The resource is cleaned up before the background task runs.
|
|
"""
|
|
events: list[str] = []
|
|
|
|
app = FastAPI()
|
|
|
|
async def get_resource():
|
|
events.append("setup")
|
|
resource = {"active": True}
|
|
yield resource
|
|
events.append("cleanup")
|
|
resource["active"] = False
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
resource: dict = Depends(get_resource, scope="function"),
|
|
):
|
|
async def bg_task(res: dict):
|
|
events.append(f"bg:active={res['active']}")
|
|
|
|
background_tasks.add_task(bg_task, resource)
|
|
return {"status": "ok"}
|
|
|
|
client = TestClient(app)
|
|
events.clear()
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# With function scope, cleanup happens BEFORE background task
|
|
# This is the documented "inconsistent" behavior
|
|
assert events == ["setup", "cleanup", "bg:active=False"]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|
|
|