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.

406 lines
12 KiB

"""
Tests verifying the fixes for BackgroundTasks bugs.
Fixed bugs:
1. First task failure now doesn't stop remaining tasks
2. Tasks added after execution now emit a warning
"""
import logging
from typing import Generator
import pytest
from fastapi import BackgroundTasks, Depends, FastAPI
from fastapi.background import BackgroundTaskError
from fastapi.testclient import TestClient
class TestBackgroundTaskErrorIsolation:
"""
Test that task failures don't stop remaining tasks.
"""
def test_all_tasks_run_despite_failure(self):
"""
FIX: All tasks should run even if one fails in the middle.
"""
executed = []
app = FastAPI()
@app.get("/test")
async def endpoint(background_tasks: BackgroundTasks):
async def task1():
executed.append("task1")
async def failing_task():
executed.append("failing_start")
raise ValueError("I failed")
async def task3():
executed.append("task3")
background_tasks.add_task(task1)
background_tasks.add_task(failing_task)
background_tasks.add_task(task3)
return {"status": "ok"}
client = TestClient(app, raise_server_exceptions=False)
executed.clear()
response = client.get("/test")
assert response.status_code == 200
# FIX VERIFICATION: All three tasks should have run
assert "task1" in executed
assert "failing_start" in executed
assert "task3" in executed # This now runs!
def test_multiple_failures_all_logged(self):
"""
Multiple task failures should all be captured.
"""
executed = []
app = FastAPI()
@app.get("/test")
async def endpoint(background_tasks: BackgroundTasks):
async def failing1():
executed.append("failing1")
raise ValueError("Error 1")
async def failing2():
executed.append("failing2")
raise RuntimeError("Error 2")
async def success():
executed.append("success")
background_tasks.add_task(failing1)
background_tasks.add_task(success)
background_tasks.add_task(failing2)
return {"status": "ok"}
client = TestClient(app, raise_server_exceptions=False)
executed.clear()
response = client.get("/test")
assert response.status_code == 200
# All tasks should have executed
assert "failing1" in executed
assert "success" in executed
assert "failing2" in executed
def test_single_task_failure_raises_original_exception(self):
"""
Single task failure should raise the original exception (backward compatible).
"""
app = FastAPI()
@app.get("/test")
async def endpoint(background_tasks: BackgroundTasks):
async def failing():
raise ValueError("Task failed")
background_tasks.add_task(failing)
return {"status": "ok"}
client = TestClient(app, raise_server_exceptions=True)
# Single failure: original exception raised for backward compatibility
with pytest.raises(ValueError, match="Task failed"):
client.get("/test")
def test_multiple_task_failures_raise_background_task_error(self):
"""
Multiple task failures should raise BackgroundTaskError with all errors.
"""
app = FastAPI()
@app.get("/test")
async def endpoint(background_tasks: BackgroundTasks):
async def failing1():
raise ValueError("Error 1")
async def failing2():
raise RuntimeError("Error 2")
background_tasks.add_task(failing1)
background_tasks.add_task(failing2)
return {"status": "ok"}
client = TestClient(app, raise_server_exceptions=True)
# Multiple failures: BackgroundTaskError raised
with pytest.raises(BackgroundTaskError) as exc_info:
client.get("/test")
assert len(exc_info.value.errors) == 2
assert isinstance(exc_info.value.errors[0][1], ValueError)
assert isinstance(exc_info.value.errors[1][1], RuntimeError)
class TestBackgroundTaskAfterYieldWarning:
"""
Test that adding tasks after execution emits a warning.
"""
def test_warning_when_task_added_after_execution(self, caplog):
"""
FIX: A log warning should be emitted when task is added after execution.
"""
executed = []
app = FastAPI()
async def dependency_with_yield(background_tasks: BackgroundTasks):
background_tasks.add_task(lambda: executed.append("before_yield"))
yield "value"
background_tasks.add_task(lambda: executed.append("after_yield"))
@app.get("/test")
async def endpoint(dep: str = Depends(dependency_with_yield)):
return {"dep": dep}
client = TestClient(app)
executed.clear()
with caplog.at_level(logging.WARNING, logger="fastapi.background"):
response = client.get("/test")
assert response.status_code == 200
# Task added before yield should have executed
assert "before_yield" in executed
# Task added after yield should NOT have executed
assert "after_yield" not in executed
# Warning should have been logged
assert any(
"Background task added after tasks have already been executed" in r.message
for r in caplog.records
)
def test_no_warning_for_normal_task_addition(self, caplog):
"""
No warning should be logged for normal task addition.
"""
app = FastAPI()
@app.get("/test")
async def endpoint(background_tasks: BackgroundTasks):
background_tasks.add_task(lambda: None)
background_tasks.add_task(lambda: None)
return {"status": "ok"}
client = TestClient(app)
with caplog.at_level(logging.WARNING, logger="fastapi.background"):
response = client.get("/test")
assert response.status_code == 200
assert not any(
"already been executed" in r.message for r in caplog.records
)
class TestDependencyCleanupStillWorks:
"""
Verify that dependency cleanup still works correctly with the fixes.
"""
def test_cleanup_runs_after_all_tasks(self):
"""
Dependency cleanup should run after all background tasks complete.
"""
events = []
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 task1():
events.append("task1")
async def task2():
events.append("task2")
background_tasks.add_task(task1)
background_tasks.add_task(task2)
return {"status": "ok"}
client = TestClient(app)
events.clear()
response = client.get("/test")
assert response.status_code == 200
# Verify order: setup -> tasks -> cleanup
assert events.index("setup") < events.index("task1")
assert events.index("task1") < events.index("task2")
assert events.index("task2") < events.index("cleanup")
def test_cleanup_runs_even_when_tasks_fail(self):
"""
Dependency cleanup should run even if background tasks fail.
"""
events = []
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("task_start")
raise ValueError("Failed")
background_tasks.add_task(failing_task)
return {"status": "ok"}
client = TestClient(app, raise_server_exceptions=False)
events.clear()
response = client.get("/test")
assert response.status_code == 200
# Cleanup must happen
assert "setup" in events
assert "task_start" in events
assert "cleanup" in events
class TestEdgeCases:
"""Test edge cases and robustness fixes."""
def test_task_with_no_name_attribute(self):
"""Test that tasks without __name__ are handled gracefully."""
executed = []
app = FastAPI()
@app.get("/test")
async def endpoint(background_tasks: BackgroundTasks):
# Lambda has __name__ = '<lambda>'
background_tasks.add_task(lambda: executed.append("lambda"))
return {"status": "ok"}
client = TestClient(app)
executed.clear()
response = client.get("/test")
assert response.status_code == 200
assert "lambda" in executed
def test_task_mutation_during_execution(self):
"""Test that task list mutation during execution doesn't cause issues."""
executed = []
tasks_ref = {"bg": None}
app = FastAPI()
@app.get("/test")
async def endpoint(background_tasks: BackgroundTasks):
tasks_ref["bg"] = background_tasks
async def mutating_task():
executed.append("task1")
# Try to mutate the tasks list during execution
# This should not affect iteration due to snapshot
tasks_ref["bg"].tasks.append(
BackgroundTasks() # Add garbage
)
async def task2():
executed.append("task2")
background_tasks.add_task(mutating_task)
background_tasks.add_task(task2)
return {"status": "ok"}
client = TestClient(app, raise_server_exceptions=False)
executed.clear()
response = client.get("/test")
assert response.status_code == 200
# Both tasks should have executed despite mutation attempt
assert "task1" in executed
assert "task2" in executed
@pytest.mark.anyio
async def test_keyboard_interrupt_not_suppressed(self):
"""Test that KeyboardInterrupt is not suppressed."""
from fastapi.background import BackgroundTasks as BgTasks
async def raising_task():
raise KeyboardInterrupt()
bg = BgTasks()
bg.add_task(raising_task)
with pytest.raises(KeyboardInterrupt):
await bg()
@pytest.mark.anyio
async def test_system_exit_not_suppressed(self):
"""Test that SystemExit is not suppressed."""
from fastapi.background import BackgroundTasks as BgTasks
async def raising_task():
raise SystemExit(1)
bg = BgTasks()
bg.add_task(raising_task)
with pytest.raises(SystemExit):
await bg()
def test_base_exception_subclass_caught(self):
"""Test that BaseException subclasses (except critical ones) are caught."""
executed = []
app = FastAPI()
class CustomBaseException(BaseException):
pass
@app.get("/test")
async def endpoint(background_tasks: BackgroundTasks):
async def failing_task():
executed.append("failing")
raise CustomBaseException("custom error")
async def second_task():
executed.append("second")
background_tasks.add_task(failing_task)
background_tasks.add_task(second_task)
return {"status": "ok"}
client = TestClient(app, raise_server_exceptions=False)
executed.clear()
response = client.get("/test")
assert response.status_code == 200
# Both tasks should execute - CustomBaseException should be caught
assert "failing" in executed
assert "second" in executed
if __name__ == "__main__":
pytest.main([__file__, "-v"])