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.

344 lines
11 KiB

"""
Test accessing dependencies from within BackgroundTasks.
This tests scenarios where background tasks try to access dependency-provided
resources directly, which can lead to inconsistent behavior if the dependency
has been cleaned up.
"""
import asyncio
from typing import Any
import pytest
from fastapi import BackgroundTasks, Depends, FastAPI, Request
from fastapi.testclient import TestClient
# Scenario 1: Background task accessing a resource passed to it
def test_bg_task_with_passed_resource():
"""
Test that a resource passed to a background task remains valid
when using default (request) scope.
"""
events: list[str] = []
app = FastAPI()
class DatabaseConnection:
def __init__(self):
self.connected = False
self.queries: list[str] = []
def connect(self):
self.connected = True
events.append("db_connected")
def disconnect(self):
self.connected = False
events.append("db_disconnected")
def query(self, sql: str) -> str:
if not self.connected:
events.append(f"query_failed:{sql}")
raise RuntimeError("Not connected")
events.append(f"query_success:{sql}")
self.queries.append(sql)
return f"result:{sql}"
async def get_db():
db = DatabaseConnection()
db.connect()
yield db
db.disconnect()
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
db: DatabaseConnection = Depends(get_db),
):
async def bg_query(conn: DatabaseConnection):
# This should work because default scope keeps db alive
result = conn.query("SELECT * FROM bg_table")
events.append(f"bg_got:{result}")
background_tasks.add_task(bg_query, db)
return {"status": "queued"}
client = TestClient(app)
events.clear()
response = client.get("/test")
assert response.status_code == 200
# Verify query succeeded in background task
assert "db_connected" in events
assert "query_success:SELECT * FROM bg_table" in events
assert "bg_got:result:SELECT * FROM bg_table" in events
assert "db_disconnected" in events
# Verify order: connect -> query -> disconnect
connect_idx = events.index("db_connected")
query_idx = events.index("query_success:SELECT * FROM bg_table")
disconnect_idx = events.index("db_disconnected")
assert connect_idx < query_idx < disconnect_idx
# Scenario 2: Background task accessing function-scoped resource (FAILS)
def test_bg_task_with_function_scoped_resource():
"""
Test that a function-scoped resource is cleaned up before background task runs.
This demonstrates the potential pitfall of using scope="function" with BackgroundTasks.
"""
events: list[str] = []
app = FastAPI()
class DatabaseConnection:
def __init__(self):
self.connected = False
def connect(self):
self.connected = True
events.append("db_connected")
def disconnect(self):
self.connected = False
events.append("db_disconnected")
def query(self, sql: str) -> str:
if not self.connected:
events.append(f"query_failed:{sql}")
return "ERROR: Not connected"
events.append(f"query_success:{sql}")
return f"result:{sql}"
async def get_db():
db = DatabaseConnection()
db.connect()
yield db
db.disconnect()
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
db: DatabaseConnection = Depends(get_db, scope="function"),
):
async def bg_query(conn: DatabaseConnection):
# This will FAIL because function scope cleans up before bg task
result = conn.query("SELECT * FROM bg_table")
events.append(f"bg_got:{result}")
background_tasks.add_task(bg_query, db)
return {"status": "queued"}
client = TestClient(app)
events.clear()
response = client.get("/test")
assert response.status_code == 200
# With function scope, db is disconnected BEFORE bg task runs
assert "db_connected" in events
assert "db_disconnected" in events
assert "query_failed:SELECT * FROM bg_table" in events
# Verify problematic order: connect -> disconnect -> query_failed
connect_idx = events.index("db_connected")
disconnect_idx = events.index("db_disconnected")
query_idx = events.index("query_failed:SELECT * FROM bg_table")
assert connect_idx < disconnect_idx < query_idx
# Scenario 3: Multiple concurrent requests with shared mutable state
def test_concurrent_requests_shared_state():
"""
Test behavior when multiple concurrent requests share mutable state
through a dependency.
"""
events: list[str] = []
request_counter = {"value": 0}
app = FastAPI()
async def get_request_id():
request_counter["value"] += 1
request_id = request_counter["value"]
events.append(f"setup:{request_id}")
yield request_id
events.append(f"cleanup:{request_id}")
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
request_id: int = Depends(get_request_id),
):
async def bg_task(rid: int):
await asyncio.sleep(0.01) # Small delay
events.append(f"bg:{rid}")
background_tasks.add_task(bg_task, request_id)
return {"request_id": request_id}
client = TestClient(app)
events.clear()
request_counter["value"] = 0
# Make multiple sequential requests
for _ in range(3):
response = client.get("/test")
assert response.status_code == 200
# Verify each request has proper setup -> bg -> cleanup sequence
# (Note: with default scope, cleanup happens after bg task)
assert len([e for e in events if e.startswith("setup:")]) == 3
assert len([e for e in events if e.startswith("bg:")]) == 3
assert len([e for e in events if e.startswith("cleanup:")]) == 3
# Scenario 4: Nested async generators with background tasks
def test_nested_async_generators_with_bg_tasks():
"""
Test deeply nested async generators and their interaction with background tasks.
"""
events: list[str] = []
app = FastAPI()
async def outer():
events.append("outer_start")
yield "outer"
events.append("outer_end")
async def middle(outer_val: str = Depends(outer)):
events.append(f"middle_start:{outer_val}")
yield f"middle({outer_val})"
events.append("middle_end")
async def inner(middle_val: str = Depends(middle)):
events.append(f"inner_start:{middle_val}")
yield f"inner({middle_val})"
events.append("inner_end")
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
value: str = Depends(inner),
):
async def bg_task(val: str):
events.append(f"bg:{val}")
background_tasks.add_task(bg_task, value)
return {"value": value}
client = TestClient(app)
events.clear()
response = client.get("/test")
assert response.status_code == 200
assert response.json()["value"] == "inner(middle(outer))"
# Verify setup order: outer -> middle -> inner
assert events.index("outer_start") < events.index("middle_start:outer")
assert events.index("middle_start:outer") < events.index("inner_start:middle(outer)")
# Verify bg task runs before cleanup
bg_idx = events.index("bg:inner(middle(outer))")
assert bg_idx < events.index("inner_end")
# Verify cleanup order (reverse): inner -> middle -> outer
assert events.index("inner_end") < events.index("middle_end")
assert events.index("middle_end") < events.index("outer_end")
# Scenario 5: Background task that adds more background tasks
def test_bg_task_adding_more_bg_tasks():
"""
Test a background task that adds additional background tasks.
"""
events: list[str] = []
app = FastAPI()
async def get_resource():
events.append("resource_setup")
yield {"active": True}
events.append("resource_cleanup")
@app.get("/test")
async def endpoint(
request: Request,
background_tasks: BackgroundTasks,
resource: dict = Depends(get_resource),
):
async def first_bg_task(res: dict, tasks: BackgroundTasks):
events.append(f"first_bg:active={res['active']}")
# Note: Adding tasks from within a task might not work as expected
# because the BackgroundTasks object is tied to the response
async def second_bg_task(res: dict):
events.append(f"second_bg:active={res['active']}")
background_tasks.add_task(first_bg_task, resource, background_tasks)
background_tasks.add_task(second_bg_task, resource)
return {"status": "ok"}
client = TestClient(app)
events.clear()
response = client.get("/test")
assert response.status_code == 200
# Both background tasks should see active=True (default scope)
assert "first_bg:active=True" in events
assert "second_bg:active=True" in events
assert "resource_cleanup" in events
# Scenario 6: Error in one background task doesn't affect others
def test_error_in_one_bg_task_others_continue():
"""
Test that an error in one background task doesn't prevent cleanup or other tasks.
Note: Cleanup code MUST be in a finally block to run when exceptions occur.
With the fix, all tasks run even if one fails.
"""
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_task():
events.append("failing_start")
raise ValueError("Task failed")
async def succeeding_task():
events.append("succeeding")
background_tasks.add_task(failing_task)
background_tasks.add_task(succeeding_task)
return {"status": "ok"}
client = TestClient(app, raise_server_exceptions=True)
events.clear()
# Single task failure: original exception re-raised (backward compatible)
with pytest.raises(ValueError, match="Task failed"):
client.get("/test")
# Cleanup should still happen
assert "setup" in events
assert "failing_start" in events
assert "cleanup" in events
# FIXED: Second task now runs even though first task failed
assert "succeeding" in events
if __name__ == "__main__":
pytest.main([__file__, "-v"])