Skip to content

Commit 545e439

Browse files
Harden dialect: narrow exception handling, URL query params, explicit AUTOCOMMIT rejection
ISSUE-16 — do_ping now catches only connection-level exceptions (OperationalError, InterfaceError, DqliteConnectionError, OSError, TimeoutError). Unexpected errors (bugs, MemoryError, etc.) propagate so the pool doesn't silently discard a healthy connection as "dead". ISSUE-18 — is_disconnect now dispatches on exception type first (DqliteConnectionError, ConnectionError/BrokenPipeError/OSError, leader-change codes 10250/10506), falling back to the substring list. The hand-maintained list still exists as a last resort for error kinds that aren't yet modelled as specific exception types. ISSUE-19 — create_connect_args parses URL query params with a strict allowlist. ?timeout=5.0 plumbs through to dqlitedbapi.connect; unknown keys raise ArgumentError so typos surface instead of being silently dropped. ISSUE-21 — set_isolation_level now raises ArgumentError for AUTOCOMMIT with an explanation. Other unsupported levels still emit a warning (future-proof). ISSUE-23 — supported_isolation_levels = ("SERIALIZABLE",) declared explicitly. Applications can introspect via engine.dialect. ISSUE-24 — dqlitedbapi.exceptions / dqliteclient.exceptions imports moved to module top; no circular import was actually blocking them. Existing tests that asserted the old blanket-Exception behavior (test_ping_returns_false_on_error, test_ping_closes_cursor_even_on_error, test_returns_fallback_on_error) were rewritten to reflect the new narrow-exception contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dabac9e commit 545e439

3 files changed

Lines changed: 246 additions & 35 deletions

File tree

src/sqlalchemydqlite/base.py

Lines changed: 104 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
"""Base dqlite dialect for SQLAlchemy."""
22

33
import datetime
4+
import warnings
45
from typing import Any
56

7+
import dqliteclient.exceptions as _client_exc
8+
import dqlitedbapi.exceptions as _dbapi_exc
69
from sqlalchemy import types as sqltypes
710
from sqlalchemy.dialects.sqlite.base import SQLiteDialect
811
from sqlalchemy.engine import URL
912
from sqlalchemy.engine.interfaces import DBAPIConnection, IsolationLevel
13+
from sqlalchemy.exc import ArgumentError
1014

1115

1216
class _DqliteDateTime(sqltypes.DateTime):
@@ -57,6 +61,11 @@ class DqliteDialect(SQLiteDialect):
5761
# Enable SQLAlchemy statement caching
5862
supports_statement_cache = True
5963

