Skip to content

Commit 1bf1d1b

Browse files
Preserve the server error code on dbapi OperationalError
dqliteclient.OperationalError carries a typed ``code`` attribute (the SQLite extended error code forwarded from the server), but the dbapi _call_client wrapper stringified the exception and dropped the code. Two in-tree callers (``_is_no_transaction_error`` in the dbapi layer and ``is_disconnect`` in the SQLAlchemy dialect) do ``getattr(exc, "code", None) in LEADER_ERROR_CODES`` — both guards were silently dead code for the dbapi-remapped path. Teach ``dqlitedbapi.OperationalError`` to carry an optional ``code`` field and forward it through _call_client. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d29acdc commit 1bf1d1b

3 files changed

Lines changed: 53 additions & 4 deletions

File tree

src/dqlitedbapi/cursor.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ async def _call_client(coro: Coroutine[Any, Any, Any]) -> Any:
4545
try:
4646
return await coro
4747
except _client_exc.OperationalError as e:
48-
raise OperationalError(str(e)) from e
48+
# Forward the SQLite extended error code so callers that branch
49+
# on leader-change / busy / integrity codes (see
50+
# ``_is_no_transaction_error`` and the SQLAlchemy dialect's
51+
# ``is_disconnect``) continue to work after the remap.
52+
raise OperationalError(str(e), code=e.code) from e
4953
except _client_exc.DqliteConnectionError as e:
5054
raise OperationalError(str(e)) from e
5155
except _client_exc.ClusterError as e:

src/dqlitedbapi/exceptions.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,18 @@ class DataError(DatabaseError):
3232

3333

3434
class OperationalError(DatabaseError):
35-
"""Error related to database operation."""
36-
37-
pass
35+
"""Error related to database operation.
36+
37+
Optional ``code`` attribute carries the SQLite extended error code
38+
forwarded from the dqlite server (e.g. ``SQLITE_IOERR_NOT_LEADER``).
39+
Callers can inspect ``getattr(exc, "code", None)`` to branch on
40+
specific wire-level failures without importing the lower-level
41+
client exception module.
42+
"""
43+
44+
def __init__(self, message: object = "", code: int | None = None) -> None:
45+
super().__init__(message)
46+
self.code = code
3847

3948

4049
class IntegrityError(DatabaseError):

tests/test_dbapi_hardening.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,39 @@ def test_detects_row_returning(self, sql: str) -> None:
348348
)
349349
def test_detects_non_row_returning(self, sql: str) -> None:
350350
assert not _is_row_returning(sql)
351+
352+
353+
class TestOperationalErrorCode:
354+
"""dbapi OperationalError carries the SQLite extended error code
355+
forwarded from the client layer. Consumers like
356+
``_is_no_transaction_error`` and the SQLAlchemy dialect's
357+
``is_disconnect`` key on ``getattr(exc, 'code', None)``.
358+
"""
359+
360+
def test_default_code_is_none(self) -> None:
361+
from dqlitedbapi.exceptions import OperationalError
362+
363+
e = OperationalError("boom")
364+
assert e.code is None
365+
assert str(e) == "boom"
366+
367+
def test_explicit_code_preserved(self) -> None:
368+
from dqlitedbapi.exceptions import OperationalError
369+
370+
e = OperationalError("not leader", code=10250)
371+
assert e.code == 10250
372+
373+
def test_call_client_preserves_client_code(self) -> None:
374+
import asyncio
375+
376+
import dqliteclient.exceptions as _client_exc
377+
from dqlitedbapi.cursor import _call_client
378+
from dqlitedbapi.exceptions import OperationalError
379+
380+
async def raiser() -> None:
381+
raise _client_exc.OperationalError(10250, "not the leader")
382+
383+
with pytest.raises(OperationalError) as excinfo:
384+
asyncio.run(_call_client(raiser()))
385+
386+
assert excinfo.value.code == 10250

0 commit comments

Comments
 (0)