Skip to content

Commit 11624a9

Browse files
Thread code and raw_message through leader-change connect rewrap
A leader-change OperationalError(code=10250 / 10506, raw_message=...) that the OPEN step of DqliteConnection.connect() catches was rewrapped into a bare DqliteConnectionError, dropping both code and raw_message. The dbapi's connect-path classifier comment advertises preservation of the server-supplied code "matching the query path" — but the OperationalError arm was unreachable for leader-change codes because the client's connect() rewrap got there first. SA's is_disconnect code-based classifier could only fall back to the substring branch (works today, breaks on a future message rewording), and forensic consumers had to walk __cause__ to recover the verbatim server text. Extend DqliteConnectionError.__init__ with optional keyword-only ``code`` and ``raw_message`` (defaulting None for backwards-compat), thread both through the leader-change rewrap, and propagate them on the dbapi side via the DqliteConnectionError catch arm in _build_and_connect. Pin the new shape and the no-arg / message-only backwards-compat construction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 04961bd commit 11624a9

3 files changed

Lines changed: 87 additions & 3 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,9 +1118,19 @@ async def _connect_impl(self) -> None:
11181118
if e.code in LEADER_ERROR_CODES:
11191119
# Leader-change errors during OPEN are transport-level
11201120
# problems — the caller needs to reconnect elsewhere, not
1121-
# treat this as a SQL error.
1121+
# treat this as a SQL error. Thread ``code`` and
1122+
# ``raw_message`` through the rewrap so the dbapi /
1123+
# SA-dialect classifiers see the same code-bearing
1124+
# signal they would on the query path: without this
1125+
# the leader-change SQLITE_IOERR_NOT_LEADER /
1126+
# SQLITE_IOERR_LEADERSHIP_LOST code is dropped on the
1127+
# floor and SA's is_disconnect can only fall back to
1128+
# the substring branch (which works today but would
1129+
# break on a future message rewording).
11221130
raise DqliteConnectionError(
1123-
f"Node {self._address} is no longer leader: {e.message}"
1131+
f"Node {self._address} is no longer leader: {e.message}",
1132+
code=e.code,
1133+
raw_message=e.raw_message,
11241134
) from e
11251135
raise
11261136
except BaseException:

src/dqliteclient/exceptions.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,31 @@ class DqliteError(Exception):
2121

2222

2323
class DqliteConnectionError(DqliteError):
24-
"""Error establishing or maintaining connection."""
24+
"""Error establishing or maintaining connection.
25+
26+
Optionally carries ``code`` and ``raw_message`` so the verbatim
27+
server-supplied diagnostic survives a connect-path rewrap of an
28+
upstream ``OperationalError`` (e.g. the ``LEADER_ERROR_CODES``
29+
branch in ``DqliteConnection.connect()``). Both attributes are
30+
``None`` for the canonical TCP / handshake / cluster-level
31+
failures; callers that want the verbatim server text fall back to
32+
``str(exc)`` when ``raw_message`` is None — matching the dbapi-
33+
side ``getattr(e, "raw_message", None) or str(e)`` idiom.
34+
"""
35+
36+
code: int | None
37+
raw_message: str | None
38+
39+
def __init__(
40+
self,
41+
message: str = "",
42+
*,
43+
code: int | None = None,
44+
raw_message: str | None = None,
45+
) -> None:
46+
self.code = code
47+
self.raw_message = raw_message
48+
super().__init__(message)
2549

2650

2751
class ProtocolError(DqliteError, _WireProtocolError):
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Pin: ``DqliteConnectionError`` accepts and preserves ``code`` and
2+
``raw_message`` so a leader-change rewrap on the connect path
3+
surfaces the wire-level signal that downstream classifiers expect.
4+
5+
When ``DqliteConnection.connect()`` catches a leader-change
6+
``OperationalError(code=10250 / 10506, raw_message=...)`` from the
7+
OPEN step, it rewraps the failure into ``DqliteConnectionError`` so
8+
the dbapi-side classifier maps it to a transport-class error rather
9+
than a SQL error. Without threading ``code`` and ``raw_message``
10+
through the rewrap, both fields are dropped on the floor — SA's
11+
``is_disconnect`` code-based branch can never fire on the connect
12+
path, and any forensic reader who wants the verbatim server text
13+
has to walk ``__cause__``.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from dqliteclient.exceptions import DqliteConnectionError, DqliteError
19+
20+
21+
def test_dqlite_connection_error_default_construction_works() -> None:
22+
"""Backwards-compat: positional message still constructs an
23+
instance with code=None / raw_message=None."""
24+
e = DqliteConnectionError("Connection refused")
25+
assert str(e) == "Connection refused"
26+
assert e.code is None
27+
assert e.raw_message is None
28+
29+
30+
def test_dqlite_connection_error_carries_code_and_raw_message() -> None:
31+
"""A leader-change rewrap threads the wire-level diagnostic."""
32+
e = DqliteConnectionError(
33+
"Node leader-a:9001 is no longer leader: not leader",
34+
code=10250,
35+
raw_message="not leader",
36+
)
37+
assert e.code == 10250
38+
assert e.raw_message == "not leader"
39+
40+
41+
def test_dqlite_connection_error_is_dqlite_error_subclass() -> None:
42+
"""Defence pin against re-parenting."""
43+
assert issubclass(DqliteConnectionError, DqliteError)
44+
45+
46+
def test_no_args_construction_works() -> None:
47+
"""Some call sites raise with no message."""
48+
e = DqliteConnectionError()
49+
assert e.code is None
50+
assert e.raw_message is None

0 commit comments

Comments
 (0)