64+
# dqlite runs every statement through Raft consensus; there is no
65+
# exposed way to weaken isolation. Declaring this explicitly lets
66+
# applications introspect via ``engine.dialect.supported_isolation_levels``.
67+
supported_isolation_levels: tuple[str, ...] = ("SERIALIZABLE",)
68+
6069
# Override the SQLite dialect's string-based DATE/DATETIME processors:
6170
# dqlitedbapi returns datetime objects (PEP 249), not ISO strings.
6271
colspecs = {
@@ -71,21 +80,42 @@ def import_dbapi(cls) -> Any:
7180

7281
return dqlitedbapi
7382

83+
# Whitelist of URL query parameters we forward to the DBAPI connect
84+
# call. Unknown keys raise ``ArgumentError`` so typos surface.
85+
_URL_QUERY_ALLOWED: dict[str, type] = {"timeout": float}
86+
7487
def create_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
7588
"""Create connection arguments from URL.
7689
77-
URL format: dqlite://host:port/database
90+
URL format: ``dqlite://host:port/database?timeout=...``
91+
92+
Known query parameters are typed and passed through to the
93+
DBAPI. Unknown ones raise :class:`ArgumentError` so typos like
94+
``?timeoutt=5`` don't silently disappear.
7895
"""
7996
host = url.host or "localhost"
8097
port = url.port or 9001
8198
database = url.database or "default"
8299

83100
address = f"{host}:{port}"
84-
85-
return [], {
86-
"address": address,
87-
"database": database,
88-
}
101+
kwargs: dict[str, Any] = {"address": address, "database": database}
102+
103+
query = dict(url.query) if url.query else {}
104+
for key, raw in query.items():
105+
if key not in self._URL_QUERY_ALLOWED:
106+
raise ArgumentError(
107+
f"Unknown dqlite URL query parameter {key!r}. "
108+
f"Allowed: {sorted(self._URL_QUERY_ALLOWED)}"
109+
)
110+
converter = self._URL_QUERY_ALLOWED[key]
111+
try:
112+
kwargs[key] = converter(raw)
113+
except (TypeError, ValueError) as e:
114+
raise ArgumentError(
115+
f"Cannot convert URL query {key}={raw!r} to {converter.__name__}: {e}"
116+
) from e
117+
118+
return [], kwargs
89119

90120
def get_isolation_level(self, dbapi_connection: DBAPIConnection) -> IsolationLevel:
91121
"""Return the isolation level.
@@ -98,17 +128,25 @@ def get_isolation_level(self, dbapi_connection: DBAPIConnection) -> IsolationLev
98128
def set_isolation_level(self, dbapi_connection: DBAPIConnection, level: str | None) -> None:
99129
"""Set isolation level.
100130
101-
dqlite only supports SERIALIZABLE isolation. A warning is emitted
102-
if a different level is requested.
131+
dqlite only supports SERIALIZABLE. ``AUTOCOMMIT`` is explicitly
132+
rejected because silently dropping the request would cause users
133+
to lose transactionality without knowing it. Other unsupported
134+
levels emit a warning (future-proof for isolation levels dqlite
135+
may grow to support).
103136
"""
104-
if level is not None and level != "SERIALIZABLE":
105-
import warnings
106-
107-
warnings.warn(
108-
f"dqlite only supports SERIALIZABLE isolation. "
109-
f"Requested level {level!r} is ignored.",
110-
stacklevel=2,
137+
if level is None or level == "SERIALIZABLE":
138+
return
139+
if level == "AUTOCOMMIT":
140+
raise ArgumentError(
141+
"dqlite does not support AUTOCOMMIT; every statement goes through "
142+
"Raft consensus and there is no per-statement autocommit mode. "
143+
"Use explicit commit() / rollback() on the connection."
111144
)
145+
warnings.warn(
146+
f"dqlite only supports SERIALIZABLE isolation. "
147+
f"Requested level {level!r} is ignored.",
148+
stacklevel=2,
149+
)
112150

113151
# do_rollback / do_commit are intentionally left inherited from the
114152
# parent dialect. The "cannot commit/rollback — no transaction is
@@ -124,31 +162,67 @@ def set_isolation_level(self, dbapi_connection: DBAPIConnection, level: str | No
124162
"Not connected",
125163
)
126164

165+
# Server-side SQLite error codes that mean the connection is useless
166+
# even if the TCP socket is still alive — kept in sync with the
167+
# client's _LEADER_ERROR_CODES.
168+
# SQLITE_IOERR_NOT_LEADER = SQLITE_IOERR | (40 << 8) = 10250
169+
# SQLITE_IOERR_LEADERSHIP_LOST = SQLITE_IOERR | (41 << 8) = 10506
170+
_LEADER_CHANGE_CODES: frozenset[int] = frozenset({10250, 10506})
171+
127172
def is_disconnect(self, e: Any, connection: Any, cursor: Any) -> bool:
128173
"""Detect whether an exception indicates a broken connection.
129174
130-
dqlite is a network database, so we must detect TCP-level and
131-
leader-change errors that the inherited pysqlite patterns miss.
175+
Prefer exception-type dispatch over message matching; the C
176+
server's error wording is not a contract. Type-based checks
177+
cover TCP resets, DNS failures, and partial-read timeouts that
178+
the hand-maintained substring list misses.
132179
"""
133-
import dqlitedbapi.exceptions
134-
135-
if isinstance(e, dqlitedbapi.exceptions.OperationalError):
180+
# Explicit connection-level error types from the client layer.
181+
if isinstance(e, _client_exc.DqliteConnectionError):
182+
return True
183+
# Underlying OS-level transport failures (socket RST, broken pipe,
184+
# DNS, connect refused) surface as these stdlib types.
185+
if isinstance(e, (ConnectionError, BrokenPipeError, TimeoutError, OSError)):
186+
return True
187+
# Leader-change error codes signal that the connection is useless
188+
# even though it's TCP-alive.
189+
for err in (_dbapi_exc.OperationalError, _client_exc.OperationalError):
190+
if isinstance(e, err) and getattr(e, "code", None) in self._LEADER_CHANGE_CODES:
191+
return True
192+
# Legacy substring fallback — kept so we still catch anything
193+
# that wasn't modelled as a specific exception type yet.
194+
if isinstance(e, _dbapi_exc.OperationalError):
136195
msg = str(e)
137196
for pattern in self._dqlite_disconnect_messages:
138197
if pattern in msg:
139198
return True
140199
return super().is_disconnect(e, connection, cursor)
141200

142201
def do_ping(self, dbapi_connection: Any) -> bool:
143-
"""Check if the connection is still alive."""
202+
"""Check if the connection is still alive.
203+
204+
Only connection-level exceptions are interpreted as "dead"; any
205+
other exception propagates so the caller can see real bugs
206+
instead of having them silently rewritten as "please reconnect."
207+
"""
144208
cursor = dbapi_connection.cursor()
145209
try:
146-
cursor.execute("SELECT 1")
147-
return True
148-
except Exception:
149-
return False
210+
try:
211+
cursor.execute("SELECT 1")
212+
return True
213+
except (
214+
_dbapi_exc.OperationalError,
215+
_dbapi_exc.InterfaceError,
216+
_client_exc.DqliteConnectionError,
217+
OSError,
218+
TimeoutError,
219+
):
220+
return False
150221
finally:
151-
cursor.close()
222+
try:
223+
cursor.close()
224+
except Exception:
225+
pass # cursor close shouldn't crash the ping result
152226

153227
def _get_server_version_info(self, connection: Any) -> tuple[int, ...]:
154228
"""Return the server version as a tuple.
@@ -160,7 +234,10 @@ def _get_server_version_info(self, connection: Any) -> tuple[int, ...]:
160234
version_str = result.scalar()
161235
if version_str:
162236
return tuple(int(x) for x in version_str.split("."))
163-
except Exception:
237+
except (
238+
_dbapi_exc.OperationalError,
239+
_client_exc.DqliteConnectionError,
240+
):
164241
pass
165242
return (3, 0, 0)
166243

tests/test_dialect.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,18 @@ def test_does_not_access_dbapi_connection_directly(self) -> None:
111111
"should use connection.exec_driver_sql() instead"
112112
)
113113

