fix(web/api): scope DB-retry sleep so tests don't starve background tasks

test_lifespan_db_retry patched decnet.web.api.asyncio.sleep to skip the
DB-retry backoff. Problem: asyncio is a shared module — the patch leaks
to every caller that looked up asyncio.sleep via `import asyncio`,
including run_health_heartbeat's own sleep loop. That heartbeat task
spawns inside the same lifespan; with its sleep mocked, the while-loop
spins tight, starves cancellation, and leaves an orphan task that
pytest-timeout eventually signals — surfacing as the 'Task exception
was never retrieved' warnings the user saw when running the suite.

Fix: give decnet.web.api a local binding `_retry_sleep = asyncio.sleep`
for the DB-retry wait, and have the test patch that instead. Narrowly
scoped, no impact on asyncio.sleep callers elsewhere.

Test timing before: 12s with --timeout=10 (interrupted by signal).
Test timing after: 0.58s. Full tests/web slice: 27s → 7.1s with the
spurious warnings gone.
This commit is contained in:
2026-04-24 17:11:44 -04:00
parent efc98285aa
commit ce6b4a4174
2 changed files with 9 additions and 2 deletions

View File

@@ -1,4 +1,8 @@
import asyncio
# Local binding for the DB-retry sleep so tests can patch it without
# affecting `asyncio.sleep` globally (which would otherwise starve the
# heartbeat / worker loops that share the interpreter's asyncio module).
from asyncio import sleep as _retry_sleep
import os
import traceback
import uuid
@@ -75,7 +79,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
log.warning("DB init attempt %d/5 failed: %s", attempt, exc)
if attempt == 5:
log.error("DB failed to initialize after 5 attempts — startup may be degraded")
await asyncio.sleep(0.5)
await _retry_sleep(0.5)
# Conditionally enable OpenTelemetry tracing
from decnet.telemetry import setup_tracing

View File

@@ -148,7 +148,10 @@ class TestLifespan:
mock_repo.initialize = _failing_init
with patch("decnet.web.api.repo", mock_repo):
with patch("decnet.web.api.asyncio.sleep", new_callable=AsyncMock):
# Patch only the local _retry_sleep binding — patching
# `asyncio.sleep` globally would starve the heartbeat loop's
# own sleep and leak the task past the test's lifetime.
with patch("decnet.web.api._retry_sleep", new_callable=AsyncMock):
with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)):
with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)):
with patch("decnet.web.api.attacker_profile_worker", return_value=asyncio.sleep(0)):