Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3cb80da
If the token is null, the connection hangs (#458)
Jul 23, 2026
11579f5
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
f255f4a
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
3afd559
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
7e3e520
ai: apply changes for #876 (2 review threads)
peco-engineer-bot[bot] Jul 23, 2026
8aa4e7d
ai: apply changes for #876 (2 review threads)
peco-engineer-bot[bot] Jul 23, 2026
9ea4bb8
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
121bd0b
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
ac27f93
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
2d6f729
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
eb1af56
ai: apply changes for #876 (2 review threads)
peco-engineer-bot[bot] Jul 23, 2026
bf48dae
ai: apply changes for #876 (2 review threads)
peco-engineer-bot[bot] Jul 23, 2026
ead14dd
ai: apply changes for #876 (2 review threads)
peco-engineer-bot[bot] Jul 23, 2026
91e698e
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
fbdb5dc
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
c10bee7
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
dcba798
ai: apply changes for #876 (1 review thread)
peco-engineer-bot[bot] Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/databricks/sql/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ def refresh(self) -> Token:


class OAuthManager:
# Maximum time (in seconds) to wait for the browser OAuth redirect callback
# before giving up. Without this, the local callback server would block
# forever in a headless environment (e.g. a notebook/job with no browser),
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Outdated
# making the connection appear to hang indefinitely. See issue #458.
REDIRECT_CALLBACK_TIMEOUT_SECONDS = 60 * 5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — This introduces a hard 5-minute cap on every U2M interactive login, not just the headless failure case, and — as the docstring itself notes — there is no public connect()/Connection parameter to change it. Previously handle_request() blocked indefinitely, so any working interactive flow that took longer than 5 minutes (slow MFA, hardware token, IdP re-auth, a user who steps away mid-login) never timed out. After this change those flows will now fail with the new Timed out ... RuntimeError. That's a behavior change for existing, successful desktop users, and they have no supported way to extend the bound.

Separately, for the exact reported scenario in #458 (headless notebook/job), the common path is webbrowser.open_new() returning False without raising webbrowser.Error (no browser registered). As the inline comment acknowledges, that case is not caught by the fast-fail branch and instead falls through to this timeout — so the reported "hang" becomes a full 5-minute wait before the error surfaces, rather than a prompt failure. The fix is correct and bounded, but consider (a) exposing the timeout as a public/documented knob, and/or (b) reducing the wait for the detectable-headless case so the common #458 path fails faster than 5 minutes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NEEDS HUMAN DECISION — the bots can't resolve this thread; a maintainer's input is required.

Agreed the concern is valid, but neither remedy is actionable in this bug-fix PR. (a) Exposing the redirect-callback timeout as a public/documented knob is a public API change — it must be plumbed through DatabricksOAuthProvider and the public connect()/Connection kwargs (the code deliberately keeps it internal-only), which is out of scope here and belongs in a separate API-review PR. (b) Fast-failing when webbrowser.open_new() returns False (the common #458 headless path) conflicts with a deliberate, documented decision (oauth.py ~L222-237) that a falsy return is not a reliable cross-platform headless signal and would break working interactive logins. The remaining question — the correct default ceiling and whether to expose a public override for a widely-consumed connector, given that logins >5 min that previously succeeded will now fail — is a product/API judgment call for a human maintainer, and further bot back-and-forth won't resolve it. Flagging for human review.


def __init__(
self,
port_range: List[int],
Expand Down Expand Up @@ -130,9 +136,24 @@ def __get_authorization_code(self, client, auth_url, scope, state, challenge):
handler = OAuthHttpSingleRequestHandler("Databricks Sql Connector")

last_error = None
callback_timed_out = False
for port in self.port_range:
try:
with HTTPServer(("", port), handler) as httpd:
# Bound how long we wait for the browser redirect callback so
# that a headless environment (no browser to complete the
# flow) fails with a clear error instead of hanging forever.
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
httpd.timeout = self.REDIRECT_CALLBACK_TIMEOUT_SECONDS
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Outdated

# HTTPServer.handle_request() returns normally (via
# handle_timeout()) when the wait elapses without a
# connection, so record that case to distinguish it from a
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
# received-but-empty callback below.
def _on_timeout():
nonlocal callback_timed_out

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The accept-wait timeout (httpd.timeout) bounds the entire interactive login duration, not just the headless case. handle_request() blocks in select() waiting to accept the local redirect connection, and that connection only arrives after the user finishes logging in via the browser. So a real user whose SSO/MFA/IdP re-auth takes longer than the default 5 minutes will now hit callback_timed_out and get a RuntimeError, whereas before the flow waited indefinitely and succeeded.

5 minutes is generous and the tradeoff is documented in the class comment, so this is likely acceptable — but note there is no public connect()/Connection kwarg to raise the ceiling (the redirect_callback_timeout_seconds arg is not plumbed through DatabricksOAuthProvider), so an end user on a slow corporate login flow that legitimately exceeds 5 minutes has no escape hatch. Consider plumbing the override through the public API, or confirm 5 minutes is comfortably above worst-case interactive login time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NEEDS HUMAN DECISION — the bots can't resolve this thread; a maintainer's input is required.

Valid concern, but not actionable in this bug-fix PR — needs a human/maintainer decision. The tradeoff is already documented in the OAuthManager class docstring (oauth.py:63–76): the 5-min ceiling is deliberate and redirect_callback_timeout_seconds is intentionally an internal-only override, not plumbed through DatabricksOAuthProvider or the public connect() kwargs. The reviewer's two suggestions are both out of scope here: (1) exposing a public escape-hatch kwarg is a public API change on a widely-consumed connector that belongs in a separate PR and is a maintainer/product decision, and (2) confirming 5 minutes comfortably exceeds worst-case interactive SSO/MFA/IdP login time is a product judgment I can't verify from code. Flagging for a human to decide whether to plumb a public timeout override in a follow-up PR.

callback_timed_out = True

httpd.handle_timeout = _on_timeout
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
redirect_url = OAuthManager.__get_redirect_url(port)
auth_req_uri, _, _ = client.prepare_authorization_request(
authorization_url=auth_url,
Expand Down Expand Up @@ -164,7 +185,16 @@ def __get_authorization_code(self, client, auth_url, scope, state, challenge):
raise last_error

if not handler.request_path:
msg = f"No path parameters were returned to the callback at {redirect_url}"
if callback_timed_out:
msg = (
f"Timed out after {self.REDIRECT_CALLBACK_TIMEOUT_SECONDS} "
f"seconds waiting for the OAuth redirect callback at "
f"{redirect_url}. No browser completed the login flow — this "
"is expected in a headless environment (e.g. a notebook or "
"job with no browser). See issue #458."
)
else:
msg = f"No path parameters were returned to the callback at {redirect_url}"
logger.error(msg)
raise RuntimeError(msg)
# This is a kludge because the parsing library expects https callbacks
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,73 @@ def test_get_python_sql_connector_default_auth(self, mock__initial_get_token):

self.assertEqual(auth_provider.external_provider._client_id, PYSQL_OAUTH_CLIENT_ID)

@patch("databricks.sql.auth.oauth.webbrowser.open_new")
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
@patch.object(OAuthManager, "_OAuthManager__fetch_well_known_config")
def test_get_tokens_does_not_hang_when_no_callback_received(
self, mock_fetch_config, mock_open_new
):
"""When the U2M browser OAuth callback never arrives (e.g. a headless
notebook/job with a null token), the local redirect server must not
block forever. It should time out and surface a clear error rather than
hang. See issue #458."""
mock_fetch_config.return_value = {
"authorization_endpoint": "https://foo.cloud.databricks.com/oidc/oauth2/v2.0/authorize",
"token_endpoint": "https://foo.cloud.databricks.com/oidc/oauth2/v2.0/token",
}
# Do not actually launch a browser during the test.
mock_open_new.return_value = True
Comment thread
peco-review-bot[bot] marked this conversation as resolved.

# Bind to an OS-assigned ephemeral port (0) rather than a fixed port so
# the test does not depend on a specific port being free. A fixed port
# that happens to be occupied would fail to bind and take the
# can't-find-free-port branch instead of the timeout path we exercise.
oauth_manager = OAuthManager(
port_range=[0],
client_id="mock-id",
idp_endpoint=InHouseOAuthEndpointCollection(),
http_client=MagicMock(),
)
# Keep the test fast: shorten the callback wait. The production default
# is minutes; the bug is that WITHOUT any bound the wait is infinite.
oauth_manager.REDIRECT_CALLBACK_TIMEOUT_SECONDS = 2

# No callback is ever delivered to the redirect server. Run the flow in
# a daemon thread and join with a wall-clock bound so a regression to
# the infinite-block behaviour fails this test loudly instead of hanging
# the whole suite.
import threading

result = {}

def run():
try:
oauth_manager.get_tokens(
hostname="foo.cloud.databricks.com", scope="offline_access sql"
)
result["outcome"] = "returned"
except BaseException as e: # noqa: BLE001
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
result["outcome"] = "raised"
result["error"] = e

worker = threading.Thread(target=run, daemon=True)
worker.start()
worker.join(timeout=30)

self.assertFalse(
worker.is_alive(),
"OAuth callback server blocked indefinitely waiting for a callback "
"that never arrives (issue #458)",
)
self.assertEqual(result.get("outcome"), "raised")
self.assertIsInstance(result.get("error"), RuntimeError)
# The headless timeout path must surface a clear, timeout-specific error
# (not the generic received-but-empty callback message). See issue #458.
error_message = str(result.get("error"))
self.assertIn("Timed out", error_message)
self.assertIn(
str(oauth_manager.REDIRECT_CALLBACK_TIMEOUT_SECONDS), error_message
)


class TestClientCredentialsTokenSource:
@pytest.fixture
Expand Down
Loading