114-
def test_returns_fallback_on_error(self) -> None:
115-
"""Should return (3, 0, 0) if the query fails."""
114+
def test_returns_fallback_on_operational_error(self) -> None:
115+
"""Should return (3, 0, 0) if the server query fails with a
116+
connection-level error. Unrelated errors (bugs) propagate."""
116117
from unittest.mock import MagicMock
117118

119+
import dqlitedbapi.exceptions
120+
118121
dialect = DqliteDialect()
119122
mock_conn = MagicMock()
120-
mock_conn.exec_driver_sql.side_effect = Exception("connection broken")
123+
mock_conn.exec_driver_sql.side_effect = dqlitedbapi.exceptions.OperationalError(
124+
"connection broken"
125+
)
121126

122127
result = dialect._get_server_version_info(mock_conn)
123128
assert result == (3, 0, 0)
@@ -300,23 +305,30 @@ def test_ping_returns_true_on_success(self) -> None:
300305
mock_conn = MagicMock()
301306
assert dialect.do_ping(mock_conn) is True
302307

303-
def test_ping_returns_false_on_error(self) -> None:
304-
"""do_ping should return False when query fails."""
308+
def test_ping_returns_false_on_connection_error(self) -> None:
309+
"""do_ping returns False on connection-level errors."""
305310
from unittest.mock import MagicMock
306311

312+
import dqlitedbapi.exceptions
313+
307314
dialect = DqliteDialect()
308315
mock_conn = MagicMock()
309-
mock_conn.cursor.return_value.execute.side_effect = RuntimeError("boom")
316+
mock_conn.cursor.return_value.execute.side_effect = (
317+
dqlitedbapi.exceptions.OperationalError("bye")
318+
)
310319
assert dialect.do_ping(mock_conn) is False
311320

312321
def test_ping_closes_cursor_even_on_error(self) -> None:
313-
"""do_ping must close cursor even when execute fails."""
322+
"""do_ping must close cursor even when execute fails with a
323+
connection-level error."""
314324
from unittest.mock import MagicMock
315325

326+
import dqlitedbapi.exceptions
327+
316328
dialect = DqliteDialect()
317329
mock_conn = MagicMock()
318330
mock_cursor = MagicMock()
319-
mock_cursor.execute.side_effect = RuntimeError("boom")
331+
mock_cursor.execute.side_effect = dqlitedbapi.exceptions.OperationalError("bye")
320332
mock_conn.cursor.return_value = mock_cursor
321333

