Skip to content

Commit ad4e760

Browse files
Hoist raw_message to DqliteError base so all subclasses carry it
The cycle-21 invariant — "raw_message is the verbatim server text preserved through layer wrapping" — was implemented on exactly one of the eight client exception classes (OperationalError). The dbapi compensated with ``getattr(e, "raw_message", None) or str(e)`` at 13+ catch arms; the comment at one of those arms documented the fallback as "older client versions without the attribute" — fictional, since the current client lacked it on every other class. Move the attribute to the ``DqliteError`` base, defaulting to ``None``. Every subclass (DqliteConnectionError, ProtocolError, InterfaceError, ClusterError, ClusterPolicyError, DataError) now inherits the slot. OperationalError keeps its existing display- truncation semantics (1 KiB cap on ``message``, unbounded ``raw_message``) and threads ``raw_message=`` to the base. Replace the dbapi-side fictional comment with the real rationale and use direct attribute access on the now-guaranteed slot. Pin the hierarchy contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 11624a9 commit ad4e760

2 files changed

Lines changed: 126 additions & 18 deletions

File tree

src/dqliteclient/exceptions.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,41 @@
1717

1818

1919
class DqliteError(Exception):
20-
"""Base exception for dqlite client errors."""
20+
"""Base exception for dqlite client errors.
21+
22+
Carries an optional ``raw_message`` attribute — the verbatim
23+
server-supplied diagnostic, un-truncated and un-suffixed — so
24+
callers (the dbapi-layer classifier, SA's ``is_disconnect``)
25+
can read forensic-grade text without falling back to
26+
``str(e)`` (which can be truncated by display caps or padded
27+
by per-class wrap prefixes). Defaults to ``None`` for raises
28+
that are purely client-side (no server text in scope).
29+
30+
The ``OperationalError`` subclass overrides ``__init__`` to keep
31+
its existing display-truncation semantics (a 1 KiB cap on the
32+
user-facing ``message`` field with the un-truncated text on
33+
``raw_message``); all other subclasses inherit this base
34+
constructor unchanged.
35+
"""
36+
37+
raw_message: str | None
38+
39+
def __init__(self, *args: object, raw_message: str | None = None) -> None:
40+
super().__init__(*args)
41+
self.raw_message = raw_message
2142

2243

2344
class DqliteConnectionError(DqliteError):
2445
"""Error establishing or maintaining connection.
2546
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.
47+
Optionally carries ``code`` so the wire-level signal survives a
48+
connect-path rewrap of an upstream ``OperationalError`` (e.g. the
49+
``LEADER_ERROR_CODES`` branch in ``DqliteConnection.connect()``).
50+
``raw_message`` (the verbatim server text) is inherited from the
51+
``DqliteError`` base.
3452
"""
3553

3654
code: int | None
37-
raw_message: str | None
3855

