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
25 changes: 23 additions & 2 deletions crates/sqltide-python/src/cli/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,31 @@ pub(crate) fn render_expected_warehouse_error(
"Retry the command after checking warehouse availability.".to_owned(),
]
} else if is("AdapterUnavailableError")? {
let detail = |name: &str| -> PyResult<Option<String>> {
let value: Bound<'_, PyAny> = error
.getattr(name)
.unwrap_or_else(|_| py.None().into_bound(py));
if value.is_none() {
Ok(None)
} else {
Ok(Some(value.str()?.to_string()))
}
};
let through_proxy: bool = error
.getattr("through_proxy")
.map_or(Ok(false), |value| value.is_truthy())?;
let endpoint: String = match (detail("endpoint")?, through_proxy) {
(Some(endpoint), true) => format!(" through proxy {endpoint}"),
(Some(endpoint), false) => format!(" at {endpoint}"),
(None, true) => " through a proxy".to_owned(),
(None, false) => String::new(),
};
let reason: String = detail("reason")?.unwrap_or_else(|| {
"the connection was refused or the host could not be resolved".to_owned()
});
vec![
format!("{command} could not connect to ClickHouse"),
format!("{command} could not connect to ClickHouse{endpoint}: {reason}"),
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(),
Expand Down
138 changes: 125 additions & 13 deletions crates/sqltide-python/src/clickhouse_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ 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;
/// The line clickhouse_connect logs at WARNING before raising any HTTP transport failure.
const DRIVER_HTTP_LOGGER: &str = "clickhouse_connect.driver.httpclient";
const DRIVER_HTTP_FAILURE_LOG: &str = "Unexpected Http Driver Exception";
const REFUSED_REASON: &str = "connection refused";
const UNRESOLVED_REASON: &str = "host not found";
const TEMPORARY_DNS_REASON: &str = "temporary DNS failure";
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 @@ -52,7 +58,8 @@ fn translate_clickhouse_driver_error<'py>(error: &Bound<'py, PyAny>) -> PyResult
let text: String = message.to_string_lossy().into_owned();
// 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)? {
let unreachable: Option<Unreachable> = unreachable_host(error)?;
let class: &str = if unreachable.is_some() {
"AdapterUnavailableError"
} else if TIMEOUT_ERROR_MARKERS
.iter()
Expand All @@ -68,36 +75,140 @@ fn translate_clickhouse_driver_error<'py>(error: &Bound<'py, PyAny>) -> PyResult
} else {
"AdapterWarehouseError"
};
py.import(ADAPTER_EXCEPTIONS)?
let translated: Value<'py> = py
.import(ADAPTER_EXCEPTIONS)?
.getattr(class)?
.call1((message,))
.call1((message,))?;
if let Some(unreachable) = unreachable {
// What the driver's own log line used to show, for the operator-facing message.
translated.setattr("endpoint", connection_endpoint(&text))?;
translated.setattr("reason", unreachable.reason)?;
translated.setattr("through_proxy", unreachable.through_proxy)?;
}
Ok(translated)
}

/// Why a connection could not be opened, and whether the unreachable endpoint is a proxy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Unreachable {
reason: &'static str,
/// The failed pool is a configured proxy (urllib3 `ProxyError` in the chain).
through_proxy: bool,
}

/// 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> {
/// The refused connection or unresolvable host that caused the driver error, 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. A `socket.gaierror` with
/// errno `EAI_AGAIN` is a temporary DNS failure; any other is a host that was not found.
fn unreachable_host(error: &Bound<'_, PyAny>) -> PyResult<Option<Unreachable>> {
let py: Python<'_> = error.py();
let refused: Value<'_> = py.import("builtins")?.getattr("ConnectionRefusedError")?;
let unresolved: Value<'_> = py.import("socket")?.getattr("gaierror")?;
let socket: Bound<'_, PyModule> = py.import("socket")?;
let unresolved: Value<'_> = socket.getattr("gaierror")?;
let temporary: Value<'_> = socket.getattr("EAI_AGAIN")?;
let proxy: Value<'_> = py.import("urllib3.exceptions")?.getattr("ProxyError")?;
let mut through_proxy: bool = false;
let mut current: Value<'_> = error.clone();
for _ in 0..MAX_CAUSE_DEPTH {
if current.is_instance(&refused)? || current.is_instance(&unresolved)? {
return Ok(true);
through_proxy |= current.is_instance(&proxy)?;
let reason: Option<&'static str> = if current.is_instance(&refused)? {
Some(REFUSED_REASON)
} else if current.is_instance(&unresolved)? {
Some(if current.getattr("errno")?.eq(&temporary)? {
TEMPORARY_DNS_REASON
} else {
UNRESOLVED_REASON
})
} else {
None
};
if let Some(reason) = reason {
return Ok(Some(Unreachable {
reason,
through_proxy,
}));
}
let cause: Value<'_> = current.getattr("__cause__")?;
current = if !cause.is_none() {
cause
} else if current.getattr("__suppress_context__")?.is_truthy()? {
return Ok(false);
return Ok(None);
} else {
current.getattr("__context__")?
};
if current.is_none() {
return Ok(false);
return Ok(None);
}
}
Ok(None)
}