322334
dialect.do_ping(mock_conn)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Dialect configuration tests covering recent hardening.
2+
3+
- ISSUE-16 ``do_ping`` narrowed to connection-level exceptions only.
4+
- ISSUE-18 ``is_disconnect`` type-dispatches before falling back to
5+
message substring matching.
6+
- ISSUE-19 ``create_connect_args`` plumbs the ``timeout`` URL query
7+
through and rejects typos.
8+
- ISSUE-21 ``set_isolation_level`` explicitly rejects AUTOCOMMIT.
9+
- ISSUE-23 ``supported_isolation_levels`` is declared.
10+
"""
11+
12+
from unittest.mock import MagicMock
13+
14+
import dqliteclient.exceptions
15+
import dqlitedbapi.exceptions
16+
import pytest
17+
from sqlalchemy.engine import make_url
18+
from sqlalchemy.exc import ArgumentError
19+
20+
from sqlalchemydqlite.base import DqliteDialect
21+
22+
23+
class TestSupportedIsolationLevels:
24+
def test_declared(self) -> None:
25+
assert DqliteDialect.supported_isolation_levels == ("SERIALIZABLE",)
26+
27+
28+
class TestSetIsolationLevel:
29+
def test_serializable_is_noop(self) -> None:
30+
dialect = DqliteDialect()
31+
dialect.set_isolation_level(MagicMock(), "SERIALIZABLE") # no raise
32+
33+
def test_autocommit_rejected(self) -> None:
34+
dialect = DqliteDialect()
35+
with pytest.raises(ArgumentError, match="AUTOCOMMIT"):
36+
dialect.set_isolation_level(MagicMock(), "AUTOCOMMIT")
37+
38+
def test_other_levels_warn(self) -> None:
39+
dialect = DqliteDialect()
40+
with pytest.warns(UserWarning, match="SERIALIZABLE"):
41+
dialect.set_isolation_level(MagicMock(), "READ COMMITTED")
42+
43+
44+
class TestCreateConnectArgsURLQuery:
45+
def test_timeout_forwarded(self) -> None:
46+
dialect = DqliteDialect()
47+
url = make_url("dqlite://host:19001/db?timeout=5.5")
48+
args, kwargs = dialect.create_connect_args(url)
49+
assert kwargs["timeout"] == 5.5
50+
assert kwargs["address"] == "host:19001"
51+
assert kwargs["database"] == "db"
52+
53+
def test_no_query_params_still_works(self) -> None:
54+
dialect = DqliteDialect()
55+
url = make_url("dqlite://host:19001/db")
56+
_, kwargs = dialect.create_connect_args(url)
57+
assert "timeout" not in kwargs
58+
59+
def test_unknown_param_raises(self) -> None:
60+
dialect = DqliteDialect()
61+
url = make_url("dqlite://host:19001/db?timeoutt=5") # typo
62+
with pytest.raises(ArgumentError, match="timeoutt"):
63+
dialect.create_connect_args(url)
64+
65+
def test_unparseable_timeout_raises(self) -> None:
66+
dialect = DqliteDialect()
67+
url = make_url("dqlite://host:19001/db?timeout=not-a-number")
68+
with pytest.raises(ArgumentError, match="float"):
69+
dialect.create_connect_args(url)
70+
71+
72+
class TestDoPingNarrowExceptions:
73+
def test_returns_true_on_success(self) -> None:
74+
dialect = DqliteDialect()
75+
conn = MagicMock()
76+
cursor = MagicMock()
77+
conn.cursor.return_value = cursor
78+
cursor.execute.return_value = None
79+
assert dialect.do_ping(conn) is True
80+
81+
def test_returns_false_on_operational_error(self) -> None:
82+
dialect = DqliteDialect()
83+
conn = MagicMock()
84+
cursor = MagicMock()
85+
conn.cursor.return_value = cursor
86+
cursor.execute.side_effect = dqlitedbapi.exceptions.OperationalError("bye")
87+
assert dialect.do_ping(conn) is False
88+
89+
def test_propagates_unexpected_exception(self) -> None:
90+
dialect = DqliteDialect()
91+
conn = MagicMock()
92+
cursor = MagicMock()
93+
conn.cursor.return_value = cursor
94+
cursor.execute.side_effect = RuntimeError("bug")
95+
with pytest.raises(RuntimeError, match="bug"):
96+
dialect.do_ping(conn)
97+
98+
99+
class TestIsDisconnectTypeDispatch:
100+
def test_dqlite_connection_error_is_disconnect(self) -> None:
101+
dialect = DqliteDialect()
102+
e = dqliteclient.exceptions.DqliteConnectionError("boom")
103+
assert dialect.is_disconnect(e, None, None) is True
104+
105+
def test_os_error_is_disconnect(self) -> None:
106+
dialect = DqliteDialect()
107+
e = OSError(111, "Connection refused")
108+
assert dialect.is_disconnect(e, None, None) is True
109+
110+
def test_broken_pipe_is_disconnect(self) -> None:
111+
dialect = DqliteDialect()
112+
assert dialect.is_disconnect(BrokenPipeError(), None, None) is True
113+
114+
def test_leader_change_code_is_disconnect(self) -> None:
115+
dialect = DqliteDialect()
116+
e = dqliteclient.exceptions.OperationalError(10250, "not leader")
117+
assert dialect.is_disconnect(e, None, None) is True
118+
119+
def test_unrelated_operational_error_is_not_disconnect(self) -> None:
120+
dialect = DqliteDialect()
121+
e = dqlitedbapi.exceptions.OperationalError("no such table")
122+
assert dialect.is_disconnect(e, None, None) is False

0 commit comments

Comments
 (0)