3956
def __init__(
4057
self,
@@ -44,8 +61,7 @@ def __init__(
4461
raw_message: str | None = None,
4562
) -> None:
4663
self.code = code
47-
self.raw_message = raw_message
48-
super().__init__(message)
64+
super().__init__(message, raw_message=raw_message)
4965

5066

5167
class ProtocolError(DqliteError, _WireProtocolError):
@@ -129,7 +145,7 @@ def __init__(
129145
# preserved through the dbapi-layer plumbing. Old call sites
130146
# that omit the kwarg still get the previous behaviour
131147
# (``raw_message`` defaults to ``message``).
132-
self.raw_message = message if raw_message is None else raw_message
148+
resolved_raw_message = message if raw_message is None else raw_message
133149
if len(message) > self._MAX_DISPLAY_MESSAGE:
134150
# ``len(message)`` and the slice cap count Python codepoints,
135151
# not UTF-8 bytes. Match the unit in the marker so an
@@ -143,11 +159,11 @@ def __init__(
143159
self.message = message
144160
# Pass ``code`` and the display ``message`` through as separate
145161
# args so ``self.args == (code, message)``; pickle / deepcopy
146-
# reconstruct via ``OperationalError(*args)``. The
147-
# ``raw_message`` kwarg is keyword-only and lossy on pickle —
148-
# but the dbapi layer is the consumer and reconstructs from
149-
# ``e.raw_message`` directly, not from ``args``.
150-
super().__init__(code, message)
162+
# reconstruct via ``OperationalError(*args)``. Pass
163+
# ``raw_message=`` through to the base so the ``DqliteError``
164+
# ``raw_message`` slot is set there (keeps a single source of
165+
# truth across the hierarchy).
166+
super().__init__(code, message, raw_message=resolved_raw_message)
151167

152168
def __str__(self) -> str:
153169
return f"[{self.code}] {self.message}"
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Pin: every client exception class carries the ``raw_message``
2+
attribute (defaulting to ``None``), so the cycle-21 invariant — the
3+
verbatim server text survives layer wrapping — applies symmetrically
4+
across the hierarchy and not only to ``OperationalError``.
5+
6+
The dbapi-layer ``getattr(e, "raw_message", None) or str(e)`` idiom
7+
previously documented its fallback as "older client versions without
8+
the attribute"; that rationale was a fiction (the current client
9+
lacked the attribute too). After hoisting ``raw_message`` to the
10+
``DqliteError`` base, every subclass exposes the attribute as a
11+
property and downstream consumers can read it without ``getattr``.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from dqliteclient.exceptions import (
17+
ClusterError,
18+
ClusterPolicyError,
19+
DataError,
20+
DqliteConnectionError,
21+
DqliteError,
22+
InterfaceError,
23+
OperationalError,
24+
ProtocolError,
25+
)
26+
27+
28+
def test_base_dqlite_error_has_raw_message_attribute() -> None:
29+
e = DqliteError("oops")
30+
assert e.raw_message is None
31+
32+
33+
def test_base_dqlite_error_accepts_raw_message_kwarg() -> None:
34+
e = DqliteError("oops", raw_message="server text")
35+
assert e.raw_message == "server text"
36+
37+
38+
def test_dqlite_connection_error_inherits_raw_message_from_base() -> None:
39+
e = DqliteConnectionError("Connection refused", raw_message="ECONNREFUSED")
40+
assert e.raw_message == "ECONNREFUSED"
41+
42+
43+
def test_protocol_error_carries_raw_message() -> None:
44+
e = ProtocolError("Wire decode failed", raw_message="malformed frame")
45+
assert e.raw_message == "malformed frame"
46+
47+
48+
def test_interface_error_carries_raw_message() -> None:
49+
e = InterfaceError("Connection is closed", raw_message="closed by peer")
50+
assert e.raw_message == "closed by peer"
51+
52+
53+
def test_cluster_error_carries_raw_message() -> None:
54+
e = ClusterError("Could not find leader", raw_message="errors: ...")
55+
assert e.raw_message == "errors: ..."
56+
57+
58+
def test_cluster_policy_error_carries_raw_message() -> None:
59+
e = ClusterPolicyError("rejected", raw_message="policy says no")
60+
assert e.raw_message == "policy says no"
61+
62+
63+
def test_data_error_carries_raw_message() -> None:
64+
e = DataError("encode failed", raw_message="value too large")
65+
assert e.raw_message == "value too large"
66+
67+
68+
def test_operational_error_keeps_existing_raw_message_truncation_invariant() -> None:
69+
"""OperationalError still truncates ``message`` for display while
70+
keeping the unbounded server text on ``raw_message`` — the
71+
original cycle-21 contract is not broken by the hoist."""
72+
long = "X" * 5000
73+
e = OperationalError(1, long, raw_message=long)
74+
assert "[truncated," in e.message
75+
assert e.raw_message == long # untruncated
76+
77+
78+
def test_operational_error_default_raw_message_is_message() -> None:
79+
"""Backwards-compat: if ``raw_message=`` is omitted, OperationalError
80+
derives it from ``message`` per the existing contract."""
81+
e = OperationalError(1, "boom")
82+
assert e.raw_message == "boom"
83+
84+
85+
def test_default_raw_message_is_none_for_other_classes() -> None:
86+
"""Sibling classes default ``raw_message`` to ``None`` (no
87+
server text in scope on a purely client-side raise)."""
88+
assert DqliteConnectionError("x").raw_message is None
89+
assert ProtocolError("x").raw_message is None
90+
assert InterfaceError("x").raw_message is None
91+
assert ClusterError("x").raw_message is None
92+
assert DataError("x").raw_message is None

0 commit comments

Comments
 (0)