/// `host:port` of the HTTP connection pool a driver message names, as urllib3 renders it:
/// `HTTPConnectionPool(host='db.example.com', port=8123)`. An IPv6 host is bracketed.
fn connection_endpoint(message: &str) -> Option<String> {
let rest: &str = &message[message.find("host='")? + "host='".len()..];
let (host, rest) = rest.split_once("', port=")?;
let digits: usize = rest.find(|character: char| !character.is_ascii_digit())?;
if host.is_empty() || digits == 0 {
return None;
}
let port: &str = &rest[..digits];
Some(if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
})
}

/// Logging filter on clickhouse_connect's HTTP client logger that demotes its generic
/// transport-failure warning to DEBUG while a refused connection or unresolvable host is being
/// handled, because SQLTide reports that failure itself. The record is dropped unless DEBUG is
/// enabled for the logger; every other record passes unchanged.
#[pyclass(module = "sqltide._native", frozen)]
pub(crate) struct UnreachableHostLogFilter;

#[pymethods]
impl UnreachableHostLogFilter {
#[new]
fn new() -> Self {
Self
}

fn filter(&self, record: &Bound<'_, PyAny>) -> PyResult<bool> {
let py: Python<'_> = record.py();
if !record.getattr("msg")?.eq(DRIVER_HTTP_FAILURE_LOG)? {
return Ok(true);
}
let handled: Value<'_> = py.import("sys")?.call_method0("exc_info")?.get_item(1)?;
if handled.is_none() || unreachable_host(&handled)?.is_none() {
return Ok(true);
}
let logging: Bound<'_, PyModule> = py.import("logging")?;
let debug: Value<'_> = logging.getattr("DEBUG")?;
let enabled: bool = logging
.call_method1("getLogger", (record.getattr("name")?,))?
.call_method1("isEnabledFor", (&debug,))?
.is_truthy()?;
if enabled {
record.setattr("levelno", &debug)?;
record.setattr("levelname", "DEBUG")?;
}
Ok(enabled)
}
}

/// Add [`UnreachableHostLogFilter`] to clickhouse_connect's HTTP client logger once.
pub(crate) fn install_unreachable_host_log_filter(py: Python<'_>) -> PyResult<()> {
let logger: Value<'_> = py
.import("logging")?
.call_method1("getLogger", (DRIVER_HTTP_LOGGER,))?;
for existing in logger.getattr("filters")?.try_iter()? {
if existing?.is_instance_of::<UnreachableHostLogFilter>() {
return Ok(());
}
}
Ok(false)
logger.call_method1("addFilter", (UnreachableHostLogFilter,))?;
Ok(())
}

/// The leftmost `__sqltide_secret__NAME__` placeholder as `(placeholder, NAME)`, matching
Expand Down Expand Up @@ -525,6 +636,7 @@ fn build_clickhouse_connection_config<'py>(
}

pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_class::<UnreachableHostLogFilter>()?;
module.add_function(wrap_pyfunction!(translate_clickhouse_driver_error, module)?)?;
module.add_function(wrap_pyfunction!(render_clickhouse_resource, module)?)?;
module.add_function(wrap_pyfunction!(realize_clickhouse_source, module)?)?;
Expand Down
1 change: 1 addition & 0 deletions crates/sqltide-python/src/clickhouse_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,7 @@ impl ClickHouseAdapterCore {
/// Open a ClickHouse connection for the resolved configuration.
fn connect<'py>(slf: &Bound<'py, Self>, config: Any<'py>) -> PyResult<Any<'py>> {
let py: Python<'py> = slf.py();
crate::clickhouse_adapter::install_unreachable_host_log_filter(py)?;
let raw_client: Any<'py> =
driver_call(py, || slf.call_method1("_open_raw_client", (config,)))?;
attr(py, CONNECTION_CLASS, "ClickHouseConnection")?.call1((raw_client,))
Expand Down
6 changes: 6 additions & 0 deletions src/sqltide/_native.pyi
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse
import logging
import os
import threading
from collections.abc import Callable, Iterable, Mapping, Sequence
Expand Down Expand Up @@ -5728,6 +5729,11 @@ def direct_model_identity_metadata(
tuple[str, list[str], str | None, str | None, list[tuple[str, str]] | None] | None,
],
) -> str: ...

class UnreachableHostLogFilter:
def __new__(cls) -> Self: ...
def filter(self, record: logging.LogRecord) -> bool: ...

def translate_clickhouse_driver_error(error: Exception) -> AdapterWarehouseError: ...
def render_clickhouse_resource(
*,
Expand Down
7 changes: 7 additions & 0 deletions src/sqltide/adapter/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ class AdapterWarehouseError(AdapterError):
class AdapterUnavailableError(AdapterWarehouseError):
"""Raised when the warehouse refuses the connection or its host cannot be resolved."""

endpoint: str | None = None
"""The `host:port` the connection was attempted to, when the adapter knows it."""
reason: str | None = None
"""A short cause such as `connection refused` or `host not found`."""
through_proxy: bool = False
"""Whether `endpoint` is a configured proxy rather than ClickHouse itself."""


class AdapterTimeoutError(AdapterWarehouseError):
"""Raised when a warehouse operation exceeds its available time."""
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/src/sqltide/adapters/clickhouse/_test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,22 @@ class OffsetDedupeRenderTestCase:
description: str
dedupe_offsets: bool
expected_clause_present: bool


@dataclass(frozen=True)
class UnreachableHostDetailsTestCase:
description: str
driver_error: Exception
expected_endpoint: str | None
expected_reason: str
expected_through_proxy: bool


@dataclass(frozen=True)
class DriverLogFilterTestCase:
description: str
message: str
handled_errors: tuple[BaseException, ...]
logger_level: int
expected_kept: bool
expected_level: str
54 changes: 53 additions & 1 deletion tests/unit/src/sqltide/adapters/clickhouse/helpers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import logging
from collections.abc import Iterator, Mapping, Sequence
from typing import cast

from clickhouse_connect.driver.exceptions import OperationalError
from urllib3.exceptions import ProxyError

from sqltide._native import sql_canonicalize
from sqltide._native import UnreachableHostLogFilter, sql_canonicalize
from sqltide.adapter.exceptions import AdapterWarehouseError
from sqltide.adapter.models import (
AdapterManifest,
Expand Down Expand Up @@ -352,3 +354,53 @@ def connection_failure(
setattr(driver_error, link, transport_error)
driver_error.__suppress_context__ = suppress_context
return driver_error


def proxy_failure(message: str, *, cause: BaseException) -> OperationalError:
"""A driver error for a configured proxy that could not be reached, chained as urllib3 wraps
the proxy's connection failure in a `ProxyError`."""
transport_error: OSError = OSError("Failed to establish a new connection")
transport_error.__cause__ = cause
proxy_error: ProxyError = ProxyError("Unable to connect to proxy", transport_error)
proxy_error.__context__ = transport_error
driver_error: OperationalError = OperationalError(message)
driver_error.__cause__ = proxy_error
return driver_error


def filter_driver_record(
*, message: str, handled_errors: tuple[BaseException, ...], logger_level: int
) -> tuple[bool, str]:
"""Run the driver log filter on one driver record while the first of `handled_errors` (if
any) is being handled, as clickhouse_connect logs it, at `logger_level`; return whether the
record is kept and its final level name."""
logger: logging.Logger = logging.getLogger("clickhouse_connect.driver.httpclient")
record: logging.LogRecord = logger.makeRecord(
logger.name, logging.WARNING, __file__, 0, message, (), None
)
previous_level: int = logger.level
logger.setLevel(logger_level)
outcome: _FilterOutcome = _FilterOutcome(record=record)
with outcome:
_ = [_raise(error) for error in handled_errors]
logger.setLevel(previous_level)
return outcome.kept, record.levelname


class _FilterOutcome:
"""Applies the filter in `__exit__`, where the raised error is the one being handled."""

def __init__(self, *, record: logging.LogRecord) -> None:
self.record: logging.LogRecord = record
self.kept: bool = False

def __enter__(self) -> None:
return None

def __exit__(self, *_: object) -> bool:
self.kept = UnreachableHostLogFilter().filter(self.record)
return True


def _raise(error: BaseException) -> None:
raise error
Loading
Loading