Skip to content

Commit be39535

Browse files
committed
Merge main and resolve release history conflict
Signed-off-by: TangoEnSkai <21152231+TangoEnSkai@users.noreply.github.com>
2 parents 3265132 + 70427d7 commit be39535

15 files changed

Lines changed: 872 additions & 47 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
# Unreleased
44
- Fix: a rejected token-federation exchange now reports the reason the endpoint gave. `_exchange_token` raised `KeyError: 'access_token'` on an OAuth error body, so the connection logged `Token exchange failed, using external token: 'access_token'` and the endpoint's `error` / `error_description` were discarded. It now raises a `ValueError` naming the endpoint, the HTTP status, and the returned error, and a non-JSON body reports the endpoint and status instead of surfacing a `JSONDecodeError`. The graceful fallback to the external token is unchanged ([#904](https://github.com/databricks/databricks-sql-python/issues/904))
5+
- Transparently auto-recover Thrift connections to Reyden / Real-Time warehouses: when a warehouse rejects the default Thrift protocol (SQLSTATE `KP001`), the session is re-opened on the kernel backend and the warehouse is remembered so later connections skip Thrift. Applies only when no backend was chosen explicitly.
6+
- Reject an mTLS private key without a client certificate, and identify missing or empty client certificate/key files in connection errors.
57

68
# 4.5.0 (2026-09-01)
79
- Upgrade Databricks SQL Kernel to 1.0.0.

‎src/databricks/sql/backend/kernel/client.py‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
if TYPE_CHECKING:
5454
from databricks.sql.client import Cursor
5555
from databricks.sql.result_set import ResultSet
56+
from databricks.sql.types import SSLOptions
5657

5758
# Type-annotation-only import (deferred by ``from __future__ import
5859
# annotations``). ``execute_command`` accepts the Thrift-shaped
@@ -1078,7 +1079,7 @@ def max_download_threads(self) -> int:
10781079
}
10791080

10801081

