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.
355 lines
12 KiB
355 lines
12 KiB
"""
|
|
Test for async dependencies with cleanup (yield) and BackgroundTasks interaction.
|
|
|
|
This tests the scenario where dependencies with cleanup logic (yield) may behave
|
|
inconsistently when used inside BackgroundTasks under different scope settings.
|
|
|
|
The key issue:
|
|
- Dependencies with scope="function" have cleanup run BEFORE BackgroundTasks execute
|
|
- Dependencies with scope="request" (default) have cleanup run AFTER BackgroundTasks execute
|
|
|
|
This can lead to inconsistent behavior where a resource is cleaned up before
|
|
a background task that depends on it has a chance to run.
|
|
"""
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import BackgroundTasks, Depends, FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
# Track cleanup and background task execution order
|
|
execution_log: list[str] = []
|
|
|
|
|
|
def reset_log():
|
|
execution_log.clear()
|
|
|
|
|
|
# Test 1: Default scope (request) - cleanup should happen AFTER background task
|
|
def test_background_task_with_request_scope_yield_dependency():
|
|
"""
|
|
With default (request) scope, dependency cleanup should happen AFTER
|
|
the background task completes.
|
|
"""
|
|
reset_log()
|
|
app = FastAPI()
|
|
resource_value = {"status": "initialized"}
|
|
|
|
async def get_resource():
|
|
execution_log.append("resource_setup")
|
|
resource_value["status"] = "active"
|
|
yield resource_value
|
|
execution_log.append("resource_cleanup")
|
|
resource_value["status"] = "cleaned_up"
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
resource: dict = Depends(get_resource),
|
|
):
|
|
async def bg_task(res: dict):
|
|
execution_log.append(f"bg_task_start:status={res['status']}")
|
|
await asyncio.sleep(0.01) # Simulate async work
|
|
execution_log.append(f"bg_task_end:status={res['status']}")
|
|
|
|
background_tasks.add_task(bg_task, resource)
|
|
return {"status": resource["status"]}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "active"}
|
|
|
|
# Verify order: setup -> bg_task -> cleanup
|
|
assert execution_log == [
|
|
"resource_setup",
|
|
"bg_task_start:status=active",
|
|
"bg_task_end:status=active",
|
|
"resource_cleanup",
|
|
]
|
|
|
|
|
|
# Test 2: Function scope - cleanup happens BEFORE background task (BUG!)
|
|
def test_background_task_with_function_scope_yield_dependency():
|
|
"""
|
|
With scope="function", dependency cleanup happens BEFORE the background task,
|
|
which is inconsistent and potentially problematic.
|
|
|
|
This test demonstrates the issue: the resource is cleaned up before
|
|
the background task runs, so the background task sees the cleaned up state.
|
|
"""
|
|
reset_log()
|
|
app = FastAPI()
|
|
resource_value = {"status": "initialized"}
|
|
|
|
async def get_resource():
|
|
execution_log.append("resource_setup")
|
|
resource_value["status"] = "active"
|
|
yield resource_value
|
|
execution_log.append("resource_cleanup")
|
|
resource_value["status"] = "cleaned_up"
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
resource: dict = Depends(get_resource, scope="function"),
|
|
):
|
|
async def bg_task(res: dict):
|
|
execution_log.append(f"bg_task_start:status={res['status']}")
|
|
await asyncio.sleep(0.01)
|
|
execution_log.append(f"bg_task_end:status={res['status']}")
|
|
|
|
background_tasks.add_task(bg_task, resource)
|
|
return {"status": resource["status"]}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "active"}
|
|
|
|
# With function scope, cleanup happens BEFORE background task runs
|
|
# This is the inconsistent behavior!
|
|
assert execution_log == [
|
|
"resource_setup",
|
|
"resource_cleanup", # Cleanup happens first!
|
|
"bg_task_start:status=cleaned_up", # BG task sees cleaned up state
|
|
"bg_task_end:status=cleaned_up",
|
|
]
|
|
|
|
|
|
# Test 3: Database-like connection scenario with function scope
|
|
def test_database_connection_closed_before_background_task():
|
|
"""
|
|
Simulates a real-world scenario where a database connection is closed
|
|
before a background task that needs it runs.
|
|
"""
|
|
reset_log()
|
|
app = FastAPI()
|
|
|
|
class FakeDBConnection:
|
|
def __init__(self):
|
|
self.is_open = False
|
|
self.data = []
|
|
|
|
def open(self):
|
|
self.is_open = True
|
|
execution_log.append("db_opened")
|
|
|
|
def close(self):
|
|
self.is_open = False
|
|
execution_log.append("db_closed")
|
|
|
|
def query(self):
|
|
if not self.is_open:
|
|
execution_log.append("db_query_failed:connection_closed")
|
|
raise RuntimeError("Connection closed")
|
|
execution_log.append("db_query_success")
|
|
return {"result": "data"}
|
|
|
|
db = FakeDBConnection()
|
|
|
|
async def get_db():
|
|
db.open()
|
|
yield db
|
|
db.close()
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
conn: FakeDBConnection = Depends(get_db, scope="function"),
|
|
):
|
|
async def bg_task(connection: FakeDBConnection):
|
|
try:
|
|
result = connection.query()
|
|
execution_log.append(f"bg_task_got_result")
|
|
except RuntimeError as e:
|
|
execution_log.append(f"bg_task_error:{e}")
|
|
|
|
background_tasks.add_task(bg_task, conn)
|
|
return {"status": "task_queued"}
|
|
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# The background task fails because connection was closed before it ran
|
|
assert "db_closed" in execution_log
|
|
assert execution_log.index("db_closed") < execution_log.index("db_query_failed:connection_closed")
|
|
|
|
|
|
# Test 4: Nested dependencies with mixed scopes
|
|
def test_nested_dependencies_mixed_scopes():
|
|
"""
|
|
Test nested dependencies where outer has request scope and inner has function scope.
|
|
"""
|
|
reset_log()
|
|
app = FastAPI()
|
|
|
|
async def outer_resource():
|
|
execution_log.append("outer_setup")
|
|
yield "outer_active"
|
|
execution_log.append("outer_cleanup")
|
|
|
|
async def inner_resource(outer: str = Depends(outer_resource)):
|
|
execution_log.append(f"inner_setup:outer={outer}")
|
|
yield f"inner_active:outer={outer}"
|
|
execution_log.append("inner_cleanup")
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
inner: str = Depends(inner_resource, scope="function"),
|
|
):
|
|
async def bg_task(value: str):
|
|
execution_log.append(f"bg_task:{value}")
|
|
|
|
background_tasks.add_task(bg_task, inner)
|
|
return {"value": inner}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# Check execution order - inner cleanup should happen before outer
|
|
# and before background task due to function scope
|
|
print("Execution log:", execution_log)
|
|
assert "inner_cleanup" in execution_log
|
|
assert "outer_cleanup" in execution_log
|
|
|
|
|
|
# Test 5: Multiple background tasks with function scope dependency
|
|
def test_multiple_background_tasks_function_scope():
|
|
"""
|
|
Multiple background tasks all trying to use a function-scoped dependency.
|
|
"""
|
|
reset_log()
|
|
app = FastAPI()
|
|
counter = {"value": 0}
|
|
|
|
async def get_counter():
|
|
counter["value"] = 1
|
|
execution_log.append("counter_setup")
|
|
yield counter
|
|
execution_log.append("counter_cleanup")
|
|
counter["value"] = 0
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
cnt: dict = Depends(get_counter, scope="function"),
|
|
):
|
|
async def increment(c: dict):
|
|
execution_log.append(f"increment:value={c['value']}")
|
|
c["value"] += 1
|
|
|
|
background_tasks.add_task(increment, cnt)
|
|
background_tasks.add_task(increment, cnt)
|
|
background_tasks.add_task(increment, cnt)
|
|
return {"initial_value": cnt["value"]}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"initial_value": 1}
|
|
|
|
# Check that cleanup happened before background tasks ran
|
|
cleanup_idx = execution_log.index("counter_cleanup")
|
|
increment_indices = [
|
|
i for i, log in enumerate(execution_log) if log.startswith("increment")
|
|
]
|
|
|
|
# With function scope, cleanup happens before all increments
|
|
assert all(cleanup_idx < idx for idx in increment_indices)
|
|
|
|
|
|
# Test 6: Async workload with concurrent requests
|
|
def test_concurrent_requests_with_yield_dependencies():
|
|
"""
|
|
Test behavior under concurrent async workload.
|
|
"""
|
|
reset_log()
|
|
app = FastAPI()
|
|
shared_state = {"active_connections": 0, "max_connections": 0}
|
|
|
|
async def get_connection():
|
|
shared_state["active_connections"] += 1
|
|
shared_state["max_connections"] = max(
|
|
shared_state["max_connections"], shared_state["active_connections"]
|
|
)
|
|
execution_log.append(f"conn_open:active={shared_state['active_connections']}")
|
|
yield shared_state["active_connections"]
|
|
shared_state["active_connections"] -= 1
|
|
execution_log.append(f"conn_close:active={shared_state['active_connections']}")
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
conn_id: int = Depends(get_connection, scope="function"),
|
|
):
|
|
async def bg_task(id: int):
|
|
await asyncio.sleep(0.01)
|
|
execution_log.append(f"bg_task:conn_id={id}:active={shared_state['active_connections']}")
|
|
|
|
background_tasks.add_task(bg_task, conn_id)
|
|
return {"conn_id": conn_id}
|
|
|
|
client = TestClient(app)
|
|
|
|
# Make a single request first
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# Verify that connection was closed before background task ran
|
|
# (due to function scope)
|
|
conn_close_indices = [
|
|
i for i, log in enumerate(execution_log) if log.startswith("conn_close")
|
|
]
|
|
bg_task_indices = [
|
|
i for i, log in enumerate(execution_log) if log.startswith("bg_task")
|
|
]
|
|
|
|
if conn_close_indices and bg_task_indices:
|
|
# Function scope: close happens before bg_task
|
|
assert conn_close_indices[0] < bg_task_indices[0]
|
|
|
|
|
|
# Test 7: Verify request scope works correctly (control test)
|
|
def test_request_scope_preserves_resource_for_background_task():
|
|
"""
|
|
Control test: request scope should keep resource active during background task.
|
|
"""
|
|
reset_log()
|
|
app = FastAPI()
|
|
resource = {"is_active": False}
|
|
|
|
async def get_resource():
|
|
resource["is_active"] = True
|
|
execution_log.append("resource_activated")
|
|
yield resource
|
|
resource["is_active"] = False
|
|
execution_log.append("resource_deactivated")
|
|
|
|
@app.get("/test")
|
|
async def endpoint(
|
|
background_tasks: BackgroundTasks,
|
|
res: dict = Depends(get_resource), # Default request scope
|
|
):
|
|
async def bg_task(r: dict):
|
|
execution_log.append(f"bg_task:is_active={r['is_active']}")
|
|
|
|
background_tasks.add_task(bg_task, res)
|
|
return {"is_active": res["is_active"]}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/test")
|
|
assert response.status_code == 200
|
|
|
|
# Background task should see is_active=True because cleanup happens after
|
|
assert "bg_task:is_active=True" in execution_log
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|
|
|