Skip to content

Commit bf48dae

Browse files
ai: apply changes for #876 (2 review threads)
Addresses: - #3636204521 at src/databricks/sql/auth/authenticators.py:66 - #3636204532 at src/databricks/sql/auth/oauth.py:75 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
1 parent eb1af56 commit bf48dae

3 files changed

Lines changed: 77 additions & 6 deletions

File tree

src/databricks/sql/auth/authenticators.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import abc
22
import logging
3-
from typing import Callable, Dict, List, Optional
3+
from typing import Callable, Dict, List
44
from databricks.sql.common.http import HttpHeader
55
from databricks.sql.auth.oauth import (
66
OAuthManager,
@@ -63,7 +63,6 @@ def __init__(
6363
scopes: List[str],
6464
http_client,
6565
auth_type: str = "databricks-oauth",
66-
redirect_callback_timeout_seconds: Optional[int] = None,
6766
):
6867
try:
6968
idp_endpoint = get_oauth_endpoints(hostname, auth_type == "azure-oauth")
@@ -75,15 +74,11 @@ def __init__(
7574
# Convert to the corresponding scopes in the corresponding IdP
7675
cloud_scopes = idp_endpoint.get_scopes_mapping(scopes)
7776

78-
# Pass through the (optional) callback-timeout override so this
79-
# private provider can surface it later if a public connection
80-
# knob is added; None falls back to OAuthManager's default.
8177
self.oauth_manager = OAuthManager(
8278
port_range=redirect_port_range,
8379
client_id=client_id,
8480
idp_endpoint=idp_endpoint,
8581
http_client=http_client,
86-
redirect_callback_timeout_seconds=redirect_callback_timeout_seconds,
8782
)
8883
self._hostname = hostname
8984
self._scopes_as_str = DatabricksOAuthProvider.SCOPE_DELIM.join(cloud_scopes)

src/databricks/sql/auth/oauth.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,25 @@ def _on_timeout():
212212
if e.errno == errno.EADDRINUSE:
213213
logger.info(f"Port {port} is in use")
214214
last_error = e
215+
except webbrowser.Error as e:
216+
# No browser could be launched at all (webbrowser.get() found no
217+
# runnable browser). This is a strong, reliable headless signal
218+
# (e.g. a notebook/job with no display), so fail fast with a
219+
# clear error instead of waiting out the full redirect-callback
220+
# timeout. We do NOT fail fast merely on a falsy open_new return
221+
# value: that is unreliable across platforms (it can be falsy
222+
# even when a browser did open) and would break working
223+
# interactive logins. See issue #458. Placed before the generic
224+
# ``except Exception`` so this RuntimeError propagates instead of
225+
# being swallowed and retried against the remaining ports.
226+
msg = (
227+
"Could not launch a web browser to complete the U2M OAuth "
228+
f"login flow ({e}). This looks like a headless environment "
229+
"(e.g. a notebook or job with no browser), where browser-"
230+
"based OAuth cannot complete. See issue #458."
231+
)
232+
logger.error(msg)
233+
raise RuntimeError(msg) from e
215234
except Exception as e:
216235
logger.error("unexpected error: %s", e)
217236
if self.redirect_port is None:

tests/unit/test_auth.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,63 @@ def run():
381381
for sock in stalled_sockets:
382382
sock.close()
383383

384+
@patch("databricks.sql.auth.oauth.webbrowser.open_new")
385+
@patch.object(OAuthManager, "_OAuthManager__fetch_well_known_config")
386+
def test_get_tokens_fails_fast_when_no_browser_available(
387+
self, mock_fetch_config, mock_open_new
388+
):
389+
"""When no browser can be launched at all (webbrowser.open_new raises
390+
webbrowser.Error), the flow must fail fast with a clear headless error
391+
rather than waiting out the full redirect-callback timeout. See issue
392+
#458."""
393+
import webbrowser
394+
395+
mock_fetch_config.return_value = {
396+
"authorization_endpoint": "https://foo.cloud.databricks.com/oidc/oauth2/v2.0/authorize",
397+
"token_endpoint": "https://foo.cloud.databricks.com/oidc/oauth2/v2.0/token",
398+
}
399+
mock_open_new.side_effect = webbrowser.Error("no browser available")
400+
401+
# A large timeout: if the fail-fast path regressed, joining below would
402+
# hit the wall-clock bound and the test would fail loudly instead of
403+
# silently waiting minutes.
404+
oauth_manager = OAuthManager(
405+
port_range=[0],
406+
client_id="mock-id",
407+
idp_endpoint=InHouseOAuthEndpointCollection(),
408+
http_client=MagicMock(),
409+
redirect_callback_timeout_seconds=600,
410+
)
411+
412+
import threading
413+
414+
result = {}
415+
416+
def run():
417+
try:
418+
oauth_manager.get_tokens(
419+
hostname="foo.cloud.databricks.com", scope="offline_access sql"
420+
)
421+
result["outcome"] = "returned"
422+
except BaseException as e: # noqa: BLE001
423+
result["outcome"] = "raised"
424+
result["error"] = e
425+
426+
worker = threading.Thread(target=run, daemon=True)
427+
worker.start()
428+
worker.join(timeout=30)
429+
430+
self.assertFalse(
431+
worker.is_alive(),
432+
"OAuth flow did not fail fast when no browser was available",
433+
)
434+
self.assertEqual(result.get("outcome"), "raised")
435+
self.assertIsInstance(result.get("error"), RuntimeError)
436+
error_message = str(result.get("error"))
437+
self.assertIn("browser", error_message.lower())
438+
# The fail-fast path must not surface the accept-wait timeout message.
439+
self.assertNotIn("Timed out", error_message)
440+
384441

385442
class TestClientCredentialsTokenSource:
386443
@pytest.fixture

0 commit comments

Comments
 (0)