Skip to content

Commit 2a9589f

Browse files
slayofferclaude
andauthored
fix(db_utils): make acquire_with_retry yield exactly once (#1880)
acquire_with_retry's retry loop wrapped the yield, violating @asynccontextmanager's single-yield contract. When user code inside the async with block raised a retryable exception, the loop iterated and tried to yield again, producing RuntimeError("generator didn't stop after athrow()") on every retryable inner error. This masked the real cause and was the root of 1,934 identical failed consolidation ops on shurick-memory in production since 2026-03-30. Retry now wraps only the acquire (via AsyncExitStack). User-code exceptions inside the block propagate as their real types — strictly better for observability, since the prior retry-of-user-code branch was already non-functional (always crashed with the RuntimeError above). Includes a regression unit test asserting (a) the original retryable exception propagates unchanged and (b) the connection is released exactly once. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9345b46 commit 2a9589f

2 files changed

Lines changed: 114 additions & 26 deletions

File tree

hindsight-api-slim/hindsight_api/engine/db_utils.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
import time
88
from collections.abc import AsyncIterator
9-
from contextlib import asynccontextmanager
9+
from contextlib import AsyncExitStack, asynccontextmanager
1010
from typing import Any
1111

1212
logger = logging.getLogger(__name__)
@@ -101,6 +101,14 @@ async def acquire_with_retry(backend_or_pool: Any, max_retries: int = DEFAULT_MA
101101
"""
102102
Async context manager to acquire a database connection with retry logic.
103103
104+
Retries the *acquire* itself when it raises a retryable error (connection
105+
drop, timeout, deadlock detected during acquire). Exceptions raised by
106+
user code inside the ``async with`` block are NOT retried — they propagate
107+
as-is. Wrapping retry around the yield would violate the
108+
``@asynccontextmanager`` single-yield contract and surface as
109+
``RuntimeError("generator didn't stop after athrow()")`` on every
110+
retryable inner error, masking the real cause.
111+
104112
Accepts either a DatabaseBackend or a raw asyncpg.Pool for backward compatibility.
105113
106114
Usage:
@@ -109,39 +117,40 @@ async def acquire_with_retry(backend_or_pool: Any, max_retries: int = DEFAULT_MA
109117
110118
Args:
111119
backend_or_pool: A DatabaseBackend instance or asyncpg.Pool
112-
max_retries: Maximum number of retry attempts
120+
max_retries: Maximum number of retry attempts for the acquire step
113121
114122
Yields:
115123
A DatabaseConnection (if backend) or asyncpg.Connection (if pool)
116124
"""
117125
from .db.base import DatabaseBackend
118126

119127
if isinstance(backend_or_pool, DatabaseBackend) or getattr(backend_or_pool, "_wraps_backend", False):
120-
# Use the backend's acquire context manager with retry
121128
start = time.time()
122-
last_exception = None
123-
for attempt in range(max_retries + 1):
124-
try:
125-
async with backend_or_pool.acquire() as conn:
126-
acquire_time = time.time() - start
127-
if acquire_time > 0.05:
128-
logger.warning(f"[DB POOL] Slow acquire: {acquire_time:.3f}s")
129-
yield conn
130-
return
131-
except Exception as e:
132-
if not _is_retryable(e):
133-
raise
134-
last_exception = e
135-
if attempt < max_retries:
136-
delay = min(DEFAULT_BASE_DELAY * (2**attempt), DEFAULT_MAX_DELAY)
137-
logger.warning(
138-
f"Database acquire failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
139-
f"Retrying in {delay:.1f}s..."
140-
)
141-
await asyncio.sleep(delay)
142-
else:
143-
logger.error(f"Database acquire failed after {max_retries + 1} attempts: {e}")
144-
raise last_exception
129+
async with AsyncExitStack() as stack:
130+
conn: Any = None
131+
for attempt in range(max_retries + 1):
132+
try:
133+
conn = await stack.enter_async_context(backend_or_pool.acquire())
134+
break
135+
except Exception as e:
136+
if not _is_retryable(e):
137+
raise
138+
if attempt < max_retries:
139+
delay = min(DEFAULT_BASE_DELAY * (2**attempt), DEFAULT_MAX_DELAY)
140+
logger.warning(
141+
f"Database acquire failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
142+
f"Retrying in {delay:.1f}s..."
143+
)
144+
await asyncio.sleep(delay)
145+
else:
146+
logger.error(f"Database acquire failed after {max_retries + 1} attempts: {e}")
147+
raise
148+
149+
acquire_time = time.time() - start
150+
if acquire_time > 0.05:
151+
logger.warning(f"[DB POOL] Slow acquire: {acquire_time:.3f}s")
152+
153+
yield conn
145154
else:
146155
# Legacy path: raw asyncpg.Pool
147156
pool = backend_or_pool
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Tests for hindsight_api.engine.db_utils.
2+
3+
Regression coverage for the single-yield contract of ``acquire_with_retry`` —
4+
historically a retry loop wrapped the ``yield`` and caused every retryable
5+
user-code exception to surface as
6+
``RuntimeError("generator didn't stop after athrow()")``, masking the real
7+
cause and producing identical failed-op rows in production (see the 1,934
8+
failed consolidations on ``shurick-memory`` in May 2026).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
from contextlib import asynccontextmanager
15+
16+
import pytest
17+
18+
from hindsight_api.engine.db_utils import acquire_with_retry
19+
20+
21+
class _FakeConnection:
22+
"""Stand-in for a DatabaseConnection that records release events."""
23+
24+
def __init__(self) -> None:
25+
self.released = 0
26+
27+
28+
class _FakeBackend:
29+
"""Duck-typed DatabaseBackend that opts in via the ``_wraps_backend`` flag.
30+
31+
``acquire_with_retry`` accepts either a real ``DatabaseBackend`` subclass
32+
or any object with ``_wraps_backend = True``; the flag avoids having to
33+
stub the full abstract surface for unit tests.
34+
"""
35+
36+
_wraps_backend = True
37+
38+
def __init__(self) -> None:
39+
self.acquired = 0
40+
self.last_conn: _FakeConnection | None = None
41+
42+
@asynccontextmanager
43+
async def acquire(self):
44+
self.acquired += 1
45+
conn = _FakeConnection()
46+
self.last_conn = conn
47+
try:
48+
yield conn
49+
finally:
50+
conn.released += 1
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_retryable_user_code_exception_propagates_unchanged():
55+
"""A retryable exception inside ``async with`` must propagate as itself.
56+
57+
Before the single-yield refactor, the retry loop around the ``yield``
58+
re-entered ``yield conn`` on the next iteration, violating
59+
``@asynccontextmanager``'s contract and surfacing as
60+
``RuntimeError("generator didn't stop after athrow()")`` — the symptom
61+
that broke consolidation on large banks.
62+
"""
63+
64+
backend = _FakeBackend()
65+
sentinel = asyncio.TimeoutError("query exceeded statement_timeout")
66+
67+
with pytest.raises(asyncio.TimeoutError) as excinfo:
68+
async with acquire_with_retry(backend) as conn:
69+
assert isinstance(conn, _FakeConnection)
70+
raise sentinel
71+
72+
# The original exception flows out — not a RuntimeError wrapper.
73+
assert excinfo.value is sentinel
74+
assert not isinstance(excinfo.value, RuntimeError)
75+
76+
# Acquire was called exactly once — user-code failure must not retry.
77+
assert backend.acquired == 1
78+
assert backend.last_conn is not None
79+
assert backend.last_conn.released == 1, "connection must be released exactly once"

0 commit comments

Comments
 (0)