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())