1081-
def _kernel_tls_kwargs(ssl_options) -> Dict[str, Any]:
1082+
def _kernel_tls_kwargs(ssl_options: Optional[SSLOptions]) -> Dict[str, Any]:
10821083
"""Translate the connector's ``SSLOptions`` into the kernel
10831084
``Session``'s ``tls_*`` kwargs.
10841085
@@ -1102,6 +1103,11 @@ def _kernel_tls_kwargs(ssl_options) -> Dict[str, Any]:
11021103
if ssl_options is None:
11031104
return {}
11041105

1106+
# The kernel rejects an in-memory key without a certificate, but a lone key file
1107+
# used to be dropped here before it could reach that validation. Reject the
1108+
# incomplete connector configuration directly instead.
1109+
ssl_options.validate_client_identity()
1110+
11051111
kwargs: Dict[str, Any] = {}
11061112

11071113
# Inverted booleans. Emit only the insecure (skip) direction so the
@@ -1111,18 +1117,18 @@ def _kernel_tls_kwargs(ssl_options) -> Dict[str, Any]:
11111117
# own semantics (``create_ssl_context`` sets ``check_hostname=False``
11121118
# whenever ``tls_verify`` is False). Without this the kernel could
11131119
# still attempt a hostname check the connector considers disabled.
1114-
if getattr(ssl_options, "tls_verify", True) is False:
1120+
if ssl_options.tls_verify is False:
11151121
kwargs["tls_skip_verify"] = True
11161122
kwargs["tls_skip_hostname_verify"] = True
1117-
elif getattr(ssl_options, "tls_verify_hostname", True) is False:
1123+
elif ssl_options.tls_verify_hostname is False:
11181124
kwargs["tls_skip_hostname_verify"] = True
11191125

1120-
ca_file = getattr(ssl_options, "tls_trusted_ca_file", None)
1126+
ca_file = ssl_options.tls_trusted_ca_file
11211127
if ca_file:
11221128
kwargs["tls_ca_cert"] = _read_pem_bytes(ca_file, "tls_trusted_ca_file")
11231129

1124-
cert_file = getattr(ssl_options, "tls_client_cert_file", None)
1125-
key_file = getattr(ssl_options, "tls_client_cert_key_file", None)
1130+
cert_file = ssl_options.tls_client_cert_file
1131+
key_file = ssl_options.tls_client_cert_key_file
11261132
if cert_file:
11271133
# The kernel pairs cert + key for mutual TLS; a cert without a
11281134
# key (or vice versa) is rejected kernel-side. The connector's
@@ -1135,7 +1141,7 @@ def _kernel_tls_kwargs(ssl_options) -> Dict[str, Any]:
11351141
# The kernel has no surface for an encrypted client key today.
11361142
# Reject loudly rather than hand the kernel a key it can't
11371143
# decrypt (which would fail with an opaque TLS parse error).
1138-
if getattr(ssl_options, "tls_client_cert_key_password", None):
1144+
if ssl_options.tls_client_cert_key_password:
11391145
raise NotSupportedError(
11401146
"use_kernel=True does not support a password-protected mTLS "
11411147
"client key (tls_client_cert_key_password). Provide an "
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Process-wide cache of warehouses known to reject the legacy Thrift protocol.
2+
3+
A Reyden / Real-Time SQL warehouse rejects a Thrift ``OpenSession`` — the SQL
4+
Gateway proxy stamps SQLSTATE ``KP001`` on the rejection. When the driver
5+
auto-recovers by re-opening on the kernel backend, it records the warehouse
6+
here so later connections to the same warehouse skip the doomed Thrift attempt
7+
and open on the kernel directly.
8+
9+
Keyed by ``(host, warehouse_id)``. Warehouse ids are globally unique, so the
10+
warehouse id alone identifies the warehouse — even on a SPOG host shared by many
11+
workspaces (where only the ``?o=<workspace-id>`` path param distinguishes them),
12+
there is no cross-workspace collision. The host is kept in the key only as a
13+
cheap optimization (scoping lookups) and defense-in-depth, not for correctness.
14+
Entries expire after ``_TTL_SECONDS`` so a warehouse later reconfigured to accept
15+
Thrift is eventually retried.
16+
"""
17+
18+
import re
19+
import threading
20+
import time
21+
from typing import Dict, Optional, Tuple
22+
23+
# A warehouse's Reyden membership can change (an id may be recreated on a
24+
# Thrift-capable endpoint), so cached entries are re-validated after this long.
25+
# Matches the ADBC driver's 6-hour horizon.
26+
_TTL_SECONDS = 6 * 60 * 60
27+
28+
# Warehouse paths look like ``/sql/1.0/warehouses/<id>`` or
29+
# ``.../endpoints/<id>``; the id stops at the next ``/``, ``?`` or ``&`` (e.g. a
30+
# ``?o=`` SPOG routing param). All-purpose-compute cluster paths carry no
31+
# warehouse id and never match — they are never Reyden warehouses.
32+
_WAREHOUSE_PATH_RE = re.compile(r".*/(?:warehouses|endpoints)/([^?&/]+)")
33+
34+
35+
def extract_warehouse_id(http_path: Optional[str]) -> Optional[str]:
36+
"""Return the warehouse/endpoint id embedded in ``http_path``, or ``None``."""
37+
if not http_path:
38+
return None
39+
match = _WAREHOUSE_PATH_RE.match(http_path)
40+
return match.group(1) if match else None
41+
42+
43+
class _ReydenWarehouseCache:
44+
def __init__(self, ttl_seconds: float = _TTL_SECONDS) -> None:
45+
self._ttl_seconds = ttl_seconds
46+
self._lock = threading.Lock()
47+
# (host_lowercased, warehouse_id) -> monotonic expiry deadline
48+
self._expiry: Dict[Tuple[str, str], float] = {}
49+
50+
@staticmethod
51+
def _key(host: str, warehouse_id: str) -> Tuple[str, str]:
52+
return (host.lower(), warehouse_id)
53+
54+
def mark_reyden(self, host: str, warehouse_id: str) -> None:
55+
now = time.monotonic()
56+
with self._lock:
57+
# Opportunistic sweep: mark_reyden only runs on an actual Thrift
58+
# rejection (rare), so purging every expired entry here is near-free
59+
# and bounds the cache to warehouses seen within the TTL window
60+
# rather than every warehouse ever seen (the per-key lazy eviction
61+
# in is_known_reyden never reclaims a warehouse that is not looked
62+
# up again).
63+
for key in [k for k, deadline in self._expiry.items() if deadline <= now]:
64+
del self._expiry[key]
65+
self._expiry[self._key(host, warehouse_id)] = now + self._ttl_seconds
66+
67+
def is_known_reyden(self, host: str, warehouse_id: str) -> bool:
68+
key = self._key(host, warehouse_id)
69+
now = time.monotonic()
70+
with self._lock:
71+
deadline = self._expiry.get(key)
72+
if deadline is None:
73+
return False
74+
if deadline <= now:
75+
# Lazily evict so a reconfigured warehouse is retried over Thrift.
76+
del self._expiry[key]
77+
return False
78+
return True
79+
80+
def clear(self) -> None:
81+
with self._lock:
82+
self._expiry.clear()
83+
84+
85+
# Process-wide singleton; multi-tenant safe via the host component of the key.
86+
_CACHE = _ReydenWarehouseCache()
87+
88+
89+
def mark_reyden(host: str, warehouse_id: str) -> None:
90+
"""Record that ``warehouse_id`` on ``host`` rejects the Thrift protocol."""
91+
_CACHE.mark_reyden(host, warehouse_id)
92+
93+
94+
def is_known_reyden(host: str, warehouse_id: str) -> bool:
95+
"""Whether ``warehouse_id`` on ``host`` is known (unexpired) to reject Thrift."""
96+
return _CACHE.is_known_reyden(host, warehouse_id)
97+
98+
99+
def clear_cache() -> None:
100+
"""Reset the cache. Intended for tests."""
101+
_CACHE.clear()

