Skip to content

Commit 0c975f0

Browse files
Narrow do_ping's cursor.close exception handling
contextlib.suppress(Exception) on the final close call used to hide every error — including programming bugs like a ValueError or AttributeError introduced by a refactor inside Cursor.close. That silently degraded pool pre-ping to "always alive" while looking functional. Replace the broad suppress with the same narrow 5-class tuple the outer execute-level except already uses — OperationalError, InterfaceError, DqliteConnectionError, OSError, TimeoutError — and DEBUG-log the suppressed cause so operators can tell close-swallow from close-success in logs, matching the pattern AsyncAdaptedConnection.close already uses for rollback failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4652d02 commit 0c975f0

2 files changed

Lines changed: 130 additions & 3 deletions

File tree

src/sqlalchemydqlite/base.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Base dqlite dialect for SQLAlchemy."""
22

3-
import contextlib
43
import datetime
4+
import logging
55
import math
66
import types
77
import warnings
@@ -18,6 +18,8 @@
1818
import dqlitedbapi.exceptions as _dbapi_exc
1919
from dqlitewire import LEADER_ERROR_CODES as _LEADER_CHANGE_CODES
2020

21+
logger = logging.getLogger(__name__)
22+
2123
__all__ = ["DqliteDialect"]
2224

2325
_TRUE_TOKENS = frozenset({"1", "true", "yes", "on"})
@@ -372,8 +374,27 @@ def do_ping(self, dbapi_connection: Any) -> bool:
372374
):
373375
return False
374376
finally:
375-
with contextlib.suppress(Exception):
376-
cursor.close() # cursor close shouldn't crash the ping result
377+
# Narrow the suppression to the same set as the outer
378+
# except: connection-level errors are expected on a dead
379+
# socket and must not crash the ping; programming bugs
380+
# (TypeError, AttributeError, ValueError, …) must still
381+
# propagate so refactors can't silently break the probe.
382+
# DEBUG-log the suppressed cause so operators can tell
383+
# close-swallow from close-success in logs.
384+
try:
385+
cursor.close()
386+
except (
387+
_dbapi_exc.OperationalError,
388+
_dbapi_exc.InterfaceError,
389+
_client_exc.DqliteConnectionError,
390+
OSError,
391+
TimeoutError,
392+
) as exc:
393+
logger.debug(
394+
"do_ping: cursor.close failed (%s); proceeding",
395+
type(exc).__name__,
396+
exc_info=True,
397+
)
377398

378399
def _get_server_version_info(self, connection: Any) -> tuple[int, ...]:
379400
"""Return the server's SQLite version as a tuple.

tests/test_do_ping_close_narrow.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""``do_ping`` must not swallow programming bugs from cursor.close.
2+
3+
The inner ``SELECT 1`` / except-connection-errors loop correctly
4+
narrows to transport-level failures. The ``finally`` used to catch
5+
every ``Exception`` on ``cursor.close()`` — hiding e.g. a
6+
``ValueError`` / ``TypeError`` introduced by a refactor — and emit
7+
nothing to the logs. That is too broad: programming bugs must be
8+
visible, and legitimate connection-level close failures should leave
9+
a diagnostic trail.
10+
11+
Peer of ISSUE-297.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
18+
import pytest
19+
20+
import dqliteclient.exceptions as _client_exc
21+
import dqlitedbapi.exceptions as _dbapi_exc
22+
from sqlalchemydqlite.base import DqliteDialect
23+
24+
25+
class _CursorExecuteOK:
26+
"""Cursor whose execute succeeds; close is configurable per-test."""
27+
28+
def __init__(self, on_close: Exception | None) -> None:
29+
self._on_close = on_close
30+
31+
def execute(self, sql: str) -> None:
32+
pass
33+
34+
def close(self) -> None:
35+
if self._on_close is not None:
36+
raise self._on_close
37+
38+
39+
class _CursorExecuteRaises:
40+
"""Cursor whose execute raises a connection-level error."""
41+
42+
def __init__(self, on_execute: Exception, on_close: Exception | None) -> None:
43+
self._on_execute = on_execute
44+
self._on_close = on_close
45+
46+
def execute(self, sql: str) -> None:
47+
raise self._on_execute
48+
49+
def close(self) -> None:
50+
if self._on_close is not None:
51+
raise self._on_close
52+
53+
54+
class _FakeConnection:
55+
"""Minimal ``dbapi_connection`` shape — only ``cursor()`` is used."""
56+
57+
def __init__(self, cursor: object) -> None:
58+
self._cursor = cursor
59+
60+
def cursor(self) -> object:
61+
return self._cursor
62+
63+
64+
def test_do_ping_propagates_programming_bug_from_close() -> None:
65+
"""A ``ValueError`` from ``cursor.close`` must propagate.
66+
67+
A bare ``contextlib.suppress(Exception)`` used to hide this,
68+
effectively disabling pool pre-ping while looking like it
69+
worked.
70+
"""
71+
cursor = _CursorExecuteOK(on_close=ValueError("refactor bug"))
72+
conn = _FakeConnection(cursor)
73+
dialect = DqliteDialect()
74+
with pytest.raises(ValueError, match="refactor bug"):
75+
dialect.do_ping(conn)
76+
77+
78+
def test_do_ping_swallows_connection_level_close_errors_with_log(
79+
caplog: pytest.LogCaptureFixture,
80+
) -> None:
81+
"""A connection-level error on close is expected on a dead socket;
82+
it must be swallowed AND DEBUG-logged.
83+
"""
84+
caplog.set_level(logging.DEBUG, logger="sqlalchemydqlite.base")
85+
cursor = _CursorExecuteRaises(
86+
on_execute=_dbapi_exc.OperationalError("dead"),
87+
on_close=_client_exc.DqliteConnectionError("socket closed"),
88+
)
89+
conn = _FakeConnection(cursor)
90+
dialect = DqliteDialect()
91+
# Ping returns False for dead connection; does not raise.
92+
assert dialect.do_ping(conn) is False
93+
# Close-failure is logged at DEBUG.
94+
messages = [r.getMessage() for r in caplog.records if r.name == "sqlalchemydqlite.base"]
95+
assert any("cursor.close failed" in m for m in messages), messages
96+
97+
98+
def test_do_ping_happy_path_is_silent(caplog: pytest.LogCaptureFixture) -> None:
99+
"""Successful ping: execute succeeds, close succeeds, no DEBUG noise."""
100+
caplog.set_level(logging.DEBUG, logger="sqlalchemydqlite.base")
101+
cursor = _CursorExecuteOK(on_close=None)
102+
conn = _FakeConnection(cursor)
103+
dialect = DqliteDialect()
104+
assert dialect.do_ping(conn) is True
105+
messages = [r.getMessage() for r in caplog.records if r.name == "sqlalchemydqlite.base"]
106+
assert not messages, f"happy path must not emit DEBUG logs; got {messages!r}"

0 commit comments

Comments
 (0)