From 3cb80da667cbc52e91a5cd80bfe40a3fc31d2e4f Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" <3815206+peco-engineer-bot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:56:52 +0000 Subject: [PATCH 01/17] If the token is null, the connection hangs (#458) Signed-off-by: peco-engineer-bot[bot] <3815206+peco-engineer-bot[bot]@users.noreply.github.com> --- src/databricks/sql/auth/oauth.py | 10 ++++++ tests/unit/test_auth.py | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 231a18905..4e42c798d 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -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), + # making the connection appear to hang indefinitely. See issue #458. + REDIRECT_CALLBACK_TIMEOUT_SECONDS = 60 * 5 + def __init__( self, port_range: List[int], @@ -133,6 +139,10 @@ def __get_authorization_code(self, client, auth_url, scope, state, challenge): 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. + httpd.timeout = self.REDIRECT_CALLBACK_TIMEOUT_SECONDS redirect_url = OAuthManager.__get_redirect_url(port) auth_req_uri, _, _ = client.prepare_authorization_request( authorization_url=auth_url, diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index d1b941208..fc8119abd 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -207,6 +207,63 @@ 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") + @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 + + oauth_manager = OAuthManager( + port_range=[8030], + 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 + 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) + self.assertIn("No path parameters were returned", str(result.get("error"))) + class TestClientCredentialsTokenSource: @pytest.fixture From 11579f594cb192fd55bfe1324e3c7ca51726d318 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 06:03:35 +0000 Subject: [PATCH 02/17] ai: apply changes for #876 (1 review thread) Addresses: - #3635880990 at src/databricks/sql/auth/oauth.py:145 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 22 +++++++++++++++++++++- tests/unit/test_auth.py | 8 +++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 4e42c798d..a60ef9345 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -136,6 +136,7 @@ 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: @@ -143,6 +144,16 @@ def __get_authorization_code(self, client, auth_url, scope, state, challenge): # that a headless environment (no browser to complete the # flow) fails with a clear error instead of hanging forever. httpd.timeout = self.REDIRECT_CALLBACK_TIMEOUT_SECONDS + + # HTTPServer.handle_request() returns normally (via + # handle_timeout()) when the wait elapses without a + # connection, so record that case to distinguish it from a + # received-but-empty callback below. + def _on_timeout(): + nonlocal callback_timed_out + callback_timed_out = True + + httpd.handle_timeout = _on_timeout redirect_url = OAuthManager.__get_redirect_url(port) auth_req_uri, _, _ = client.prepare_authorization_request( authorization_url=auth_url, @@ -174,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 diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index fc8119abd..bd53f9773 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -262,7 +262,13 @@ def run(): ) self.assertEqual(result.get("outcome"), "raised") self.assertIsInstance(result.get("error"), RuntimeError) - self.assertIn("No path parameters were returned", str(result.get("error"))) + # 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: From f255f4a18f4a808b78ef8817293e36b28eaebeba Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 06:10:26 +0000 Subject: [PATCH 03/17] ai: apply changes for #876 (1 review thread) Addresses: - #3635910705 at tests/unit/test_auth.py:224 Signed-off-by: peco-engineer-bot[bot] --- tests/unit/test_auth.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index bd53f9773..d37300adf 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -223,8 +223,12 @@ def test_get_tokens_does_not_hang_when_no_callback_received( # Do not actually launch a browser during the test. mock_open_new.return_value = True + # 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=[8030], + port_range=[0], client_id="mock-id", idp_endpoint=InHouseOAuthEndpointCollection(), http_client=MagicMock(), From 3afd55940f7ab3c0d9edca0330b0d359389a385a Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 06:14:51 +0000 Subject: [PATCH 04/17] ai: apply changes for #876 (1 review thread) Addresses: - #3635937318 at src/databricks/sql/auth/oauth.py:67 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index a60ef9345..2a175cd26 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -62,10 +62,13 @@ 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), - # making the connection appear to hang indefinitely. See issue #458. + # Default maximum time (in seconds) to wait for the browser OAuth redirect + # callback before giving up. Without a bound, the local callback server + # would block forever in a headless environment (e.g. a notebook/job with + # no browser), making the connection appear to hang indefinitely. See issue + # #458. This is generous for interactive logins (slow MFA, IdP re-auth, SSO + # redirects); environments that need longer can override it by passing + # ``redirect_callback_timeout_seconds`` to ``__init__``. REDIRECT_CALLBACK_TIMEOUT_SECONDS = 60 * 5 def __init__( @@ -74,12 +77,18 @@ def __init__( client_id: str, idp_endpoint: OAuthEndpointCollection, http_client, + redirect_callback_timeout_seconds: Optional[int] = None, ): self.port_range = port_range self.client_id = client_id self.redirect_port = None self.idp_endpoint = idp_endpoint self.http_client = http_client + # Fall back to the class default when not overridden. Set as an instance + # attribute so the rest of the flow reads a single source of truth via + # ``self.REDIRECT_CALLBACK_TIMEOUT_SECONDS``. + if redirect_callback_timeout_seconds is not None: + self.REDIRECT_CALLBACK_TIMEOUT_SECONDS = redirect_callback_timeout_seconds @staticmethod def __token_urlsafe(nbytes=32): From 7e3e52034dbd960654562f551edda0ee7bb6a44b Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 06:22:03 +0000 Subject: [PATCH 05/17] ai: apply changes for #876 (2 review threads) Addresses: - #3635960114 at src/databricks/sql/auth/oauth.py:70 - #3635960121 at src/databricks/sql/auth/oauth.py:91 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 26 +++++++++++++++++--------- tests/unit/test_auth.py | 4 ++-- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 2a175cd26..8f773d86e 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -67,8 +67,12 @@ class OAuthManager: # would block forever in a headless environment (e.g. a notebook/job with # no browser), making the connection appear to hang indefinitely. See issue # #458. This is generous for interactive logins (slow MFA, IdP re-auth, SSO - # redirects); environments that need longer can override it by passing - # ``redirect_callback_timeout_seconds`` to ``__init__``. + # redirects) and is a fixed ceiling for connector end users: there is no + # public ``connect()``/``Connection`` parameter to change it. The + # ``redirect_callback_timeout_seconds`` argument below is an internal-only + # override for callers constructing ``OAuthManager`` directly (private API); + # it is not plumbed through ``DatabricksOAuthProvider`` or the public + # connection kwargs. REDIRECT_CALLBACK_TIMEOUT_SECONDS = 60 * 5 def __init__( @@ -84,11 +88,15 @@ def __init__( self.redirect_port = None self.idp_endpoint = idp_endpoint self.http_client = http_client - # Fall back to the class default when not overridden. Set as an instance - # attribute so the rest of the flow reads a single source of truth via - # ``self.REDIRECT_CALLBACK_TIMEOUT_SECONDS``. - if redirect_callback_timeout_seconds is not None: - self.REDIRECT_CALLBACK_TIMEOUT_SECONDS = redirect_callback_timeout_seconds + # Fall back to the class default when not overridden. Kept as a + # lowercase instance attribute (distinct from the ALL_CAPS class + # constant that provides the default) so the rest of the flow reads a + # single, clearly per-instance source of truth. + self._redirect_callback_timeout_seconds = ( + redirect_callback_timeout_seconds + if redirect_callback_timeout_seconds is not None + else self.REDIRECT_CALLBACK_TIMEOUT_SECONDS + ) @staticmethod def __token_urlsafe(nbytes=32): @@ -152,7 +160,7 @@ def __get_authorization_code(self, client, auth_url, scope, state, challenge): # 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. - httpd.timeout = self.REDIRECT_CALLBACK_TIMEOUT_SECONDS + httpd.timeout = self._redirect_callback_timeout_seconds # HTTPServer.handle_request() returns normally (via # handle_timeout()) when the wait elapses without a @@ -196,7 +204,7 @@ def _on_timeout(): if not handler.request_path: if callback_timed_out: msg = ( - f"Timed out after {self.REDIRECT_CALLBACK_TIMEOUT_SECONDS} " + 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 " diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index d37300adf..d976c6131 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -235,7 +235,7 @@ def test_get_tokens_does_not_hang_when_no_callback_received( ) # 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 + 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 @@ -271,7 +271,7 @@ def run(): error_message = str(result.get("error")) self.assertIn("Timed out", error_message) self.assertIn( - str(oauth_manager.REDIRECT_CALLBACK_TIMEOUT_SECONDS), error_message + str(oauth_manager._redirect_callback_timeout_seconds), error_message ) From 8aa4e7d803fb17aa20ce9ee8bd06a2bf0aab7c11 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 06:28:09 +0000 Subject: [PATCH 06/17] ai: apply changes for #876 (2 review threads) Addresses: - #3635989590 at src/databricks/sql/auth/oauth.py:74 - #3635989594 at tests/unit/test_auth.py:254 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 8 +++++--- tests/unit/test_auth.py | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 8f773d86e..8e6b525a1 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -206,9 +206,11 @@ def _on_timeout(): 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." + f"{redirect_url}. The login flow was not completed in time — " + "either the interactive login was not finished within the " + "timeout, or this is a headless environment (e.g. a notebook " + "or job with no browser) where no browser can complete the " + "flow. See issue #458." ) else: msg = f"No path parameters were returned to the callback at {redirect_url}" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index d976c6131..afcea79cc 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -227,15 +227,18 @@ def test_get_tokens_does_not_hang_when_no_callback_received( # 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. + # Keep the test fast: shorten the callback wait via the constructor + # keyword (the actual new surface this PR introduces) rather than + # mutating the private attribute after the fact. This also exercises the + # Optional[int] fall-back logic. The production default is minutes; the + # bug is that WITHOUT any bound the wait is infinite. oauth_manager = OAuthManager( port_range=[0], client_id="mock-id", idp_endpoint=InHouseOAuthEndpointCollection(), http_client=MagicMock(), + redirect_callback_timeout_seconds=2, ) - # 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 From 9ea4bb80ed262bbe478cea707003da53e1a30065 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 06:35:53 +0000 Subject: [PATCH 07/17] ai: apply changes for #876 (1 review thread) Addresses: - #3636022364 at src/databricks/sql/auth/oauth.py:162 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 8e6b525a1..63bcd3175 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -160,6 +160,14 @@ def __get_authorization_code(self, client, auth_url, scope, state, challenge): # 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. + # NOTE: httpd.timeout bounds only the wait to ACCEPT an + # incoming connection (it is the select() timeout used by + # handle_request). It does not bound the subsequent read of + # the HTTP request by the handler, so a client that connects + # but never completes the request line could still block. On + # this short-lived loopback server that residual gap is + # acceptable; the headless "no connection ever arrives" case + # (issue #458) is fully covered. httpd.timeout = self._redirect_callback_timeout_seconds # HTTPServer.handle_request() returns normally (via From 121bd0b813cb89bd4e0cf20e34846e36af6f21fb Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 06:44:32 +0000 Subject: [PATCH 08/17] ai: apply changes for #876 (1 review thread) Addresses: - #3636059804 at src/databricks/sql/auth/oauth.py:175 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 63bcd3175..a8633c76d 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -151,6 +151,11 @@ def __get_challenge(): def __get_authorization_code(self, client, auth_url, scope, state, challenge): handler = OAuthHttpSingleRequestHandler("Databricks Sql Connector") + # Bound the read of the accepted connection too (not just the accept + # wait below). StreamRequestHandler.setup() applies this to the accepted + # socket via settimeout(), so a client that connects but never completes + # the request line can no longer block indefinitely. + handler.timeout = self._redirect_callback_timeout_seconds last_error = None callback_timed_out = False @@ -162,12 +167,13 @@ def __get_authorization_code(self, client, auth_url, scope, state, challenge): # flow) fails with a clear error instead of hanging forever. # NOTE: httpd.timeout bounds only the wait to ACCEPT an # incoming connection (it is the select() timeout used by - # handle_request). It does not bound the subsequent read of - # the HTTP request by the handler, so a client that connects - # but never completes the request line could still block. On - # this short-lived loopback server that residual gap is - # acceptable; the headless "no connection ever arrives" case - # (issue #458) is fully covered. + # handle_request). The subsequent read of the HTTP request + # line is bounded separately by the handler.timeout set + # above (applied to the accepted socket by + # StreamRequestHandler.setup()), so a client that connects + # but never completes the request can no longer block. The + # headless "no connection ever arrives" case (issue #458) is + # covered by this accept-wait timeout. httpd.timeout = self._redirect_callback_timeout_seconds # HTTPServer.handle_request() returns normally (via From ac27f936ee5f8cac3cf909284f9fe71d36a5ed69 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 06:54:27 +0000 Subject: [PATCH 09/17] ai: apply changes for #876 (1 review thread) Addresses: - #3636101139 at tests/unit/test_auth.py:210 Signed-off-by: peco-engineer-bot[bot] --- tests/unit/test_auth.py | 88 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index afcea79cc..95f32fd54 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -277,6 +277,94 @@ def run(): str(oauth_manager._redirect_callback_timeout_seconds), error_message ) + @patch("databricks.sql.auth.oauth.webbrowser.open_new") + @patch.object(OAuthManager, "_OAuthManager__fetch_well_known_config") + def test_get_tokens_does_not_hang_when_callback_connects_but_never_completes( + self, mock_fetch_config, mock_open_new + ): + """A client that connects to the local redirect server but never sends a + complete HTTP request line must not block forever either. This is the + separate defense the PR adds via ``handler.timeout``: setup() applies it + to the accepted socket, so the stalled request-line read raises + socket.timeout, which socketserver swallows via handle_error. Because no + callback path is ever recorded (request_path stays None) and this is not + the accept-wait timeout (callback_timed_out stays False), the flow raises + the generic "No path parameters" error rather than the "Timed out" one. + See oauth.py __get_authorization_code.""" + import socket + import threading + from urllib.parse import urlparse, parse_qs + + 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", + } + + # Discover a currently-free port and target it explicitly. Unlike the + # accept-wait test above, this one needs the connecting client to know + # where to reach the redirect server; port_range=[0] would leave the + # redirect URL pointing at port 0. + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.bind(("localhost", 0)) + free_port = probe.getsockname()[1] + probe.close() + + stalled_sockets = [] + + def connect_but_stall(auth_req_uri): + # Instead of launching a browser, open a bare TCP connection to the + # local redirect server (parsed out of the authorization request's + # redirect_uri) and never send a request line, so the accepted + # socket's read stalls until handler.timeout fires. + redirect_uri = parse_qs(urlparse(auth_req_uri).query)["redirect_uri"][0] + port = urlparse(redirect_uri).port + stalled_sockets.append(socket.create_connection(("localhost", port))) + return True + + mock_open_new.side_effect = connect_but_stall + + oauth_manager = OAuthManager( + port_range=[free_port], + client_id="mock-id", + idp_endpoint=InHouseOAuthEndpointCollection(), + http_client=MagicMock(), + redirect_callback_timeout_seconds=2, + ) + + 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 + result["outcome"] = "raised" + result["error"] = e + + worker = threading.Thread(target=run, daemon=True) + worker.start() + worker.join(timeout=30) + + try: + self.assertFalse( + worker.is_alive(), + "OAuth callback server blocked indefinitely on a client that " + "connected but never completed the request line", + ) + self.assertEqual(result.get("outcome"), "raised") + self.assertIsInstance(result.get("error"), RuntimeError) + # A stalled request-line read is NOT the accept-wait timeout: + # request_path stays None and callback_timed_out stays False, so the + # generic message is raised, not the "Timed out" one. + error_message = str(result.get("error")) + self.assertIn("No path parameters", error_message) + self.assertNotIn("Timed out", error_message) + finally: + for sock in stalled_sockets: + sock.close() + class TestClientCredentialsTokenSource: @pytest.fixture From 2d6f72939645abc9a2536907ae12a50096621202 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 07:00:27 +0000 Subject: [PATCH 10/17] ai: apply changes for #876 (1 review thread) Addresses: - #3636143626 at tests/unit/test_auth.py:300 Signed-off-by: peco-engineer-bot[bot] --- tests/unit/test_auth.py | 75 +++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 95f32fd54..7acca427a 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -300,15 +300,6 @@ def test_get_tokens_does_not_hang_when_callback_connects_but_never_completes( "token_endpoint": "https://foo.cloud.databricks.com/oidc/oauth2/v2.0/token", } - # Discover a currently-free port and target it explicitly. Unlike the - # accept-wait test above, this one needs the connecting client to know - # where to reach the redirect server; port_range=[0] would leave the - # redirect URL pointing at port 0. - probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - probe.bind(("localhost", 0)) - free_port = probe.getsockname()[1] - probe.close() - stalled_sockets = [] def connect_but_stall(auth_req_uri): @@ -323,31 +314,57 @@ def connect_but_stall(auth_req_uri): mock_open_new.side_effect = connect_but_stall - oauth_manager = OAuthManager( - port_range=[free_port], - client_id="mock-id", - idp_endpoint=InHouseOAuthEndpointCollection(), - http_client=MagicMock(), - redirect_callback_timeout_seconds=2, - ) + def run_flow(port): + # Target a specific free port so the connecting client above knows + # where to reach the redirect server; unlike the accept-wait test, + # port_range=[0] can't be used here because the source builds the + # redirect URL from the requested port, so it would point the client + # at localhost:0. + oauth_manager = OAuthManager( + port_range=[port], + client_id="mock-id", + idp_endpoint=InHouseOAuthEndpointCollection(), + http_client=MagicMock(), + redirect_callback_timeout_seconds=2, + ) - result = {} + 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 - result["outcome"] = "raised" - result["error"] = e + 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 + result["outcome"] = "raised" + result["error"] = e - worker = threading.Thread(target=run, daemon=True) - worker.start() - worker.join(timeout=30) + worker = threading.Thread(target=run, daemon=True) + worker.start() + worker.join(timeout=30) + return worker, result try: + # Pre-selecting a fixed port has a small TOCTOU window: between + # closing the probe socket and the HTTPServer bind inside + # __get_authorization_code, another process can claim the port. That + # would take the can't-bind branch instead of the stalled-request- + # line path this test exercises (and the source's errno==48 check is + # macOS-only, so on Linux the bind failure surfaces as a TypeError + # rather than the RuntimeError asserted below). Re-probe a fresh port + # and retry on that rare race so the test stays deterministic in CI. + worker, result = None, {} + for _ in range(5): + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.bind(("localhost", 0)) + free_port = probe.getsockname()[1] + probe.close() + + worker, result = run_flow(free_port) + if isinstance(result.get("error"), RuntimeError): + break + self.assertFalse( worker.is_alive(), "OAuth callback server blocked indefinitely on a client that " From eb1af563a9b80bd636f027e2d1edc9a5f3a44d36 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 07:06:13 +0000 Subject: [PATCH 11/17] ai: apply changes for #876 (2 review threads) Addresses: - #3636172662 at src/databricks/sql/auth/oauth.py:173 - #3636172666 at src/databricks/sql/auth/oauth.py:187 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/authenticators.py | 7 ++++++- src/databricks/sql/auth/oauth.py | 7 ++++++- tests/unit/test_auth.py | 7 +++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/databricks/sql/auth/authenticators.py b/src/databricks/sql/auth/authenticators.py index c30a287ee..4117f2e73 100644 --- a/src/databricks/sql/auth/authenticators.py +++ b/src/databricks/sql/auth/authenticators.py @@ -1,6 +1,6 @@ import abc import logging -from typing import Callable, Dict, List +from typing import Callable, Dict, List, Optional from databricks.sql.common.http import HttpHeader from databricks.sql.auth.oauth import ( OAuthManager, @@ -63,6 +63,7 @@ def __init__( scopes: List[str], http_client, auth_type: str = "databricks-oauth", + redirect_callback_timeout_seconds: Optional[int] = None, ): try: idp_endpoint = get_oauth_endpoints(hostname, auth_type == "azure-oauth") @@ -74,11 +75,15 @@ def __init__( # Convert to the corresponding scopes in the corresponding IdP cloud_scopes = idp_endpoint.get_scopes_mapping(scopes) + # Pass through the (optional) callback-timeout override so this + # private provider can surface it later if a public connection + # knob is added; None falls back to OAuthManager's default. self.oauth_manager = OAuthManager( port_range=redirect_port_range, client_id=client_id, idp_endpoint=idp_endpoint, http_client=http_client, + redirect_callback_timeout_seconds=redirect_callback_timeout_seconds, ) self._hostname = hostname self._scopes_as_str = DatabricksOAuthProvider.SCOPE_DELIM.join(cloud_scopes) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index a8633c76d..78464acfe 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -1,4 +1,5 @@ import base64 +import errno import hashlib import json import logging @@ -204,7 +205,11 @@ def _on_timeout(): self.redirect_port = port break except OSError as e: - if e.errno == 48: + # errno.EADDRINUSE resolves to the platform's value (48 on + # macOS, 98 on Linux), so a port-in-use bind failure is + # recognized cross-platform. Otherwise last_error stays None on + # Linux and `raise last_error` below becomes `raise None`. + if e.errno == errno.EADDRINUSE: logger.info(f"Port {port} is in use") last_error = e except Exception as e: diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 7acca427a..67a614995 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -350,10 +350,9 @@ def run(): # closing the probe socket and the HTTPServer bind inside # __get_authorization_code, another process can claim the port. That # would take the can't-bind branch instead of the stalled-request- - # line path this test exercises (and the source's errno==48 check is - # macOS-only, so on Linux the bind failure surfaces as a TypeError - # rather than the RuntimeError asserted below). Re-probe a fresh port - # and retry on that rare race so the test stays deterministic in CI. + # line path this test exercises, surfacing a bind error rather than + # the RuntimeError asserted below. Re-probe a fresh port and retry + # on that rare race so the test stays deterministic in CI. worker, result = None, {} for _ in range(5): probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) From bf48daeb88289f8381246a59cf3bc0dc083db4bd Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 07:14:35 +0000 Subject: [PATCH 12/17] 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] --- src/databricks/sql/auth/authenticators.py | 7 +-- src/databricks/sql/auth/oauth.py | 19 ++++++++ tests/unit/test_auth.py | 57 +++++++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/databricks/sql/auth/authenticators.py b/src/databricks/sql/auth/authenticators.py index 4117f2e73..c30a287ee 100644 --- a/src/databricks/sql/auth/authenticators.py +++ b/src/databricks/sql/auth/authenticators.py @@ -1,6 +1,6 @@ import abc import logging -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List from databricks.sql.common.http import HttpHeader from databricks.sql.auth.oauth import ( OAuthManager, @@ -63,7 +63,6 @@ def __init__( scopes: List[str], http_client, auth_type: str = "databricks-oauth", - redirect_callback_timeout_seconds: Optional[int] = None, ): try: idp_endpoint = get_oauth_endpoints(hostname, auth_type == "azure-oauth") @@ -75,15 +74,11 @@ def __init__( # Convert to the corresponding scopes in the corresponding IdP cloud_scopes = idp_endpoint.get_scopes_mapping(scopes) - # Pass through the (optional) callback-timeout override so this - # private provider can surface it later if a public connection - # knob is added; None falls back to OAuthManager's default. self.oauth_manager = OAuthManager( port_range=redirect_port_range, client_id=client_id, idp_endpoint=idp_endpoint, http_client=http_client, - redirect_callback_timeout_seconds=redirect_callback_timeout_seconds, ) self._hostname = hostname self._scopes_as_str = DatabricksOAuthProvider.SCOPE_DELIM.join(cloud_scopes) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 78464acfe..dd7f8e793 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -212,6 +212,25 @@ def _on_timeout(): if e.errno == errno.EADDRINUSE: logger.info(f"Port {port} is in use") last_error = e + except webbrowser.Error as e: + # No browser could be launched at all (webbrowser.get() found no + # runnable browser). This is a strong, reliable headless signal + # (e.g. a notebook/job with no display), so fail fast with a + # clear error instead of waiting out the full redirect-callback + # timeout. We do NOT fail fast merely on a falsy open_new return + # value: that is unreliable across platforms (it can be falsy + # even when a browser did open) and would break working + # interactive logins. See issue #458. Placed before the generic + # ``except Exception`` so this RuntimeError propagates instead of + # being swallowed and retried against the remaining ports. + msg = ( + "Could not launch a web browser to complete the U2M OAuth " + f"login flow ({e}). This looks like a headless environment " + "(e.g. a notebook or job with no browser), where browser-" + "based OAuth cannot complete. See issue #458." + ) + logger.error(msg) + raise RuntimeError(msg) from e except Exception as e: logger.error("unexpected error: %s", e) if self.redirect_port is None: diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 67a614995..3d55359a3 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -381,6 +381,63 @@ def run(): for sock in stalled_sockets: sock.close() + @patch("databricks.sql.auth.oauth.webbrowser.open_new") + @patch.object(OAuthManager, "_OAuthManager__fetch_well_known_config") + def test_get_tokens_fails_fast_when_no_browser_available( + self, mock_fetch_config, mock_open_new + ): + """When no browser can be launched at all (webbrowser.open_new raises + webbrowser.Error), the flow must fail fast with a clear headless error + rather than waiting out the full redirect-callback timeout. See issue + #458.""" + import webbrowser + + 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", + } + mock_open_new.side_effect = webbrowser.Error("no browser available") + + # A large timeout: if the fail-fast path regressed, joining below would + # hit the wall-clock bound and the test would fail loudly instead of + # silently waiting minutes. + oauth_manager = OAuthManager( + port_range=[0], + client_id="mock-id", + idp_endpoint=InHouseOAuthEndpointCollection(), + http_client=MagicMock(), + redirect_callback_timeout_seconds=600, + ) + + 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 + 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 flow did not fail fast when no browser was available", + ) + self.assertEqual(result.get("outcome"), "raised") + self.assertIsInstance(result.get("error"), RuntimeError) + error_message = str(result.get("error")) + self.assertIn("browser", error_message.lower()) + # The fail-fast path must not surface the accept-wait timeout message. + self.assertNotIn("Timed out", error_message) + class TestClientCredentialsTokenSource: @pytest.fixture From ead14dd90252e18a72ee783092afa3c6fb64c33a Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 07:29:23 +0000 Subject: [PATCH 13/17] ai: apply changes for #876 (2 review threads) Addresses: - #3636302124 at src/databricks/sql/auth/oauth.py:208 - #3636302135 at tests/unit/test_auth.py:240 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 8 +++++++- tests/unit/test_auth.py | 10 ++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index dd7f8e793..a01e69d7a 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -220,7 +220,13 @@ def _on_timeout(): # timeout. We do NOT fail fast merely on a falsy open_new return # value: that is unreliable across platforms (it can be falsy # even when a browser did open) and would break working - # interactive logins. See issue #458. Placed before the generic + # interactive logins. Note this fast-fail only covers the case + # where webbrowser.get() raises; the more common headless case + # (no browser registered, so open_new() returns False without + # raising) is NOT caught here and instead falls through to the + # bounded redirect-callback timeout below, which is the real + # safeguard against blocking forever. See issue #458. Placed + # before the generic # ``except Exception`` so this RuntimeError propagates instead of # being swallowed and retried against the remaining ports. msg = ( diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 3d55359a3..927d9d150 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -237,7 +237,10 @@ def test_get_tokens_does_not_hang_when_no_callback_received( client_id="mock-id", idp_endpoint=InHouseOAuthEndpointCollection(), http_client=MagicMock(), - redirect_callback_timeout_seconds=2, + # Sub-second so this real-wallclock wait stays negligible in the + # fast/mocked unit suite. The wait IS the timeout, so there is no + # setup racing against it that a shorter bound could make flaky. + redirect_callback_timeout_seconds=0.5, ) # No callback is ever delivered to the redirect server. Run the flow in @@ -325,7 +328,10 @@ def run_flow(port): client_id="mock-id", idp_endpoint=InHouseOAuthEndpointCollection(), http_client=MagicMock(), - redirect_callback_timeout_seconds=2, + # Sub-second: bounds the stalled request-line read. Keeps the + # per-attempt wait negligible so even the rare TOCTOU retry loop + # stays fast in the mocked unit suite. + redirect_callback_timeout_seconds=0.5, ) result = {} From 91e698e8bed67277f15863de4d906dfd1bad5897 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 07:42:33 +0000 Subject: [PATCH 14/17] ai: apply changes for #876 (1 review thread) Addresses: - #3636374430 at src/databricks/sql/auth/oauth.py:215 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index a01e69d7a..2da9ad9b4 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -205,13 +205,22 @@ def _on_timeout(): self.redirect_port = port break except OSError as e: + # Record every bind-time OSError so the fall-through + # `raise last_error` below always re-raises a real exception. + # If we only recorded the port-in-use case, an unexpected error + # on every port (e.g. EACCES, EADDRNOTAVAIL) would leave + # last_error == None and turn `raise last_error` into + # `raise None`, masking the real failure with a confusing + # TypeError. + last_error = e # errno.EADDRINUSE resolves to the platform's value (48 on # macOS, 98 on Linux), so a port-in-use bind failure is - # recognized cross-platform. Otherwise last_error stays None on - # Linux and `raise last_error` below becomes `raise None`. + # recognized cross-platform and downgraded to an info log while + # we try the next port. if e.errno == errno.EADDRINUSE: logger.info(f"Port {port} is in use") - last_error = e + else: + logger.warning(f"Unexpected error binding port {port}: {e}") except webbrowser.Error as e: # No browser could be launched at all (webbrowser.get() found no # runnable browser). This is a strong, reliable headless signal From fbdb5dc1f90b2db9b651d5ad62f04975b1f33c3b Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 08:06:22 +0000 Subject: [PATCH 15/17] ai: apply changes for #876 (1 review thread) Addresses: - #3636506949 at src/databricks/sql/auth/oauth.py:246 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 2da9ad9b4..556e674f2 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -247,6 +247,13 @@ def _on_timeout(): logger.error(msg) raise RuntimeError(msg) from e except Exception as e: + # Record here too (like the OSError branch above) so the + # fall-through `raise last_error` always re-raises a real + # exception. If a non-OSError/non-webbrowser.Error is raised on + # every port, last_error would otherwise stay None and turn + # `raise last_error` into `raise None`, masking the real failure + # with a confusing TypeError. + last_error = e logger.error("unexpected error: %s", e) if self.redirect_port is None: logger.error( From c10bee742f30dc153741577d8a80122e2fb6b48d Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 08:32:14 +0000 Subject: [PATCH 16/17] ai: apply changes for #876 (1 review thread) Addresses: - #3636633446 at src/databricks/sql/auth/oauth.py:85 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 556e674f2..545bd07e2 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -73,7 +73,9 @@ class OAuthManager: # ``redirect_callback_timeout_seconds`` argument below is an internal-only # override for callers constructing ``OAuthManager`` directly (private API); # it is not plumbed through ``DatabricksOAuthProvider`` or the public - # connection kwargs. + # connection kwargs. It accepts a ``float`` so callers (and tests) may pass + # fractional-second timeouts; this class default is a whole number of + # seconds. REDIRECT_CALLBACK_TIMEOUT_SECONDS = 60 * 5 def __init__( @@ -82,7 +84,7 @@ def __init__( client_id: str, idp_endpoint: OAuthEndpointCollection, http_client, - redirect_callback_timeout_seconds: Optional[int] = None, + redirect_callback_timeout_seconds: Optional[float] = None, ): self.port_range = port_range self.client_id = client_id From dcba7984ad53b937909468a07d7b2eb35708c26a Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Thu, 23 Jul 2026 09:14:12 +0000 Subject: [PATCH 17/17] ai: apply changes for #876 (1 review thread) Addresses: - #3636895829 at src/databricks/sql/auth/oauth.py:247 Signed-off-by: peco-engineer-bot[bot] --- src/databricks/sql/auth/oauth.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/databricks/sql/auth/oauth.py b/src/databricks/sql/auth/oauth.py index 545bd07e2..3fa82650b 100644 --- a/src/databricks/sql/auth/oauth.py +++ b/src/databricks/sql/auth/oauth.py @@ -249,14 +249,19 @@ def _on_timeout(): logger.error(msg) raise RuntimeError(msg) from e except Exception as e: - # Record here too (like the OSError branch above) so the - # fall-through `raise last_error` always re-raises a real - # exception. If a non-OSError/non-webbrowser.Error is raised on - # every port, last_error would otherwise stay None and turn - # `raise last_error` into `raise None`, masking the real failure - # with a confusing TypeError. - last_error = e + # A non-OSError/non-webbrowser.Error raised here is not + # port-related (e.g. an OAuth2Error from + # prepare_authorization_request, or an unexpected programming + # bug). Binding the same call to a different local port cannot + # help, so retrying it against every remaining port would only + # multiply latency and log noise and then re-raise the *last* + # attempt's error, obscuring the original failure. Fail fast + # instead, preserving the original exception and traceback via a + # bare `raise`. This also sidesteps the `raise None` hazard that + # the OSError branch guards against, since we never fall through + # to `raise last_error` for this case. logger.error("unexpected error: %s", e) + raise if self.redirect_port is None: logger.error( f"Tried all the ports {self.port_range} for oauth redirect, but can't find free port"