‎src/databricks/sql/backend/thrift_backend.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,23 @@ def _initialize_retry_args(self, kwargs):
284284
)
285285

286286
@staticmethod
287-
def _check_response_for_error(response, host_url=None):
287+
def _check_response_for_error(response, host_url=None, detect_reyden=False):
288288
if response.status and response.status.statusCode in [
289289
ttypes.TStatusCode.ERROR_STATUS,
290290
ttypes.TStatusCode.INVALID_HANDLE_STATUS,
291291
]:
292+
# A Reyden / Real-Time warehouse rejects the legacy Thrift protocol
293+
# with SQLSTATE KP001, but only at OpenSession. `detect_reyden` gates
294+
# the marker to that call so a stray KP001 on any other RPC surfaces
295+
# as a normal DatabaseError (the connection-layer recovery only wraps
296+
# session open). host_url is deliberately omitted on the marker: it is
297+
# a recoverable signal, not a terminal failure, so it must not emit a
298+
# failure-telemetry event here.
299+
if (
300+
detect_reyden
301+
and response.status.sqlState == ReydenThriftUnsupportedError.SQL_STATE
302+
):
303+
raise ReydenThriftUnsupportedError(response.status.errorMessage)
292304
raise DatabaseError(
293305
response.status.errorMessage,
294306
host_url=host_url,
@@ -520,7 +532,14 @@ def attempt_request(attempt):
520532
if not isinstance(response_or_error_info, RequestErrorInfo):
521533
# log nothing here, presume that main request logging covers
522534
response = response_or_error_info
523-
ThriftDatabricksClient._check_response_for_error(response, self._host)
535+
# Only OpenSession opts into KP001→Reyden-marker detection (the
536+
# rejection is stamped only there). Mirrors the method.__name__
537+
# discrimination already used above for GetOperationStatus.
538+
ThriftDatabricksClient._check_response_for_error(
539+
response,
540+
self._host,
541+
detect_reyden=getattr(method, "__name__", None) == "OpenSession",
542+
)
524543
return response
525544

526545
error_info = response_or_error_info

‎src/databricks/sql/client.py‎

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
ProgrammingError,
3636
TransactionError,
3737
DatabaseError,
38+
ReydenThriftUnsupportedError,
39+
)
40+
from databricks.sql.backend.reyden_warehouse_cache import (
41+
extract_warehouse_id,
42+
is_known_reyden,
43+
mark_reyden,
3844
)
3945

