Skip to content

HTTP errors do not expose structured ClickHouse error code/name; only an ad-hoc message string #786

@claude

Description

@claude

Description

When a query fails server-side, clickhouse-connect raises a DatabaseError (or OperationalError on a retried request) whose only carrier of structured data is the formatted message string. The actual ClickHouse server error code is available to the driver. it arrives in the X-ClickHouse-Exception-Code response header, and the symbolic name (e.g. UNKNOWN_TABLE) plus numeric code are present in the response body. But the driver folds all of that into a single human-readable string and discards the structured fields.

Callers that need to branch on the ClickHouse error code (e.g. 60 UNKNOWN_TABLE, 159 TIMEOUT_EXCEEDED, 241 MEMORY_LIMIT_EXCEEDED) are forced to regex-parse the message string. There is no code, name, or message attribute on the raised exception to branch on.

This is the Python equivalent of the clickhouse-go report in ClickHouse/clickhouse-go#1876, where the HTTP path surfaces server errors as ad-hoc strings rather than a typed exception. clickhouse-connect is HTTP-first, so this is its primary error path, not an edge protocol.

Where it lives

clickhouse_connect/driver/exceptions.py defines the exception hierarchy. None of Error, DatabaseError, or OperationalError carry a code / name field.

clickhouse_connect/driver/httpclient.py, HttpClient._error_handler reads the code from the header and then throws it away into a string:

def _error_handler(self, response: HTTPResponse, retried: bool = False) -> None:
    ...
    if self.show_clickhouse_errors:
        err_code = response.headers.get(ex_header)   # X-ClickHouse-Exception-Code
        if err_code:
            err_str = f"Received ClickHouse exception, code: {err_code}"
        ...
        if body:
            err_str = f"{err_str}, server response: {body}"
    ...
    raise OperationalError(err_str) if retried else DatabaseError(err_str) from None

The async client has the same pattern in clickhouse_connect/driver/asyncclient.py around line 1912.

ClickHouse server version

Tested against ClickHouse server 26.5.1.882 (reachable over HTTP at localhost:8123).

Reproduction

import clickhouse_connect
from clickhouse_connect.driver.exceptions import DatabaseError

client = clickhouse_connect.get_client(host="localhost", port=8123)

try:
    client.query("SELECT * FROM non_existent_table")
except DatabaseError as exc:
    # Expected: a structured field to branch on, e.g.
    #     assert exc.code == 60
    #     assert exc.name == "UNKNOWN_TABLE"
    print("code attr:", getattr(exc, "code", None))   # -> None
    print("name attr:", getattr(exc, "name", None))   # -> None
    print("str:", str(exc))
    # The only way to get the code today is to regex-parse this string:
    #   "Received ClickHouse exception, code: 60, server response:
    #    Code: 60. DB::Exception: Unknown table expression identifier
    #    'non_existent_table' ... (UNKNOWN_TABLE) (version 26.5.1.882 ...)
    #    (for url http://localhost:8123)"

Expected vs actual:

  • Expected: the raised exception exposes the numeric server code and ideally the symbolic name, e.g. exc.code == 60 and exc.name == "UNKNOWN_TABLE", so callers can branch without string parsing.
  • Actual: getattr(exc, "code", None) is None. The code exists only as a substring of str(exc), and only when show_clickhouse_errors is enabled.

Suggested fix

This is a feature/enhancement rather than a regression, but the data is already in hand:

  • Add a code: int | None (and optionally name: str) attribute to DatabaseError / OperationalError in clickhouse_connect/driver/exceptions.py.
  • In HttpClient._error_handler (and the async equivalent in asyncclient.py), parse int(response.headers.get(ex_header)) and pass it into the exception, plus optionally extract the (NAME) token from the body, instead of only formatting it into err_str.

Note that the code header is populated independently of show_clickhouse_errors, so the structured field can be set even when the verbose message body is suppressed.

Link

Related upstream report: ClickHouse/clickhouse-go#1876

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions