diff --git a/DESCRIPTION.md b/DESCRIPTION.md index 5665af61b6..13e62021b8 100644 --- a/DESCRIPTION.md +++ b/DESCRIPTION.md @@ -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. @@ -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. diff --git a/src/snowflake/connector/connection.py b/src/snowflake/connector/connection.py index e95cb6e078..69215bd45e 100644 --- a/src/snowflake/connector/connection.py +++ b/src/snowflake/connector/connection.py @@ -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, @@ -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] = {} @@ -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 diff --git a/src/snowflake/connector/cursor.py b/src/snowflake/connector/cursor.py index 7134f3ddf8..a641bda73f 100644 --- a/src/snowflake/connector/cursor.py +++ b/src/snowflake/connector/cursor.py @@ -19,7 +19,6 @@ IO, TYPE_CHECKING, Any, - Callable, Dict, Generic, Iterator, @@ -70,6 +69,8 @@ from .errors import ( DatabaseError, Error, + ErrorDetails, + StructuredErrorHandler, IntegrityError, InterfaceError, NotSupportedError, @@ -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 ) @@ -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") diff --git a/src/snowflake/connector/errors.py b/src/snowflake/connector/errors.py index 0bb12b593a..ee003a5cc1 100644 --- a/src/snowflake/connector/errors.py +++ b/src/snowflake/connector/errors.py @@ -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 @@ -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.""" @@ -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. @@ -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. @@ -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. @@ -309,25 +325,34 @@ 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: @@ -335,10 +360,10 @@ def errorhandler_wrapper_from_ready_exception( @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. @@ -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): diff --git a/test/unit/test_errors.py b/test/unit/test_errors.py index a09bca727b..29fc9669ef 100644 --- a/test/unit/test_errors.py +++ b/test/unit/test_errors.py @@ -2,6 +2,9 @@ import re import uuid +from unittest.mock import MagicMock + +import pytest from snowflake.connector import errors @@ -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"), + )