4046
from databricks.sql.backend.databricks_client import DatabricksClient
@@ -66,7 +72,7 @@
6672
from databricks.sql.session import Session
6773
from databricks.sql.backend.types import CommandId, BackendType, CommandState, SessionId
6874

69-
from databricks.sql.auth.common import ClientContext
75+
from databricks.sql.auth.common import AuthType, ClientContext
7076
from databricks.sql.common.unified_http_client import UnifiedHttpClient
7177
from databricks.sql.common.http import HttpMethod
7278

@@ -399,24 +405,30 @@ def read(self) -> Optional[OAuthToken]:
399405
self.http_client = UnifiedHttpClient(client_context)
400406

401407
try:
402-
self.session = Session(
408+
self.session = self._open_session_with_reyden_fallback(
403409
server_hostname,
404410
http_path,
405-
self.http_client,
406411
http_headers,
407412
session_configuration,
408413
catalog,
409414
schema,
410415
_use_arrow_native_complex_types,
411-
**kwargs,
416+
kwargs,
412417
)
413-
self.session.open()
414418
except Exception as e:
415419
# Respect user's telemetry preference even during connection failure.
416-
# For use_kernel connections the kernel owns telemetry, so suppress
417-
# the wrapper-side failure log to avoid wrapper-vs-kernel duplication.
418-
enable_telemetry = kwargs.get("enable_telemetry", True) and not kwargs.get(
419-
"use_kernel", False
420+
# For a kernel connection the kernel owns telemetry, so suppress the
421+
# wrapper-side failure log to avoid wrapper-vs-kernel duplication.
422+
# Read the backend from the session that actually failed rather than
423+
# the caller's kwargs: on the Reyden auto-recovery path we retry on
424+
# the kernel via a kwargs copy, so the original kwargs still says
425+
# Thrift. If the kernel never got constructed (e.g. its wheel is
426+
# missing), self.session is the Thrift session and we still log.
427+
attempted_kernel = getattr(
428+
getattr(self, "session", None), "use_kernel", False
429+
)
430+
enable_telemetry = (
431+
kwargs.get("enable_telemetry", True) and not attempted_kernel
420432
)
421433
TelemetryClientFactory.connection_failure_log(
422434
error_name="Exception",
@@ -512,6 +524,106 @@ def read(self) -> Optional[OAuthToken]:
512524
session_id=self.get_session_id_hex(),
513525
)
514526

527+
def _open_session_with_reyden_fallback(
528+
self,
529+
server_hostname: str,
530+
http_path: str,
531+
http_headers,
532+
session_configuration,
533+
catalog,
534+
schema,
535+
_use_arrow_native_complex_types,
536+
kwargs: dict,
537+
) -> Session:
538+
"""Open a ``Session``, transparently recovering onto the kernel backend
539+
when a Reyden / Real-Time warehouse rejects the default Thrift protocol.
540+
541+
Reyden warehouses reject a Thrift ``OpenSession`` (SQLSTATE ``KP001``);
542+
the kernel (SEA) backend is the supported path. Auto-recovery applies
543+
only when the caller did not pick a backend explicitly (neither
544+
``use_kernel`` nor ``use_sea``). On a rejection the warehouse is
545+
remembered so later connections skip the doomed Thrift attempt.
546+
"""
547+
548+
def build_session(session_kwargs: dict) -> Session:
549+
# Assign self.session before open() so a failed open still leaves the
550+
# attempted session on the connection — __del__ and the failure
551+
# telemetry log both rely on self.session being present.
552+
self.session = Session(
553+
server_hostname,
554+
http_path,
555+
self.http_client,
556+
http_headers,
557+
session_configuration,
558+
catalog,
559+
schema,
560+
_use_arrow_native_complex_types,
561+
**session_kwargs,
562+
)
563+
self.session.open()
564+
return self.session
565+
566+
def kernel_recovery_kwargs() -> dict:
567+
# Kwargs for re-opening on the kernel. The Thrift path treats an
568+
# unset auth_type as databricks-oauth (see get_auth_provider); the
569+
# kernel path has no such fallback and rejects auth_type=None unless
570+
# a credential shape (PAT / OAuth M2M) is present. Mirror the Thrift
571+
# default so a bare OAuth-U2M connection recovers instead of failing
572+
# with NotSupportedError. Skip the injection when a credential shape
573+
# is already present — the kernel routes on it regardless of
574+
# auth_type, and forcing databricks-oauth alongside an M2M secret or
575+
# a credentials_provider would change that routing.
576+
recovery_kwargs = {**kwargs, "use_kernel": True}
577+
has_credential_shape = (
578+
recovery_kwargs.get("access_token")
579+
or recovery_kwargs.get("oauth_client_secret")
580+
or recovery_kwargs.get("oauth_jwt_key_file")
581+
or recovery_kwargs.get("credentials_provider")
582+
)
583+
if recovery_kwargs.get("auth_type") is None and not has_credential_shape:
584+
recovery_kwargs["auth_type"] = AuthType.DATABRICKS_OAUTH.value
585+
return recovery_kwargs
586+
587+
# An explicit backend choice is always honored — auto-recovery engages
588+
# only on the default (Thrift) path.
589+
explicit_backend = kwargs.get("use_kernel", False) or kwargs.get(
590+
"use_sea", False
591+
)
592+
if explicit_backend:
593+
return build_session(kwargs)
594+
595+
warehouse_id = extract_warehouse_id(http_path)
596+
597+
# Pre-check: a warehouse already seen to reject Thrift opens straight on
598+
# the kernel, skipping the doomed Thrift OpenSession round-trip.
599+
if warehouse_id and is_known_reyden(server_hostname, warehouse_id):
600+
logger.info(
601+
"Warehouse %s on %s is known to require the kernel backend; "
602+
"opening on the kernel and skipping Thrift.",
603+
warehouse_id,
604+
server_hostname,
605+
)
606+
return build_session(kernel_recovery_kwargs())
607+
608+
try:
609+
return build_session(kwargs)
610+
except ReydenThriftUnsupportedError as thrift_ex:
611+
logger.info(
612+
"Thrift is not supported for this Reyden/Real-Time warehouse; "
613+
"transparently re-opening the session on the kernel backend."
614+
)
615+
# Remember the rejection regardless of the retry's outcome — the
616+
# warehouse is Reyden either way, so future connects should skip
617+
# Thrift; a kernel failure below is a separate, orthogonal problem.
618+
if warehouse_id:
619+
mark_reyden(server_hostname, warehouse_id)
620+
try:
621+
return build_session(kernel_recovery_kwargs())
622+
except Exception as kernel_ex:
623+
# Surface the kernel failure (the actionable one) while keeping
624+
# the original Thrift rejection in the chain for diagnosis.
625+
raise kernel_ex from thrift_ex
626+
515627
def _set_use_inline_params_with_warning(self, value: Union[bool, str]):
516628
"""Valid values are True, False, and "silent"
517629

0 commit comments

Comments
 (0)