Skip to content

Commit 851ac27

Browse files
fix: chain invalidation cause into later 'Not connected' errors
After _run_protocol invalidated, the next caller's _ensure_connected raised a bare DqliteConnectionError("Not connected") with no link to the original failure. Cancel/peer reset/handshake failure/explicit close all looked identical in logs. Store the invalidating exception on the connection; surface it as __cause__ on the subsequent "Not connected" so diagnostics follow the chain to the original root cause. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 680c9f1 commit 851ac27

2 files changed

Lines changed: 39 additions & 9 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def __init__(
9494
self._bound_loop: asyncio.AbstractEventLoop | None = None
9595
self._tx_owner: asyncio.Task[Any] | None = None
9696
self._pool_released = False
97+
self._invalidation_cause: BaseException | None = None
9798

9899
@property
99100
def address(self) -> str:
@@ -173,7 +174,7 @@ async def __aexit__(self, *args: Any) -> None:
173174
def _ensure_connected(self) -> tuple[DqliteProtocol, int]:
174175
"""Ensure we're connected and return protocol and db_id."""
175176
if self._protocol is None or self._db_id is None:
176-
raise DqliteConnectionError("Not connected")
177+
raise DqliteConnectionError("Not connected") from self._invalidation_cause
177178
return self._protocol, self._db_id
178179

179180
def _check_in_use(self) -> None:
@@ -214,14 +215,20 @@ def _check_in_use(self) -> None:
214215
"the pool."
215216
)
216217

217-
def _invalidate(self) -> None:
218-
"""Mark the connection as broken after an unrecoverable error."""
218+
def _invalidate(self, cause: BaseException | None = None) -> None:
219+
"""Mark the connection as broken after an unrecoverable error.
220+
221+
If ``cause`` is provided, it is remembered so a later caller that
222+
hits "Not connected" can chain it as ``__cause__`` for diagnostics.
223+
"""
219224
if self._protocol is not None:
220225
# Connection may already be broken; suppress close errors
221226
with contextlib.suppress(Exception):
222227
self._protocol.close()
223228
self._protocol = None
224229
self._db_id = None
230+
if cause is not None:
231+
self._invalidation_cause = cause
225232

226233
async def _run_protocol[T](self, fn: Callable[[DqliteProtocol, int], Awaitable[T]]) -> T:
227234
"""Run a protocol operation with standard error handling.
@@ -234,18 +241,18 @@ async def _run_protocol[T](self, fn: Callable[[DqliteProtocol, int], Awaitable[T
234241
self._in_use = True
235242
try:
236243
return await fn(protocol, db_id)
237-
except (DqliteConnectionError, ProtocolError):
238-
self._invalidate()
244+
except (DqliteConnectionError, ProtocolError) as e:
245+
self._invalidate(e)
239246
raise
240247
except OperationalError as e:
241248
if e.code in _LEADER_ERROR_CODES:
242-
self._invalidate()
249+
self._invalidate(e)
243250
raise
244-
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
251+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit) as e:
245252
# Interrupted mid-operation; we don't know how much of the
246253
# request/response round-trip completed, so the wire state is
247254
# unsafe to reuse. Invalidate and re-raise.
248-
self._invalidate()
255+
self._invalidate(e)
249256
raise
250257
finally:
251258
self._in_use = False

tests/test_connection.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77
from dqliteclient.connection import DqliteConnection
8-
from dqliteclient.exceptions import DqliteConnectionError
8+
from dqliteclient.exceptions import DqliteConnectionError, OperationalError
99

1010

1111
class TestParseAddress:
@@ -778,6 +778,29 @@ async def raise_client_error(_db, _sql, _params=None):
778778

779779
assert conn.is_connected, "client-side TypeError must not invalidate the connection"
780780

781+
async def test_not_connected_after_invalidation_chains_cause(
782+
self, connected_connection
783+
) -> None:
784+
"""After invalidation, a subsequent operation's DqliteConnectionError
785+
('Not connected') must chain back to the original cause, so logs on
786+
the second-use path still surface why the connection died.
787+
"""
788+
conn, mock_reader, _ = connected_connection
789+
from dqlitewire.messages import FailureResponse
790+
791+
# First call: server returns NOT_LEADER, connection is invalidated.
792+
mock_reader.read.side_effect = [FailureResponse(code=10250, message="not leader").encode()]
793+
with pytest.raises(OperationalError):
794+
await conn.execute("SELECT 1")
795+
assert not conn.is_connected
796+
797+
# Second call: should surface the original cause, not a bare
798+
# "Not connected".
799+
with pytest.raises(DqliteConnectionError) as exc_info:
800+
await conn.execute("SELECT 2")
801+
assert exc_info.value.__cause__ is not None
802+
assert isinstance(exc_info.value.__cause__, OperationalError)
803+
781804
async def test_cross_event_loop_raises_interface_error(self) -> None:
782805
"""Using a connection from a different event loop must raise InterfaceError."""
783806
import asyncio

0 commit comments

Comments
 (0)