Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions crates/sqltide-python/src/cli/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
35 changes: 34 additions & 1 deletion crates/sqltide-python/src/clickhouse_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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))
{
Expand All @@ -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<bool> {
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)> {
Expand Down
4 changes: 4 additions & 0 deletions src/sqltide/adapter/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
15 changes: 15 additions & 0 deletions tests/unit/src/sqltide/adapters/clickhouse/helpers.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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
63 changes: 63 additions & 0 deletions tests/unit/src/sqltide/adapters/clickhouse/test_errors.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import socket

import pytest
from clickhouse_connect.driver.exceptions import DatabaseError, OperationalError

Expand All @@ -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)"
Expand All @@ -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(
Expand Down Expand Up @@ -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,
)
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/src/sqltide/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
34 changes: 33 additions & 1 deletion tests/unit/src/sqltide/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,6 +55,7 @@
FakeCliClickHouseClient,
handlers_with_overrides,
normalize_json_output,
refused_driver_error,
write_cli_compilation_project,
write_cli_target_project,
)
Expand Down Expand Up @@ -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,
)
Expand All @@ -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())

Expand Down
Loading