Skip to content

Commit 29271e1

Browse files
Wrap protocol exceptions in PEP 249 exception types
Exceptions from the dqlite protocol layer (ConnectionError, OSError, etc.) are now wrapped in OperationalError. Our own exception types are re-raised as-is. Also replaces assert with proper InternalError check for protocol initialization (fixes issue 09). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 141c3f2 commit 29271e1

File tree

3 files changed

+106
-34
lines changed

3 files changed

+106
-34
lines changed

src/dqlitedbapi/aio/cursor.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Sequence
44
from typing import TYPE_CHECKING, Any
55

6-
from dqlitedbapi.exceptions import InterfaceError, InternalError
6+
from dqlitedbapi.exceptions import InterfaceError, InternalError, OperationalError
77

88
if TYPE_CHECKING:
99
from dqlitedbapi.aio.connection import AsyncConnection
@@ -88,22 +88,26 @@ async def execute(
8888
" RETURNING " in normalized or normalized.endswith(" RETURNING")
8989
)
9090

91-
if is_query:
92-
if conn._protocol is None or conn._db_id is None:
93-
raise InternalError("Connection protocol not initialized")
94-
columns, rows = await conn._protocol.query_sql(conn._db_id, operation, params)
95-
self._description = [(name, None, None, None, None, None, None) for name in columns]
96-
self._rows = [tuple(row) for row in rows]
97-
self._row_index = 0
98-
self._rowcount = len(rows)
99-
else:
100-
if conn._protocol is None or conn._db_id is None:
101-
raise InternalError("Connection protocol not initialized")
102-
last_id, affected = await conn._protocol.exec_sql(conn._db_id, operation, params)
103-
self._lastrowid = last_id
104-
self._rowcount = affected
105-
self._description = None
106-
self._rows = []
91+
if conn._protocol is None or conn._db_id is None:
92+
raise InternalError("Connection protocol not initialized")
93+
94+
try:
95+
if is_query:
96+
columns, rows = await conn._protocol.query_sql(conn._db_id, operation, params)
97+
self._description = [(name, None, None, None, None, None, None) for name in columns]
98+
self._rows = [tuple(row) for row in rows]
99+
self._row_index = 0
100+
self._rowcount = len(rows)
101+
else:
102+
last_id, affected = await conn._protocol.exec_sql(conn._db_id, operation, params)
103+
self._lastrowid = last_id
104+
self._rowcount = affected
105+
self._description = None
106+
self._rows = []
107+
except (OperationalError, InterfaceError, InternalError):
108+
raise
109+
except Exception as e:
110+
raise OperationalError(str(e)) from e
107111

108112
return self
109113

src/dqlitedbapi/cursor.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Sequence
44
from typing import TYPE_CHECKING, Any
55

6-
from dqlitedbapi.exceptions import InterfaceError, InternalError
6+
from dqlitedbapi.exceptions import InterfaceError, InternalError, OperationalError
77

88
if TYPE_CHECKING:
99
from dqlitedbapi.connection import Connection
@@ -100,22 +100,26 @@ async def _execute_async(self, operation: str, parameters: Sequence[Any] | None
100100
" RETURNING " in normalized or normalized.endswith(" RETURNING")
101101
)
102102

103-
if is_query:
104-
if conn._protocol is None or conn._db_id is None:
105-
raise InternalError("Connection protocol not initialized")
106-
columns, rows = await conn._protocol.query_sql(conn._db_id, operation, params)
107-
self._description = [(name, None, None, None, None, None, None) for name in columns]
108-
self._rows = [tuple(row) for row in rows]
109-
self._row_index = 0
110-
self._rowcount = len(rows)
111-
else:
112-
if conn._protocol is None or conn._db_id is None:
113-
raise InternalError("Connection protocol not initialized")
114-
last_id, affected = await conn._protocol.exec_sql(conn._db_id, operation, params)
115-
self._lastrowid = last_id
116-
self._rowcount = affected
117-
self._description = None
118-
self._rows = []
103+
if conn._protocol is None or conn._db_id is None:
104+
raise InternalError("Connection protocol not initialized")
105+
106+
try:
107+
if is_query:
108+
columns, rows = await conn._protocol.query_sql(conn._db_id, operation, params)
109+
self._description = [(name, None, None, None, None, None, None) for name in columns]
110+
self._rows = [tuple(row) for row in rows]
111+
self._row_index = 0
112+
self._rowcount = len(rows)
113+
else:
114+
last_id, affected = await conn._protocol.exec_sql(conn._db_id, operation, params)
115+
self._lastrowid = last_id
116+
self._rowcount = affected
117+
self._description = None
118+
self._rows = []
119+
except (OperationalError, InterfaceError, InternalError):
120+
raise
121+
except Exception as e:
122+
raise OperationalError(str(e)) from e
119123

120124
def executemany(self, operation: str, seq_of_parameters: Sequence[Sequence[Any]]) -> "Cursor":
121125
"""Execute a database operation multiple times."""

tests/test_exception_wrapping.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Tests that protocol exceptions are wrapped in PEP 249 exception types."""
2+
3+
import asyncio
4+
from unittest.mock import AsyncMock, MagicMock
5+
6+
import pytest
7+
8+
from dqlitedbapi.cursor import Cursor
9+
from dqlitedbapi.exceptions import OperationalError
10+
11+
12+
def _make_mock_connection_with_error(error: Exception) -> MagicMock:
13+
"""Create a mock Connection where protocol raises the given error."""
14+
mock_protocol = AsyncMock()
15+
mock_protocol.exec_sql = AsyncMock(side_effect=error)
16+
mock_protocol.query_sql = AsyncMock(side_effect=error)
17+
18+
mock_async_conn = AsyncMock()
19+
mock_async_conn._protocol = mock_protocol
20+
mock_async_conn._db_id = 0
21+
22+
mock_conn = MagicMock()
23+
24+
async def get_async_conn() -> AsyncMock:
25+
return mock_async_conn
26+
27+
mock_conn._get_async_connection = get_async_conn
28+
29+
def run_sync(coro: object) -> object:
30+
loop = asyncio.new_event_loop()
31+
try:
32+
return loop.run_until_complete(coro)
33+
finally:
34+
loop.close()
35+
36+
mock_conn._run_sync = run_sync
37+
38+
return mock_conn
39+
40+
41+
class TestExceptionWrapping:
42+
def test_connection_error_wrapped_as_operational_error(self) -> None:
43+
"""ConnectionError from protocol should become OperationalError."""
44+
mock_conn = _make_mock_connection_with_error(ConnectionError("connection lost"))
45+
cursor = Cursor(mock_conn)
46+
47+
with pytest.raises(OperationalError, match="connection lost"):
48+
cursor.execute("SELECT 1")
49+
50+
def test_os_error_wrapped_as_operational_error(self) -> None:
51+
"""OSError from protocol should become OperationalError."""
52+
mock_conn = _make_mock_connection_with_error(OSError("network unreachable"))
53+
cursor = Cursor(mock_conn)
54+
55+
with pytest.raises(OperationalError, match="network unreachable"):
56+
cursor.execute("INSERT INTO t VALUES (1)")
57+
58+
def test_runtime_error_wrapped_as_operational_error(self) -> None:
59+
"""Generic exceptions from protocol should become OperationalError."""
60+
mock_conn = _make_mock_connection_with_error(RuntimeError("unexpected"))
61+
cursor = Cursor(mock_conn)
62+
63+
with pytest.raises(OperationalError, match="unexpected"):
64+
cursor.execute("SELECT 1")

0 commit comments

Comments
 (0)