Skip to content

Commit 447e1bf

Browse files
msrathore-dbclaude
andcommitted
Improve is_disconnect error handling precision
- InterfaceError: check for "closed" in message instead of blanket True - Add RequestError handling for transport/network-level disconnect errors - Add "unexpectedly closed server side" to DatabaseError disconnect checks - Remove overly broad Error base class catch with loose string matching - Expand tests to cover all connector error messages with exact code paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6936842 commit 447e1bf

2 files changed

Lines changed: 104 additions & 20 deletions

File tree

src/databricks/sqlalchemy/base.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -357,22 +357,26 @@ def is_disconnect(self, e, connection, cursor):
357357
Returns:
358358
True if the error indicates a disconnect, False otherwise
359359
"""
360-
from databricks.sql.exc import InterfaceError, DatabaseError, Error
360+
from databricks.sql.exc import InterfaceError, DatabaseError, RequestError
361361

362-
# InterfaceError: Client-side errors (e.g., connection already closed)
362+
error_msg = str(e).lower()
363+
364+
# InterfaceError: closed connection/cursor errors from client.py
365+
# All raised when self.open is False:
363366
if isinstance(e, InterfaceError):
367+
return "closed" in error_msg
368+
369+
# RequestError (subclass of DatabaseError via OperationalError):
370+
# transport/network-level errors indicating connection is unusable.
371+
# Check before DatabaseError since RequestError is a subclass.
372+
if isinstance(e, RequestError):
364373
return True
365374

366-
# DatabaseError: Server-side errors with invalid handle indicate session expired
375+
# DatabaseError: server-side errors indicating session/operation gone
367376
if isinstance(e, DatabaseError):
368-
error_msg = str(e).lower()
369-
return "invalid" in error_msg and "handle" in error_msg
370-
371-
# Base Error class: Check for closed connection errors
372-
# This catches errors like "Cannot create cursor from closed connection"
373-
if isinstance(e, Error):
374-
error_msg = str(e).lower()
375-
return "closed" in error_msg or "cannot create cursor" in error_msg
377+
return ("invalid" in error_msg and "handle" in error_msg) or (
378+
"unexpectedly closed server side" in error_msg
379+
)
376380

377381
return False
378382

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,72 @@
11
"""Tests for DatabricksDialect.is_disconnect() method."""
22
import pytest
33
from databricks.sqlalchemy import DatabricksDialect
4-
from databricks.sql.exc import InterfaceError, DatabaseError, OperationalError
4+
from databricks.sql.exc import (
5+
InterfaceError,
6+
DatabaseError,
7+
OperationalError,
8+
RequestError,
9+
SessionAlreadyClosedError,
10+
CursorAlreadyClosedError,
11+
MaxRetryDurationError,
12+
NonRecoverableNetworkError,
13+
UnsafeToRetryError,
14+
)
515

616

717
class TestIsDisconnect:
818
@pytest.fixture
919
def dialect(self):
1020
return DatabricksDialect()
1121

12-
def test_interface_error_is_disconnect(self, dialect):
13-
"""InterfaceError (client-side) is always a disconnect."""
14-
error = InterfaceError("Cannot create cursor from closed connection")
15-
assert dialect.is_disconnect(error, None, None) is True
22+
# --- InterfaceError: closed connection/cursor (client.py) ---
23+
24+
def test_interface_error_closed_connection(self, dialect):
25+
"""All InterfaceError messages with 'closed' are disconnects."""
26+
test_cases = [
27+
InterfaceError("Cannot create cursor from closed connection"),
28+
InterfaceError("Cannot get autocommit on closed connection"),
29+
InterfaceError("Cannot set autocommit on closed connection"),
30+
InterfaceError("Cannot commit on closed connection"),
31+
InterfaceError("Cannot rollback on closed connection"),
32+
InterfaceError("Cannot get transaction isolation on closed connection"),
33+
InterfaceError("Cannot set transaction isolation on closed connection"),
34+
InterfaceError("Attempting operation on closed cursor"),
35+
]
36+
for error in test_cases:
37+
assert dialect.is_disconnect(error, None, None) is True
38+
39+
def test_interface_error_without_closed_not_disconnect(self, dialect):
40+
"""InterfaceError without 'closed' is not a disconnect."""
41+
error = InterfaceError("Some other interface error")
42+
assert dialect.is_disconnect(error, None, None) is False
43+
44+
# --- RequestError: transport/network-level errors ---
45+
46+
def test_request_error_is_disconnect(self, dialect):
47+
"""All RequestError instances are disconnects."""
48+
test_cases = [
49+
RequestError("HTTP client is closing or has been closed"),
50+
RequestError("Connection pool not initialized"),
51+
RequestError("HTTP request failed: max retries exceeded"),
52+
RequestError("HTTP request error: connection reset"),
53+
]
54+
for error in test_cases:
55+
assert dialect.is_disconnect(error, None, None) is True
56+
57+
def test_request_error_subclasses_are_disconnect(self, dialect):
58+
"""RequestError subclasses are all disconnects."""
59+
test_cases = [
60+
SessionAlreadyClosedError("Session already closed"),
61+
CursorAlreadyClosedError("Cursor already closed"),
62+
MaxRetryDurationError("Retry duration exceeded"),
63+
NonRecoverableNetworkError("HTTP 501"),
64+
UnsafeToRetryError("Unexpected HTTP error"),
65+
]
66+
for error in test_cases:
67+
assert dialect.is_disconnect(error, None, None) is True
68+
69+
# --- DatabaseError: server-side session/operation errors ---
1670

1771
def test_database_error_with_invalid_handle(self, dialect):
1872
"""DatabaseError with 'invalid handle' is a disconnect."""
@@ -25,21 +79,47 @@ def test_database_error_with_invalid_handle(self, dialect):
2579
for error in test_cases:
2680
assert dialect.is_disconnect(error, None, None) is True
2781

28-
def test_database_error_without_invalid_handle(self, dialect):
29-
"""DatabaseError without 'invalid handle' is not a disconnect."""
82+
def test_database_error_unexpectedly_closed_server_side(self, dialect):
83+
"""DatabaseError for operations closed server-side is a disconnect."""
84+
test_cases = [
85+
DatabaseError("Command abc123 unexpectedly closed server side"),
86+
DatabaseError("Command None unexpectedly closed server side"),
87+
]
88+
for error in test_cases:
89+
assert dialect.is_disconnect(error, None, None) is True
90+
91+
def test_database_error_without_disconnect_indicators(self, dialect):
92+
"""DatabaseError without disconnect indicators is not a disconnect."""
3093
test_cases = [
3194
DatabaseError("Syntax error in SQL"),
3295
DatabaseError("Table not found"),
3396
DatabaseError("Permission denied"),
97+
DatabaseError("Catalog name is required for get_schemas"),
98+
DatabaseError("Catalog name is required for get_columns"),
3499
]
35100
for error in test_cases:
36101
assert dialect.is_disconnect(error, None, None) is False
37102

38-
def test_other_errors_not_disconnect(self, dialect):
39-
"""Other exception types are not disconnects."""
103+
# --- OperationalError (non-RequestError) ---
104+
105+
def test_operational_error_not_disconnect(self, dialect):
106+
"""OperationalError without disconnect indicators is not a disconnect."""
40107
test_cases = [
41108
OperationalError("Timeout waiting for query"),
109+
OperationalError("Empty TColumn instance"),
110+
OperationalError("Unsupported TRowSet instance"),
111+
]
112+
for error in test_cases:
113+
assert dialect.is_disconnect(error, None, None) is False
114+
115+
# --- Other exceptions ---
116+
117+
def test_other_errors_not_disconnect(self, dialect):
118+
"""Non-connector exception types are not disconnects."""
119+
test_cases = [
42120
Exception("Some random error"),
121+
ValueError("Bad value"),
122+
RuntimeError("Runtime failure"),
43123
]
44124
for error in test_cases:
45125
assert dialect.is_disconnect(error, None, None) is False

0 commit comments

Comments
 (0)