From a34ba3b28bb2ba3b76b35ee728a995a6829d7453 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:43:12 +0000 Subject: [PATCH] fix: report an unreachable warehouse without a traceback A refused connection or an unresolvable warehouse host now classifies as AdapterUnavailableError, and the CLI prints which command could not connect and what to check, exiting with status 1, instead of an unhandled AdapterWarehouseError traceback. --- crates/sqltide-python/src/cli/render.rs | 10 +++ .../sqltide-python/src/clickhouse_adapter.rs | 35 ++++++++++- src/sqltide/adapter/exceptions.py | 4 ++ .../sqltide/adapters/clickhouse/helpers.py | 15 +++++ .../adapters/clickhouse/test_errors.py | 63 +++++++++++++++++++ tests/unit/src/sqltide/cli/helpers.py | 12 ++++ tests/unit/src/sqltide/cli/test_main.py | 34 +++++++++- 7 files changed, 171 insertions(+), 2 deletions(-) diff --git a/crates/sqltide-python/src/cli/render.rs b/crates/sqltide-python/src/cli/render.rs index 3343ed97..9452d828 100644 --- a/crates/sqltide-python/src/cli/render.rs +++ b/crates/sqltide-python/src/cli/render.rs @@ -359,6 +359,16 @@ pub(crate) fn render_expected_warehouse_error( String::new(), "Retry the command after checking warehouse availability.".to_owned(), ] + } else if is("AdapterUnavailableError")? { + vec![ + format!("{command} could not connect to ClickHouse"), + format!("Database: {database}"), + "Reason: the connection was refused or the host could not be resolved.".to_owned(), + String::new(), + "Check:".to_owned(), + "- that ClickHouse is running and reachable from this machine".to_owned(), + "- the host and port in your connection settings".to_owned(), + ] } else if is("AdapterAuthenticationError")? { vec![ format!("{command} could not start"), diff --git a/crates/sqltide-python/src/clickhouse_adapter.rs b/crates/sqltide-python/src/clickhouse_adapter.rs index 30626949..e3418ff1 100644 --- a/crates/sqltide-python/src/clickhouse_adapter.rs +++ b/crates/sqltide-python/src/clickhouse_adapter.rs @@ -15,6 +15,8 @@ const ADAPTER_EXCEPTIONS: &str = "sqltide.adapter.exceptions"; const SECRET_PLACEHOLDER_PREFIX: &str = "__sqltide_secret__"; const SECRET_PLACEHOLDER_SUFFIX: &str = "__"; const TIMEOUT_ERROR_MARKERS: [&str; 2] = ["timeout", "timed out"]; +/// How many causes or contexts of a driver error are searched for a connection failure. +const MAX_CAUSE_DEPTH: usize = 16; const DEAD_LETTERS_TABLE: &str = "_sqltide_dead_letters"; const MATERIALIZED_VIEW_NAME_PREFIX: &str = "mv__"; const DEAD_LETTERS_VIEW_SUFFIX: &str = "__dead_letters"; @@ -48,7 +50,11 @@ fn translate_clickhouse_driver_error<'py>(error: &Bound<'py, PyAny>) -> PyResult .to_string_lossy() .into_owned(); let text: String = message.to_string_lossy().into_owned(); - let class: &str = if TIMEOUT_ERROR_MARKERS + // A refused or unresolved connection never timed out, so it is checked before the timeout + // markers, which the request URL in the message can contain as a setting name. + let class: &str = if caused_by_unreachable_host(error)? { + "AdapterUnavailableError" + } else if TIMEOUT_ERROR_MARKERS .iter() .any(|marker| normalized.contains(marker)) { @@ -67,6 +73,33 @@ fn translate_clickhouse_driver_error<'py>(error: &Bound<'py, PyAny>) -> PyResult .call1((message,)) } +/// Whether the driver error was caused by a refused connection or an unresolvable host, as the +/// HTTP client chains them under the driver's own error. Like a traceback, it follows +/// `__context__` only when `__cause__` is unset and the context is not suppressed. +fn caused_by_unreachable_host(error: &Bound<'_, PyAny>) -> PyResult { + let py: Python<'_> = error.py(); + let refused: Value<'_> = py.import("builtins")?.getattr("ConnectionRefusedError")?; + let unresolved: Value<'_> = py.import("socket")?.getattr("gaierror")?; + let mut current: Value<'_> = error.clone(); + for _ in 0..MAX_CAUSE_DEPTH { + if current.is_instance(&refused)? || current.is_instance(&unresolved)? { + return Ok(true); + } + let cause: Value<'_> = current.getattr("__cause__")?; + current = if !cause.is_none() { + cause + } else if current.getattr("__suppress_context__")?.is_truthy()? { + return Ok(false); + } else { + current.getattr("__context__")? + }; + if current.is_none() { + return Ok(false); + } + } + Ok(false) +} + /// The leftmost `__sqltide_secret__NAME__` placeholder as `(placeholder, NAME)`, matching /// `re.escape(prefix) + r"([A-Za-z_][A-Za-z0-9_]*)" + re.escape(suffix)` with its greedy name. fn secret_placeholder(text: &str) -> Option<(&str, &str)> { diff --git a/src/sqltide/adapter/exceptions.py b/src/sqltide/adapter/exceptions.py index 1a34185c..8283cac2 100644 --- a/src/sqltide/adapter/exceptions.py +++ b/src/sqltide/adapter/exceptions.py @@ -33,6 +33,10 @@ class AdapterWarehouseError(AdapterError): """Raised when the warehouse itself rejects a connection or statement.""" +class AdapterUnavailableError(AdapterWarehouseError): + """Raised when the warehouse refuses the connection or its host cannot be resolved.""" + + class AdapterTimeoutError(AdapterWarehouseError): """Raised when a warehouse operation exceeds its available time.""" diff --git a/tests/unit/src/sqltide/adapters/clickhouse/helpers.py b/tests/unit/src/sqltide/adapters/clickhouse/helpers.py index 8be174f5..5bc9bb44 100644 --- a/tests/unit/src/sqltide/adapters/clickhouse/helpers.py +++ b/tests/unit/src/sqltide/adapters/clickhouse/helpers.py @@ -1,6 +1,8 @@ from collections.abc import Iterator, Mapping, Sequence from typing import cast +from clickhouse_connect.driver.exceptions import OperationalError + from sqltide._native import sql_canonicalize from sqltide.adapter.exceptions import AdapterWarehouseError from sqltide.adapter.models import ( @@ -337,3 +339,16 @@ def build_metadata_state() -> MetadataState: def normalize_clickhouse_sql(sql: str) -> str: return sql_canonicalize(sql, "clickhouse") + + +def connection_failure( + message: str, *, cause: BaseException, link: str, suppress_context: bool = False +) -> OperationalError: + """A driver error whose `link` (`__cause__` or `__context__`) leads to `cause` one level down, + as the HTTP client chains them.""" + transport_error: OSError = OSError("Failed to establish a new connection") + transport_error.__cause__ = cause + driver_error: OperationalError = OperationalError(message) + setattr(driver_error, link, transport_error) + driver_error.__suppress_context__ = suppress_context + return driver_error diff --git a/tests/unit/src/sqltide/adapters/clickhouse/test_errors.py b/tests/unit/src/sqltide/adapters/clickhouse/test_errors.py index 2bbd0edd..1bba2136 100644 --- a/tests/unit/src/sqltide/adapters/clickhouse/test_errors.py +++ b/tests/unit/src/sqltide/adapters/clickhouse/test_errors.py @@ -1,3 +1,5 @@ +import socket + import pytest from clickhouse_connect.driver.exceptions import DatabaseError, OperationalError @@ -7,11 +9,13 @@ AdapterDatabaseNotFoundError, AdapterRelationNotFoundError, AdapterTimeoutError, + AdapterUnavailableError, AdapterWarehouseError, ) from tests.unit.src.sqltide.adapters.clickhouse._test_types import ( DriverErrorTranslationTestCase, ) +from tests.unit.src.sqltide.adapters.clickhouse.helpers import connection_failure _AUTHENTICATION_FAILED_MESSAGE: str = ( "Code: 516. DB::Exception: Authentication failed. (AUTHENTICATION_FAILED)" @@ -25,6 +29,14 @@ _TIMEOUT_MESSAGE: str = ( "Error ('Connection aborted.', TimeoutError('timed out')) executing HTTP request attempt 1" ) +_CONNECTION_FAILED_MESSAGE: str = ( + "Error HTTPConnectionPool(host='warehouse.example.com', port=8123): Max retries exceeded " + "executing HTTP request attempt 1 (http://warehouse.example.com:8123)" +) +_REFUSED_WITH_TIMEOUT_SETTING_MESSAGE: str = ( + "Error HTTPConnectionPool(host='warehouse.example.com', port=8123): Max retries exceeded " + "with url: /?receive_timeout=30&database=orders executing HTTP request attempt 1" +) @pytest.mark.parametrize( @@ -54,6 +66,57 @@ expected_error_type=AdapterTimeoutError, expected_message=_TIMEOUT_MESSAGE, ), + DriverErrorTranslationTestCase( + description="translates a refused connection into an unavailable warehouse error", + driver_error=connection_failure( + _CONNECTION_FAILED_MESSAGE, + cause=ConnectionRefusedError(111, "Connection refused"), + link="__cause__", + ), + expected_error_type=AdapterUnavailableError, + expected_message=_CONNECTION_FAILED_MESSAGE, + ), + DriverErrorTranslationTestCase( + description="translates an unresolvable host found through the exception context", + driver_error=connection_failure( + _CONNECTION_FAILED_MESSAGE, + cause=socket.gaierror(-2, "Name or service not known"), + link="__context__", + ), + expected_error_type=AdapterUnavailableError, + expected_message=_CONNECTION_FAILED_MESSAGE, + ), + DriverErrorTranslationTestCase( + description="treats a refusal as unavailable even when its URL names a timeout setting", + driver_error=connection_failure( + _REFUSED_WITH_TIMEOUT_SETTING_MESSAGE, + cause=ConnectionRefusedError(111, "Connection refused"), + link="__cause__", + ), + expected_error_type=AdapterUnavailableError, + expected_message=_REFUSED_WITH_TIMEOUT_SETTING_MESSAGE, + ), + DriverErrorTranslationTestCase( + description="ignores a refusal in a context suppressed with raise from None", + driver_error=connection_failure( + _CONNECTION_FAILED_MESSAGE, + cause=ConnectionRefusedError(111, "Connection refused"), + link="__context__", + suppress_context=True, + ), + expected_error_type=AdapterWarehouseError, + expected_message=_CONNECTION_FAILED_MESSAGE, + ), + DriverErrorTranslationTestCase( + description="keeps other transport failures as generic warehouse errors", + driver_error=connection_failure( + _CONNECTION_FAILED_MESSAGE, + cause=ConnectionResetError(104, "Connection reset by peer"), + link="__cause__", + ), + expected_error_type=AdapterWarehouseError, + expected_message=_CONNECTION_FAILED_MESSAGE, + ), ], ids=lambda case: case.description, ) diff --git a/tests/unit/src/sqltide/cli/helpers.py b/tests/unit/src/sqltide/cli/helpers.py index f3d59a01..0e35b695 100644 --- a/tests/unit/src/sqltide/cli/helpers.py +++ b/tests/unit/src/sqltide/cli/helpers.py @@ -4,6 +4,8 @@ from pathlib import Path from textwrap import dedent +from clickhouse_connect.driver.exceptions import OperationalError + from sqltide._native import ( render_clickhouse_replay_coverage_query, render_clickhouse_replay_from_capture, @@ -456,3 +458,13 @@ def passthrough_output(output: str) -> str: True: normalize_json_output, False: passthrough_output, } + + +def refused_driver_error() -> OperationalError: + """The driver error for a refused connection, chained as the HTTP client raises it.""" + error: OperationalError = OperationalError( + "Error HTTPConnectionPool(host='localhost', port=8123): Max retries exceeded " + "executing HTTP request attempt 1 (http://localhost:8123)" + ) + error.__cause__ = ConnectionRefusedError(111, "Connection refused") + return error diff --git a/tests/unit/src/sqltide/cli/test_main.py b/tests/unit/src/sqltide/cli/test_main.py index f916becd..73aad97f 100644 --- a/tests/unit/src/sqltide/cli/test_main.py +++ b/tests/unit/src/sqltide/cli/test_main.py @@ -9,7 +9,12 @@ import pytest -from sqltide._native import parse_cli_arguments, run_cli, run_dev +from sqltide._native import ( + parse_cli_arguments, + run_cli, + run_dev, + translate_clickhouse_driver_error, +) from sqltide.adapter.classes.adapter_connection import AdapterConnection from sqltide.adapter.exceptions import AdapterAuthenticationError, AdapterTimeoutError from sqltide.adapter.models import AdapterConnectionConfig @@ -50,6 +55,7 @@ FakeCliClickHouseClient, handlers_with_overrides, normalize_json_output, + refused_driver_error, write_cli_compilation_project, write_cli_target_project, ) @@ -1281,6 +1287,31 @@ def test_given_apply_flag_when_running_janitor_then_it_passes_apply_to_command( "ClickHouse rejected the supplied credentials", ), ), + CliMainErrorTestCase( + description="renders a refused clickhouse connection without a traceback", + argv=( + "sqt", + "plan", + "--project-dir", + "tests/fixtures/basic_project", + "--host", + "localhost", + "--port", + "8123", + "--username", + "sqltide", + "--password", + "sqltide", + "--database", + "analytics", + ), + expected_exit_code=1, + expected_error_fragments=( + "Plan could not connect to ClickHouse", + "Database: analytics", + "the connection was refused or the host could not be resolved", + ), + ), ], ids=lambda case: case.description, ) @@ -1297,6 +1328,7 @@ def test_given_expected_command_errors_when_running_entrypoint_then_it_prints_cl "Code: 516. DB::Exception: Authentication failed. (AUTHENTICATION_FAILED)" ) ), + run_plan=FailingCommandRunner(translate_clickhouse_driver_error(refused_driver_error())), ) clickhouse_client: AdapterConnection = cast(AdapterConnection, FakeCliClickHouseClient())