Skip to content
Open
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
2 changes: 1 addition & 1 deletion DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
- Added support for AWS outbound JWT token attestation for Workload Identity Federation (WIF). This can be enabled by setting the `SNOWFLAKE_ENABLE_AWS_WIF_OUTBOUND_TOKEN` environment variable to `true`. Note: This environment variable will be removed in a future release.
- Removed dynamic class deserialization from the OCSP response validation cache to prevent arbitrary code execution via crafted cache files (SNOW-2439940). The `SNOWFLAKE_ENABLE_CUSTOM_REVOCATION_ERRORS` environment variable is now a no-op.
- Updated SPCS token injection to gate on `SNOWFLAKE_RUNNING_INSIDE_SPCS` environment variable, trim whitespace, and remove configurable token path.
- Refined structured error handler typing and improved handling of non-Snowflake exceptions by re-raising generic Python exceptions directly instead of routing them through structured handlers.

- v4.4.0(March 25,2026)
- Bump the lower boundary of cryptography to 46.0.5 due to CVE-2026-26007.
Expand All @@ -29,7 +30,6 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
- Fixed JSONDecodeError in result_batch._load() when fetching large result sets
- Fixed validation ordering for `client_session_keep_alive_heartbeat_frequency`.


- v4.3.0(February 12,2026)
- Ensured proper list conversion - the converter runs to_snowflake on all list elements.
- Made the parameter `server_session_keep_alive` in `SnowflakeConnection` skip checking for pending async queries, providing faster connection close times especially when many async queries are executed.
Expand Down
9 changes: 4 additions & 5 deletions src/snowflake/connector/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
ER_NO_USER,
ER_NOT_IMPLICITY_SNOWFLAKE_DATATYPE,
)
from .errors import DatabaseError, Error, OperationalError, ProgrammingError
from .errors import DatabaseError, Error, StructuredErrorHandler, OperationalError, ProgrammingError
from .log_configuration import EasyLoggingConfigPython
from .network import (
DEFAULT_AUTHENTICATOR,
Expand Down Expand Up @@ -609,7 +609,7 @@ def __init__(
easy_logging.create_log()
self._lock_sequence_counter = Lock()
self.sequence_counter = 0
self._errorhandler = Error.default_errorhandler
self._errorhandler: StructuredErrorHandler = Error.default_errorhandler
self._lock_converter = Lock()
self.messages = []
self._async_sfqids: dict[str, None] = {}
Expand Down Expand Up @@ -959,12 +959,11 @@ def application(self) -> str:
return self._application

@property
def errorhandler(self) -> Callable: # TODO: callable args
def errorhandler(self) -> StructuredErrorHandler:
return self._errorhandler

@errorhandler.setter
# Note: Callable doesn't implement operator|
def errorhandler(self, value: Callable | None) -> None:
def errorhandler(self, value: StructuredErrorHandler | None) -> None:
if value is None:
raise ProgrammingError("None errorhandler is specified")
self._errorhandler = value
Expand Down
16 changes: 6 additions & 10 deletions src/snowflake/connector/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
IO,
TYPE_CHECKING,
Any,
Callable,
Dict,
Generic,
Iterator,
Expand Down Expand Up @@ -70,6 +69,8 @@
from .errors import (
DatabaseError,
Error,
ErrorDetails,
StructuredErrorHandler,
IntegrityError,
InterfaceError,
NotSupportedError,
Expand Down Expand Up @@ -374,13 +375,8 @@ def __init__(
"""
self._connection: SnowflakeConnection = connection

self._errorhandler: Callable[
[SnowflakeConnection, SnowflakeCursor, type[Error], dict[str, str]],
None,
] = Error.default_errorhandler
self.messages: list[
tuple[type[Error] | type[Exception], dict[str, str | bool]]
] = []
self._errorhandler: StructuredErrorHandler = Error.default_errorhandler
self.messages: list[tuple[type[Error], ErrorDetails]] = []
self._timebomb: _TrackedQueryCancellationTimer | None = (
None # must be here for abort_exit method
)
Expand Down Expand Up @@ -544,11 +540,11 @@ def connection(self) -> SnowflakeConnection:
return self._connection

@property
def errorhandler(self) -> Callable:
def errorhandler(self) -> StructuredErrorHandler:
return self._errorhandler

@errorhandler.setter
def errorhandler(self, value: Callable | None) -> None:
def errorhandler(self, value: StructuredErrorHandler | None) -> None:
logger.debug("setting errorhandler: %s", value)
if value is None:
raise ProgrammingError("Invalid errorhandler is specified")
Expand Down
86 changes: 54 additions & 32 deletions src/snowflake/connector/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import re
import traceback
from logging import getLogger
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, TypedDict, Protocol

from .errorcode import ER_HTTP_GENERAL_ERROR
from .secret_detector import SecretDetector
Expand All @@ -26,6 +26,22 @@

RE_FORMATTED_ERROR = re.compile(r"^(\d{6,})(?: \((\S+)\))?:")

class ErrorDetails(TypedDict, total=False):
msg: str
errno: int | None
sqlstate: str | None
sfqid: str | None
query: str | None
done_format_msg: bool

class StructuredErrorHandler(Protocol):
def __call__(
self,
connection: SnowflakeConnection | AsyncSnowflakeConnection | None,
cursor: SnowflakeCursor | AsyncSnowflakeCursor | None,
error_class: type[Error],
error_value: ErrorDetails,
) -> None: ...

class Error(Exception):
"""Base Snowflake exception class."""
Expand Down Expand Up @@ -208,10 +224,10 @@ def exception_telemetry(

@staticmethod
def default_errorhandler(
connection: SnowflakeConnection,
cursor: SnowflakeCursor,
connection: SnowflakeConnection | AsyncSnowflakeConnection | None,
cursor: SnowflakeCursor | AsyncSnowflakeCursor | None,
error_class: type[Error],
error_value: dict[str, str],
error_value: ErrorDetails,
) -> None:
"""Default error handler that raises an error.

Expand Down Expand Up @@ -241,9 +257,9 @@ def default_errorhandler(

@staticmethod
def errorhandler_wrapper_from_cause(
connection: SnowflakeConnection,
cause: Error | Exception,
cursor: SnowflakeCursor | None = None,
connection: SnowflakeConnection | AsyncSnowflakeConnection | None,
cause: Error,
cursor: SnowflakeCursor | AsyncSnowflakeCursor | None = None,
) -> None:
"""Wrapper for errorhandler_wrapper, it is called with a cause instead of a dictionary.

Expand Down Expand Up @@ -274,10 +290,10 @@ def errorhandler_wrapper_from_cause(

@staticmethod
def errorhandler_wrapper(
connection: SnowflakeConnection | None,
cursor: SnowflakeCursor | None,
error_class: type[Error] | type[Exception],
error_value: dict[str, Any],
connection: SnowflakeConnection | AsyncSnowflakeConnection | None,
cursor: SnowflakeCursor | AsyncSnowflakeCursor | None,
error_class: type[Error],
error_value: ErrorDetails,
) -> None:
"""Error handler wrapper that calls the errorhandler method.

Expand Down Expand Up @@ -309,36 +325,45 @@ def errorhandler_wrapper(

@staticmethod
def errorhandler_wrapper_from_ready_exception(
connection: SnowflakeConnection | None,
cursor: SnowflakeCursor | None,
connection: SnowflakeConnection | AsyncSnowflakeConnection | None,
cursor: SnowflakeCursor | AsyncSnowflakeCursor | None,
error_exc: Error | Exception,
) -> None:
"""Like errorhandler_wrapper, but it takes a ready to go Exception."""
if isinstance(error_exc, Error):
error_value = {
error_class = type(error_exc)
error_value: ErrorDetails = {
"msg": error_exc.msg,
"errno": error_exc.errno,
"sqlstate": error_exc.sqlstate,
"sfqid": error_exc.sfqid,
"query": error_exc.query,
"done_format_msg": True,
}
else:
error_value = error_exc.args
error_class = InterfaceError
error_value = {
"msg": str(error_exc),
"errno": None,
"sqlstate": None,
"done_format_msg": True,
}

handed_over = Error.hand_to_other_handler(
connection,
cursor,
type(error_exc),
error_class,
error_value,
)
if not handed_over:
raise error_exc

@staticmethod
def hand_to_other_handler(
connection: SnowflakeConnection | None,
cursor: SnowflakeCursor | None,
error_class: type[Error] | type[Exception],
error_value: dict[str, str | bool],
connection: SnowflakeConnection | AsyncSnowflakeConnection | None,
cursor: SnowflakeCursor | AsyncSnowflakeCursor | None,
error_class: type[Error],
error_value: ErrorDetails,
) -> bool:
"""If possible give error to a higher error handler in connection, or cursor.

Expand Down Expand Up @@ -367,20 +392,17 @@ def hand_to_other_handler(

@staticmethod
def errorhandler_make_exception(
error_class: type[Error] | type[Exception],
error_value: dict[str, str | bool],
) -> Error | Exception:
error_class: type[Error],
error_value: ErrorDetails,
) -> Error:
"""Helper function to errorhandler_wrapper that creates the exception."""
error_value.setdefault("done_format_msg", False)

if issubclass(error_class, Error):
return error_class(
msg=error_value["msg"],
errno=error_value.get("errno"),
sqlstate=error_value.get("sqlstate"),
sfqid=error_value.get("sfqid"),
)
return error_class(error_value)
return error_class(
msg=error_value["msg"],
errno=error_value.get("errno"),
sqlstate=error_value.get("sqlstate"),
sfqid=error_value.get("sfqid"),
)


class _Warning(Exception):
Expand Down
121 changes: 121 additions & 0 deletions test/unit/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import re
import uuid
from unittest.mock import MagicMock

import pytest

from snowflake.connector import errors

Expand Down Expand Up @@ -36,3 +39,121 @@ def test_detecting_duplicate_detail_insertion():

def test_args():
assert errors.Error("msg").args == ("msg",)

def test_default_errorhandler_raises_programming_error():
with pytest.raises(errors.ProgrammingError) as exc_info:
errors.Error.default_errorhandler(
None,
None,
errors.ProgrammingError,
{
"msg": "Some error happened",
"errno": 123456,
"sqlstate": "24000",
},
)

assert exc_info.value.errno == 123456
assert exc_info.value.sqlstate == "24000"
assert "Some error happened" in exc_info.value.msg


def test_errorhandler_wrapper_passes_structured_payload_to_custom_handler():
captured = {}

def handler(connection, cursor, error_class, error_value):
captured["connection"] = connection
captured["cursor"] = cursor
captured["error_class"] = error_class
captured["error_value"] = error_value

connection = MagicMock()
connection.messages = []

cursor = MagicMock()
cursor.messages = []
cursor.errorhandler = handler

errors.Error.errorhandler_wrapper(
connection,
cursor,
errors.ProgrammingError,
{
"msg": "Boom",
"errno": 123,
},
)

assert captured["connection"] is connection
assert captured["cursor"] is cursor
assert captured["error_class"] is errors.ProgrammingError
assert captured["error_value"]["msg"] == "Boom"
assert captured["error_value"]["errno"] == 123
assert captured["error_value"]["done_format_msg"] is False
assert connection.messages[0][0] is errors.ProgrammingError
assert cursor.messages[0][0] is errors.ProgrammingError

def test_errorhandler_wrapper_from_ready_exception_normalizes_error_instances():
captured = {}

def handler(connection, cursor, error_class, error_value):
captured["error_class"] = error_class
captured["error_value"] = error_value

connection = MagicMock()
connection.messages = []

cursor = MagicMock()
cursor.messages = []
cursor.errorhandler = handler

error_exc = errors.ProgrammingError(
msg="Boom",
errno=123,
sqlstate="24000",
)

errors.Error.errorhandler_wrapper_from_ready_exception(
connection,
cursor,
error_exc,
)

assert captured["error_class"] is errors.ProgrammingError
assert captured["error_value"]["msg"] == error_exc.msg
assert captured["error_value"]["errno"] == error_exc.errno
assert captured["error_value"]["sqlstate"] == error_exc.sqlstate
assert captured["error_value"]["done_format_msg"] is True

def test_errorhandler_wrapper_from_ready_exception_normalizes_and_calls_handler_for_generic_exception():
captured = {}
def handler(connection, cursor, error_class, error_value):
captured["error_class"] = error_class
captured["error_value"] = error_value

connection = MagicMock()
connection.messages = []
cursor = MagicMock()
cursor.messages = []
cursor.errorhandler = handler

error_exc = ValueError("generic boom")

errors.Error.errorhandler_wrapper_from_ready_exception(
connection,
cursor,
error_exc,
)

assert captured["error_class"] is errors.InterfaceError
assert captured["error_value"]["msg"] == "generic boom"
assert captured["error_value"]["done_format_msg"] is True

def test_errorhandler_wrapper_from_ready_exception_reraises_original_when_no_handler():
# Verify the original exception type is preserved on re-raise
with pytest.raises(ValueError, match="boom"):
errors.Error.errorhandler_wrapper_from_ready_exception(
None,
None,
ValueError("boom"),
)
Loading