Skip to content

Commit 1270fb0

Browse files
Pin the ClusterError and InterfaceError mappings in _call_client
The generic DqliteError catch-all was tested, but the specific branches that route ClusterError to dbapi OperationalError and InterfaceError to dbapi InterfaceError were not. Add targeted regression tests so a refactor that reorders or drops either branch cannot silently fall through to the catch-all and flip the dbapi exception type (breaking SQLAlchemy's is_disconnect classification). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bf4fa87 commit 1270fb0

File tree

1 file changed

+72
-0
lines changed

1 file changed

+72
-0
lines changed

tests/test_call_client_mapping.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Targeted tests for the exception-type mapping in ``_call_client``.
2+
3+
The generic ``DqliteError`` catch-all is already tested; the two
4+
specific branches below are not, which means a refactor that reorders
5+
or removes them could fall through to the catch-all and silently
6+
change the dbapi exception type surfaced to SQLAlchemy (which keys on
7+
``is_disconnect`` classification).
8+
"""
9+
10+
import asyncio
11+
12+
import pytest
13+
14+
import dqliteclient.exceptions as _client_exc
15+
from dqlitedbapi.cursor import _call_client
16+
from dqlitedbapi.exceptions import InterfaceError, OperationalError
17+
18+
19+
class TestCallClientClusterErrorMapping:
20+
def test_cluster_error_becomes_operational_error(self) -> None:
21+
async def raiser() -> None:
22+
raise _client_exc.ClusterError("no leader")
23+
24+
with pytest.raises(OperationalError, match="no leader"):
25+
asyncio.run(_call_client(raiser()))
26+
27+
def test_cluster_error_is_not_wrapped_as_interface_error(self) -> None:
28+
"""Regression guard: the catch-all branch would re-classify a
29+
ClusterError as InterfaceError if the specific branch were
30+
removed. Assert the mapping is stable so SQLAlchemy's
31+
is_disconnect path continues to see OperationalError.
32+
"""
33+
34+
async def raiser() -> None:
35+
raise _client_exc.ClusterError("no leader")
36+
37+
with pytest.raises(OperationalError):
38+
asyncio.run(_call_client(raiser()))
39+
40+
# Also: it must NOT be an InterfaceError (which would mask the
41+
# disconnect signal from the dialect's is_disconnect).
42+
async def raiser2() -> None:
43+
raise _client_exc.ClusterError("no leader")
44+
45+
try:
46+
asyncio.run(_call_client(raiser2()))
47+
except OperationalError as exc:
48+
assert not isinstance(exc, InterfaceError)
49+
50+
51+
class TestCallClientInterfaceErrorMapping:
52+
def test_interface_error_becomes_interface_error(self) -> None:
53+
async def raiser() -> None:
54+
raise _client_exc.InterfaceError("Connection is closed")
55+
56+
with pytest.raises(InterfaceError, match="Connection is closed"):
57+
asyncio.run(_call_client(raiser()))
58+
59+
def test_interface_error_is_not_operational_error(self) -> None:
60+
"""The dbapi InterfaceError is a DatabaseError sibling, not a
61+
subclass of OperationalError. Verify the mapping preserves the
62+
PEP 249 taxonomy boundary.
63+
"""
64+
65+
async def raiser() -> None:
66+
raise _client_exc.InterfaceError("closed")
67+
68+
try:
69+
asyncio.run(_call_client(raiser()))
70+
except Exception as exc:
71+
assert isinstance(exc, InterfaceError)
72+
assert not isinstance(exc, OperationalError)

0 commit comments

Comments
 (0)