diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30b3b5a..6b16237 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,8 +33,8 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy#egg=rhosocial-activerecord-testsuite # NOSONAR - name: Install package run: | @@ -84,8 +84,8 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy#egg=rhosocial-activerecord-testsuite # NOSONAR - name: Install package run: | @@ -117,8 +117,8 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy#egg=rhosocial-activerecord-testsuite # NOSONAR - name: Install package run: | diff --git a/changelog.d/12.added.md b/changelog.d/12.added.md new file mode 100644 index 0000000..f0d58dc --- /dev/null +++ b/changelog.d/12.added.md @@ -0,0 +1 @@ +Classified Snowflake driver errors into the semantic error taxonomy (data, record, policy, concurrency, infrastructure) and aligned `execute` / `execute_many` with the execute-phase error contract: framework errors pass through, auto-commit failures raise `TransactionError`, and only transient connection failures are retried. Added contract tests covering the mapping and phases. diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py index ca0d5d9..e1c1013 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py @@ -32,10 +32,6 @@ from rhosocial.activerecord.backend.base import AsyncStorageBackend from rhosocial.activerecord.backend.errors import ( ConnectionError, - DatabaseError, - IntegrityError, - OperationalError, - QueryError, ) from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin from ..config import SnowflakeConnectionConfig @@ -55,6 +51,16 @@ class AsyncSnowflakeBackend( driver for async compatibility. This follows the project's sync/async parity principle, providing the same API surface with async/await syntax. + Execution notes: + ``execute``/``execute_many`` come from the core execution pipeline and + are not overridden. Snowflake has no row-level locking and no + deadlock/lock-wait semantics, so execute failures never surface as + ``LockError`` or ``DeadlockError``; they keep the driver's own + operational/programming category. The backend defines no + ``_handle_auto_commit``, so execute relies on the driver's + ``autocommit`` setting (``True`` by default) rather than issuing an + extra commit. + Caveats: - All I/O operations run in a thread pool executor, not truly async. - High-concurrency scenarios should inject a bounded executor to @@ -186,24 +192,25 @@ async def ping(self, reconnect: bool = True) -> bool: return False async def _handle_error(self, error: Exception) -> None: - """Handle and classify a Snowflake error.""" - category = self._classify_error(error) - error_msg = str(error) - if category == 'connection': - self.log(logging.DEBUG, f"Connection error: {error_msg}") - raise ConnectionError(error_msg) from error - elif category == 'integrity': - self.log(logging.DEBUG, f"Integrity error: {error_msg}") - raise IntegrityError(error_msg) from error - elif category == 'query': - self.log(logging.DEBUG, f"Query error: {error_msg}") - raise QueryError(error_msg) from error - elif category == 'operational': - self.log(logging.DEBUG, f"Operational error: {error_msg}") - raise OperationalError(error_msg) from error - else: - self.log(logging.DEBUG, f"Database error: {error_msg}") - raise DatabaseError(error_msg) from error + """Handle and classify a Snowflake error asynchronously. + + Framework ``DatabaseError`` instances are re-raised unchanged so + callers can still catch ``ConnectionError``, ``TypeConversionError`` or + ``TransactionError`` by their real type; they are never reclassified + into a driver category. All other exceptions are translated into the + shared error taxonomy and carry driver diagnostics (errno, SQLSTATE, + operation, backend). Only transient infrastructure failures (network + timeouts, service unavailability, lost connections) become retryable. + + Args: + error: The exception to handle. + + Raises: + The same instance when ``error`` is already a framework + ``DatabaseError``; otherwise the appropriate rhosocial error based + on classification. + """ + self._raise_mapped_error(error) async def get_server_version(self) -> Tuple[int, ...]: """Get the Snowflake server version.""" diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py b/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py index c2cf257..1a85375 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py @@ -11,10 +11,6 @@ from rhosocial.activerecord.backend.base import StorageBackend from rhosocial.activerecord.backend.errors import ( ConnectionError, - DatabaseError, - IntegrityError, - OperationalError, - QueryError, ) from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin from ..config import SnowflakeConnectionConfig @@ -31,6 +27,16 @@ class SnowflakeBackend( """Snowflake-specific backend implementation (synchronous). Uses snowflake-connector-python for database connectivity. + + Execution notes: + ``execute``/``execute_many`` come from the core execution pipeline and + are not overridden. Snowflake has no row-level locking and no + deadlock/lock-wait semantics, so execute failures never surface as + ``LockError`` or ``DeadlockError``; they keep the driver's own + operational/programming category. The backend defines no + ``_handle_auto_commit``, so execute relies on the driver's + ``autocommit`` setting (``True`` by default) rather than issuing an + extra commit. """ def __init__(self, **kwargs): @@ -148,29 +154,23 @@ def ping(self, reconnect: bool = True) -> bool: def _handle_error(self, error: Exception) -> None: """Handle and classify a Snowflake error. + Framework ``DatabaseError`` instances are re-raised unchanged so + callers can still catch ``ConnectionError``, ``TypeConversionError`` or + ``TransactionError`` by their real type; they are never reclassified + into a driver category. All other exceptions are translated into the + shared error taxonomy and carry driver diagnostics (errno, SQLSTATE, + operation, backend). Only transient infrastructure failures (network + timeouts, service unavailability, lost connections) become retryable. + Args: error: The exception to handle. Raises: - Appropriate rhosocial error based on classification. + The same instance when ``error`` is already a framework + ``DatabaseError``; otherwise the appropriate rhosocial error based + on classification. """ - category = self._classify_error(error) - error_msg = str(error) - if category == 'connection': - self.log(logging.DEBUG, f"Connection error: {error_msg}") - raise ConnectionError(error_msg) from error - elif category == 'integrity': - self.log(logging.DEBUG, f"Integrity error: {error_msg}") - raise IntegrityError(error_msg) from error - elif category == 'query': - self.log(logging.DEBUG, f"Query error: {error_msg}") - raise QueryError(error_msg) from error - elif category == 'operational': - self.log(logging.DEBUG, f"Operational error: {error_msg}") - raise OperationalError(error_msg) from error - else: - self.log(logging.DEBUG, f"Database error: {error_msg}") - raise DatabaseError(error_msg) from error + self._raise_mapped_error(error) def get_server_version(self) -> Tuple[int, ...]: """Get the Snowflake server version. diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/backend.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/backend.py index bfb4ff5..4938f16 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/backend.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/backend.py @@ -6,8 +6,16 @@ and dialect property management. """ import logging -from typing import Any, Dict, Optional, Tuple, Type - +from typing import Any, Dict, NoReturn, Optional, Tuple, Type + +from rhosocial.activerecord.backend.errors import ( + ConnectionError, + DatabaseError, + IntegrityError, + OperationalError, + QueryError, + attach_diagnostics, +) from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter from ..adapters import ( @@ -18,6 +26,79 @@ SnowflakeVariantAdapter, ) +# Driver messages are the last-resort signal, after driver code and exception +# class (see the error-taxonomy backend mapping rules). Snowflake declares +# UNIQUE constraints but does not enforce them by default, and has no row-level +# deadlock/lock-wait semantics to classify, so lock and deadlock failures are +# deliberately absent from this taxonomy: they fall back to the driver's own +# Operational/Programming/Database class instead of LockError/DeadlockError. +_INTEGRITY_SIGNALS = ( + 'already exists', + 'duplicate', + 'unique constraint', + 'unique violation', + 'primary key', + 'foreign key', + 'not null', + 'non-nullable', + 'null result', + 'cannot be null', + 'constraint violation', + 'constraint failed', +) +_CONNECTION_SIGNALS = ( + 'incorrect username or password', + 'invalid username', + 'invalid password', + 'authentication', + 'login', + 'credential', + 'failed to connect', + 'connect', + 'connection', + 'network', + 'timeout', + 'timed out', + 'handshake', + 'refused', + 'service unavailable', + 'gateway timeout', + 'session is invalid', + 'session has expired', + 'connection is closed', + 'temporarily unavailable', + 'too many requests', +) +_QUERY_SIGNALS = ( + 'syntax error', + 'invalid identifier', + 'invalid object', + 'invalid column', + 'unknown column', + 'unknown table', + 'does not exist', + 'not found', + 'sql compilation error', +) +_PERMISSION_SIGNALS = ( + 'permission', + 'not authorized', + 'unauthorized', + 'access denied', + 'not allowed', + 'privilege', +) +_INVALID_CREDENTIAL_SIGNALS = ( + 'incorrect username or password', + 'invalid username', + 'invalid password', + 'authentication', + 'login', + 'credential', + 'user is disabled', + 'account is locked', +) + class SnowflakeBackendMixin: """Shared non-I/O methods for Snowflake backend implementations. @@ -59,20 +140,45 @@ def _dialect_instance(self) -> Any: self._dialect_cache = SnowflakeDialect(version=version) return self._dialect_cache + @staticmethod + def _matches(message: str, signals: Tuple[str, ...]) -> bool: + """Return whether any lowercase message signal occurs in the message.""" + return any(signal in message for signal in signals) + def _classify_error(self, error: Exception) -> str: """Classify a Snowflake error into a standard error category. - Uses snowflake.connector.errors exception type hierarchy for - accurate classification, with string matching as fallback for - non-Snowflake exceptions. + Framework errors (``rhosocial.activerecord.backend.errors.DatabaseError`` + and every subclass, including ``ConnectionError``, + ``TypeConversionError`` and ``TransactionError``) are returned as + ``'framework'`` before any driver inspection. They already carry the + intended taxonomy type, and their messages can contain words such as + ``connection`` or ``timeout`` that the message fallback would otherwise + misread as a driver category. + + For everything else, uses snowflake.connector.errors exception type + hierarchy for accurate classification, then driver messages for the + cases the class alone cannot express (unique/not-null constraints raised + as ProgrammingError, permission denials, login failures). String + matching remains the fallback for non-Snowflake exceptions. + + Snowflake has weak locking semantics: lock waits and deadlocks are not + translated to LockError/DeadlockError; they keep the driver's own + operational/programming category. Args: error: The exception to classify. Returns: String category: 'connection', 'integrity', 'query', - 'operational', or 'unknown' + 'operational', 'framework', or 'unknown' """ + # Short-circuit before the driver import: framework errors must not be + # re-translated, and must not reach the message fallback at the end of + # this method. + if isinstance(error, DatabaseError): + return 'framework' + import snowflake.connector.errors as _errors SnowflakeError = _errors.Error SnowflakeInterfaceError = _errors.InterfaceError @@ -92,6 +198,8 @@ def _classify_error(self, error: Exception) -> str: SnowflakeRequestTimeoutError = getattr(_errors, "RequestTimeoutError", _missing) SnowflakeServiceUnavailableError = getattr(_errors, "ServiceUnavailableError", _missing) + message = str(error).lower() + if isinstance(error, SnowflakeIntegrityError): return 'integrity' @@ -104,49 +212,171 @@ def _classify_error(self, error: Exception) -> str: return 'connection' if isinstance(error, SnowflakeOperationalError): - error_msg = str(error).lower() - if any(s in error_msg for s in [ - 'connection', 'network', 'timeout', - 'handshake', 'connect', 'refused', - ]): + if self._matches(message, _CONNECTION_SIGNALS): return 'connection' return 'operational' - if isinstance(error, SnowflakeProgrammingError): - return 'query' - - if isinstance(error, SnowflakeDataError): - return 'query' - - if isinstance(error, SnowflakeNotSupportedError): + if isinstance(error, ( + SnowflakeProgrammingError, + SnowflakeDataError, + SnowflakeNotSupportedError, + )): + # Permission failures also arrive as ProgrammingError; keep them + # in the generic DatabaseError bucket rather than QueryError. + if self._matches(message, _PERMISSION_SIGNALS): + return 'unknown' + if self._matches(message, _INTEGRITY_SIGNALS): + return 'integrity' return 'query' if isinstance(error, SnowflakeHttpError): return 'connection' - if isinstance(error, SnowflakeDatabaseError): - return 'unknown' - - if isinstance(error, SnowflakeError): + if isinstance(error, (SnowflakeDatabaseError, SnowflakeError)): + if self._matches(message, _INTEGRITY_SIGNALS): + return 'integrity' + if self._matches(message, _CONNECTION_SIGNALS): + return 'connection' + if self._matches(message, _QUERY_SIGNALS): + return 'query' return 'unknown' - error_msg = str(error).lower() - if any(s in error_msg for s in [ - 'connection', 'network', 'timeout', 'handshake', - ]): + if self._matches(message, _CONNECTION_SIGNALS): return 'connection' - if any(s in error_msg for s in [ - 'unique', 'constraint', 'duplicate', - 'primary key', 'foreign key', - ]): + if self._matches(message, _INTEGRITY_SIGNALS): return 'integrity' - if any(s in error_msg for s in [ - 'syntax', 'invalid', 'not found', 'does not exist', - ]): + if self._matches(message, _QUERY_SIGNALS): return 'query' return 'unknown' + def _is_transient_infrastructure_error(self, error: Exception) -> bool: + """Return whether a connection/operational failure should clear on retry. + + Only network timeouts, HTTP gateway/service failures and lost + connections are transient. Invalid credentials, permission denials, + syntax errors and integrity violations are not: retrying them cannot + succeed without an external change. + """ + message = str(error).lower() + if self._matches(message, _INVALID_CREDENTIAL_SIGNALS): + return False + + try: + import snowflake.connector.errors as _errors + except ImportError: # pragma: no cover - driver optional at import time + _errors = None + + if _errors is not None: + transient_types = tuple( + cls for cls in ( + getattr(_errors, "GatewayTimeoutError", None), + getattr(_errors, "RequestTimeoutError", None), + getattr(_errors, "ServiceUnavailableError", None), + ) if cls is not None + ) + if transient_types and isinstance(error, transient_types): + return True + http_error = getattr(_errors, "HttpError", None) + if http_error is not None and isinstance(error, http_error): + return True + # InterfaceError covers driver-internal protocol failures; the + # existing contract keeps it a non-transient ConnectionError. + interface_error = getattr(_errors, "InterfaceError", None) + if interface_error is not None and isinstance(error, interface_error): + return False + + return self._matches(message, _CONNECTION_SIGNALS) + + def _resolve_error_mapping(self, error: Exception, category: str) -> Tuple[str, Any]: + """Decide the concrete taxonomy target and retry hint for a category. + + Framework errors are never mapped: they return ``'framework'`` so + :meth:`_handle_error` can re-raise the original instance unchanged. + This check also covers ``category`` values computed elsewhere, because + the type of ``error`` is the authoritative signal. + + Args: + error: The driver exception being translated. + category: Coarse category returned by :meth:`_classify_error`. + + Returns: + ``(target, transient)`` where target is one of ``connection``, + ``operational``, ``integrity``, ``query``, ``framework`` or + ``database``. ``transient`` is ``True``/``False`` for infrastructure + failures and ``None`` otherwise. + """ + if isinstance(error, DatabaseError) or category == 'framework': + return 'framework', None + if category == 'integrity': + return 'integrity', False + if category == 'query': + return 'query', False + if category == 'operational': + return 'operational', self._is_transient_infrastructure_error(error) + if category == 'connection': + if self._is_transient_infrastructure_error(error): + return 'operational', True + return 'connection', False + return 'database', None + + @staticmethod + def _error_diagnostics(error: Exception, transient: Any) -> Dict[str, Any]: + """Build ``attach_diagnostics`` keyword arguments for a driver exception.""" + return { + 'operation': 'execute', + 'driver_code': getattr(error, 'errno', None), + 'sqlstate': getattr(error, 'sqlstate', None), + 'backend': 'snowflake', + 'transient': transient, + } + + def _raise_mapped_error(self, error: Exception) -> NoReturn: + """Classify a driver exception and raise the matching taxonomy error. + + Shared by the sync and async backends: classification is pure logic, + so only the ``_handle_error`` entry points differ (the async one is + awaitable). Framework ``DatabaseError`` instances are re-raised + unchanged instead of being translated into a driver category, so callers + can still catch ``ConnectionError``, ``TypeConversionError`` or + ``TransactionError`` by their real type. + + Args: + error: The driver exception to classify. + + Raises: + The same instance when ``error`` is already a framework + ``DatabaseError``; otherwise the mapped taxonomy error carrying + driver diagnostics. + """ + # Contract front door: a framework error already has its final type. + # Re-raise the same instance, before any driver inspection or mapper. + if isinstance(error, DatabaseError): + raise error + + category = self._classify_error(error) + target, transient = self._resolve_error_mapping(error, category) + if target == 'framework': + # Defensive: `_resolve_error_mapping` can only stall here if a + # future caller bypasses the check above. + raise error + error_msg = str(error) + diagnostics = self._error_diagnostics(error, transient) + if target == 'connection': + self.log(logging.DEBUG, f"Connection error: {error_msg}") + raise attach_diagnostics(ConnectionError(error_msg), **diagnostics) from error + if target == 'integrity': + self.log(logging.DEBUG, f"Integrity error: {error_msg}") + raise attach_diagnostics(IntegrityError(error_msg), **diagnostics) from error + if target == 'query': + self.log(logging.DEBUG, f"Query error: {error_msg}") + raise attach_diagnostics(QueryError(error_msg), **diagnostics) from error + if target == 'operational': + self.log(logging.DEBUG, f"Operational error: {error_msg}") + raise attach_diagnostics(OperationalError(error_msg), **diagnostics) from error + self.log(logging.DEBUG, f"Database error: {error_msg}") + raise attach_diagnostics(DatabaseError(error_msg), **diagnostics) from error + def get_default_adapter_suggestions(self) -> Dict[Type, Tuple[SQLTypeAdapter, Type]]: """Provide default type adapter suggestions for Snowflake. diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/errors/test_error_taxonomy_contract.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/errors/test_error_taxonomy_contract.py new file mode 100644 index 0000000..52e2bac --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/errors/test_error_taxonomy_contract.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/errors/test_error_taxonomy_contract.py +"""Run the testsuite error-taxonomy contract for the Snowflake backend. + +The contract lives in rhosocial-activerecord-testsuite; importing it here keeps +CI from silently skipping the taxonomy checks when that package changes. +""" +from rhosocial.activerecord.testsuite.feature.backend.errors.test_error_taxonomy import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_driver_error_mapping.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_driver_error_mapping.py new file mode 100644 index 0000000..9d99b2c --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_driver_error_mapping.py @@ -0,0 +1,347 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/test_driver_error_mapping.py +"""Driver-error to taxonomy mapping for the Snowflake backend. + +These tests construct ``snowflake.connector.errors`` exceptions directly and +call ``_handle_error`` on an unconnected backend, so they need no Snowflake +account. The driver is optional: when the connector is not installed the whole +module skips. + +Snowflake has weak locking semantics: lock waits and deadlocks are not +classified as LockError/DeadlockError, which one test pins down. +""" +import pytest + +pytest.importorskip("snowflake.connector") + +from snowflake.connector import errors as driver_errors # noqa: E402 + +from rhosocial.activerecord.backend.errors import ( # noqa: E402 + ConnectionError as BackendConnectionError, + DatabaseError as BackendDatabaseError, + DataError, + DeadlockError, + IntegrityError, + LockError, + OperationalError as BackendOperationalError, + QueryError, +) +from rhosocial.activerecord.backend.impl.snowflake.backend import SnowflakeBackend # noqa: E402 +from rhosocial.activerecord.backend.impl.snowflake.backend.async_backend import ( # noqa: E402 + AsyncSnowflakeBackend, +) +from rhosocial.activerecord.backend.impl.snowflake.config import ( # noqa: E402 + SnowflakeConnectionConfig, +) + + +def _config() -> SnowflakeConnectionConfig: + """Build a config with dummy credentials; nothing connects.""" + return SnowflakeConnectionConfig( + account="unit-test-account", + username="unit-test-user", + password="unit-test-password", + database="unit-test-database", + ) + + +def _driver_error(name, message, errno=None, sqlstate=None): + """Construct a driver exception, tolerating older connector signatures.""" + cls = getattr(driver_errors, name) + try: + return cls(msg=message, errno=errno, sqlstate=sqlstate) + except TypeError: + pass + try: + return cls(msg=message, errno=errno) + except TypeError: + return cls(msg=message) + + +def _assert_diagnostics(mapped, error): + """Assert every mapped error carries the shared driver diagnostics.""" + assert mapped.backend == "snowflake" + assert mapped.operation == "execute" + assert mapped.driver_code == getattr(error, "errno", None) + assert mapped.sqlstate == getattr(error, "sqlstate", None) + + +class TestDriverErrorMapping: + """Synchronous driver-error mapping.""" + + def test_duplicate_unique_maps_to_integrity(self): + error = _driver_error( + "ProgrammingError", + "Duplicate key value violates unique constraint", + errno=100132, + sqlstate="23000", + ) + with pytest.raises(IntegrityError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert isinstance(mapped, DataError) + assert mapped.retryable is False + assert mapped.transient is False + _assert_diagnostics(mapped, error) + assert mapped.__cause__ is error + + def test_already_exists_maps_to_integrity(self): + error = _driver_error( + "DatabaseError", + "Object 'USERS' already exists.", + errno=100132, + sqlstate="23000", + ) + with pytest.raises(IntegrityError): + SnowflakeBackend(connection_config=_config())._handle_error(error) + + def test_not_null_maps_to_integrity(self): + error = _driver_error( + "ProgrammingError", + "NULL result in a non-nullable column", + errno=100072, + sqlstate="22004", + ) + with pytest.raises(IntegrityError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + assert isinstance(info.value, DataError) + assert info.value.retryable is False + _assert_diagnostics(info.value, error) + + def test_syntax_error_maps_to_query(self): + error = _driver_error( + "ProgrammingError", + "SQL compilation error: syntax error line 1 unexpected 'SELEC'.", + errno=100038, + sqlstate="42000", + ) + with pytest.raises(QueryError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + assert info.value.retryable is False + _assert_diagnostics(info.value, error) + + def test_unknown_column_maps_to_query(self): + error = _driver_error( + "DatabaseError", + "Invalid identifier 'NAME'", + errno=100038, + sqlstate="42000", + ) + with pytest.raises(QueryError): + SnowflakeBackend(connection_config=_config())._handle_error(error) + + def test_network_timeout_maps_to_transient_operational(self): + error = _driver_error( + "OperationalError", + "Connection timeout occurred while waiting for a response.", + errno=250002, + ) + with pytest.raises(BackendOperationalError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert mapped.transient is True + assert mapped.retryable is True + _assert_diagnostics(mapped, error) + + def test_service_unavailable_maps_to_transient_operational(self): + if not hasattr(driver_errors, "ServiceUnavailableError"): + pytest.skip("ServiceUnavailableError not available in this connector") + error = _driver_error("ServiceUnavailableError", "Service unavailable") + with pytest.raises(BackendOperationalError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + assert info.value.transient is True + assert info.value.retryable is True + + def test_login_failure_maps_to_non_transient_connection(self): + error = _driver_error( + "DatabaseError", + "Incorrect username or password was specified.", + errno=390100, + ) + with pytest.raises(BackendConnectionError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert mapped.transient is False + assert mapped.retryable is False + _assert_diagnostics(mapped, error) + + def test_permission_denial_maps_to_database_error(self): + error = _driver_error( + "ProgrammingError", + "User does not have permission to execute this statement.", + errno=390403, + sqlstate="42501", + ) + with pytest.raises(BackendDatabaseError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert not isinstance(mapped, QueryError) + assert not isinstance(mapped, DataError) + assert mapped.retryable is False + _assert_diagnostics(mapped, error) + + def test_generic_driver_error_maps_to_database_error(self): + error = _driver_error( + "DatabaseError", + "Some unexpected database failure", + errno=999999, + ) + with pytest.raises(BackendDatabaseError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert mapped.transient is None + assert mapped.retryable is False + _assert_diagnostics(mapped, error) + + def test_lock_and_deadlock_are_not_invented(self): + error = _driver_error( + "OperationalError", + "Deadlock detected while waiting for a lock", + errno=999999, + ) + with pytest.raises(BackendOperationalError) as info: + SnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert not isinstance(mapped, DeadlockError) + assert not isinstance(mapped, LockError) + assert mapped.retryable is False + + +class TestAsyncDriverErrorMapping: + """Asynchronous driver-error mapping (same cases as the sync class).""" + + async def test_duplicate_unique_maps_to_integrity(self): + error = _driver_error( + "ProgrammingError", + "Duplicate key value violates unique constraint", + errno=100132, + sqlstate="23000", + ) + with pytest.raises(IntegrityError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert isinstance(mapped, DataError) + assert mapped.retryable is False + assert mapped.transient is False + _assert_diagnostics(mapped, error) + assert mapped.__cause__ is error + + async def test_already_exists_maps_to_integrity(self): + error = _driver_error( + "DatabaseError", + "Object 'USERS' already exists.", + errno=100132, + sqlstate="23000", + ) + with pytest.raises(IntegrityError): + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + + async def test_not_null_maps_to_integrity(self): + error = _driver_error( + "ProgrammingError", + "NULL result in a non-nullable column", + errno=100072, + sqlstate="22004", + ) + with pytest.raises(IntegrityError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + assert isinstance(info.value, DataError) + assert info.value.retryable is False + _assert_diagnostics(info.value, error) + + async def test_syntax_error_maps_to_query(self): + error = _driver_error( + "ProgrammingError", + "SQL compilation error: syntax error line 1 unexpected 'SELEC'.", + errno=100038, + sqlstate="42000", + ) + with pytest.raises(QueryError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + assert info.value.retryable is False + _assert_diagnostics(info.value, error) + + async def test_unknown_column_maps_to_query(self): + error = _driver_error( + "DatabaseError", + "Invalid identifier 'NAME'", + errno=100038, + sqlstate="42000", + ) + with pytest.raises(QueryError): + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + + async def test_network_timeout_maps_to_transient_operational(self): + error = _driver_error( + "OperationalError", + "Connection timeout occurred while waiting for a response.", + errno=250002, + ) + with pytest.raises(BackendOperationalError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert mapped.transient is True + assert mapped.retryable is True + _assert_diagnostics(mapped, error) + + async def test_service_unavailable_maps_to_transient_operational(self): + if not hasattr(driver_errors, "ServiceUnavailableError"): + pytest.skip("ServiceUnavailableError not available in this connector") + error = _driver_error("ServiceUnavailableError", "Service unavailable") + with pytest.raises(BackendOperationalError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + assert info.value.transient is True + assert info.value.retryable is True + + async def test_login_failure_maps_to_non_transient_connection(self): + error = _driver_error( + "DatabaseError", + "Incorrect username or password was specified.", + errno=390100, + ) + with pytest.raises(BackendConnectionError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert mapped.transient is False + assert mapped.retryable is False + _assert_diagnostics(mapped, error) + + async def test_permission_denial_maps_to_database_error(self): + error = _driver_error( + "ProgrammingError", + "User does not have permission to execute this statement.", + errno=390403, + sqlstate="42501", + ) + with pytest.raises(BackendDatabaseError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert not isinstance(mapped, QueryError) + assert not isinstance(mapped, DataError) + assert mapped.retryable is False + _assert_diagnostics(mapped, error) + + async def test_generic_driver_error_maps_to_database_error(self): + error = _driver_error( + "DatabaseError", + "Some unexpected database failure", + errno=999999, + ) + with pytest.raises(BackendDatabaseError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert mapped.transient is None + assert mapped.retryable is False + _assert_diagnostics(mapped, error) + + async def test_lock_and_deadlock_are_not_invented(self): + error = _driver_error( + "OperationalError", + "Deadlock detected while waiting for a lock", + errno=999999, + ) + with pytest.raises(BackendOperationalError) as info: + await AsyncSnowflakeBackend(connection_config=_config())._handle_error(error) + mapped = info.value + assert not isinstance(mapped, DeadlockError) + assert not isinstance(mapped, LockError) + assert mapped.retryable is False diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_execute_error_contract.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_execute_error_contract.py new file mode 100644 index 0000000..992d54a --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_execute_error_contract.py @@ -0,0 +1,296 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/test_execute_error_contract.py +"""Execute-phase error contract for the Snowflake backend. + +These tests need no Snowflake account: backends are constructed without +connecting, and driver exceptions are built directly from +``snowflake.connector.errors``. The connector is optional, so the module skips +when it is not installed. + +The contract under test: + +* framework ``DatabaseError`` subclasses (``ConnectionError``, + ``TypeConversionError``, ``TransactionError``) pass through ``_handle_error`` + unchanged instead of being reclassified into a driver category; +* the ``_classify_error`` message fallback cannot catch a framework error; +* a transient driver timeout becomes ``OperationalError(transient=True)``; +* a non-transient authentication failure stays ``ConnectionError``; +* a commit failure is surfaced as ``TransactionError`` (Snowflake's commit path + is the transaction manager, not an execute auto-commit hook). +""" +import pytest + +pytest.importorskip("snowflake.connector") + +from snowflake.connector import errors as driver_errors # noqa: E402 + +from rhosocial.activerecord.backend.errors import ( # noqa: E402 + ConnectionError as BackendConnectionError, + OperationalError as BackendOperationalError, + QueryError, + TransactionError, + TypeConversionError, + attach_diagnostics, +) +from rhosocial.activerecord.backend.impl.snowflake.backend import SnowflakeBackend # noqa: E402 +from rhosocial.activerecord.backend.impl.snowflake.backend.async_backend import ( # noqa: E402 + AsyncSnowflakeBackend, +) +from rhosocial.activerecord.backend.impl.snowflake.config import ( # noqa: E402 + SnowflakeConnectionConfig, +) +from rhosocial.activerecord.backend.impl.snowflake.transaction import ( # noqa: E402 + SnowflakeTransactionManager, +) +from rhosocial.activerecord.backend.impl.snowflake.async_transaction import ( # noqa: E402 + AsyncSnowflakeTransactionManager, +) +from rhosocial.activerecord.backend.transaction import TransactionState # noqa: E402 + + +def _config() -> SnowflakeConnectionConfig: + """Build a config with dummy credentials; nothing connects.""" + return SnowflakeConnectionConfig( + account="unit-test-account", + username="unit-test-user", + password="unit-test-password", + database="unit-test-database", + ) + + +def _backend() -> SnowflakeBackend: + """Create an unconnected synchronous backend.""" + return SnowflakeBackend(connection_config=_config()) + + +def _async_backend() -> AsyncSnowflakeBackend: + """Create an unconnected asynchronous backend.""" + return AsyncSnowflakeBackend(connection_config=_config()) + + +def _driver_error(name, message, errno=None, sqlstate=None): + """Construct a driver exception, tolerating older connector signatures.""" + cls = getattr(driver_errors, name) + try: + return cls(msg=message, errno=errno, sqlstate=sqlstate) + except TypeError: + pass + try: + return cls(msg=message, errno=errno) + except TypeError: + return cls(msg=message) + + +class TestExecuteErrorContract: + """Synchronous execute-phase error contract.""" + + def test_framework_type_conversion_error_is_preserved(self): + """A framework TypeConversionError must not become DatabaseError.""" + error = TypeConversionError("Cannot convert parameter value") + backend = _backend() + with pytest.raises(TypeConversionError) as info: + backend._handle_error(error) + mapped = info.value + assert mapped is error + assert type(mapped) is TypeConversionError + assert not isinstance(mapped, BackendOperationalError) + assert not isinstance(mapped, BackendConnectionError) + + def test_framework_error_message_fallback_is_not_used(self): + """The driver-message fallback must leave framework errors alone.""" + error = QueryError("syntax error: connection refused by server") + backend = _backend() + assert backend._classify_error(error) == "framework" + assert backend._resolve_error_mapping(error, "framework") == ("framework", None) + with pytest.raises(QueryError) as info: + backend._handle_error(error) + assert info.value is error + + def test_framework_connection_error_with_transient_flag_is_preserved(self): + """A framework ConnectionError keeps its type, diagnostics and flags.""" + error = attach_diagnostics( + BackendConnectionError("Session is invalid"), + operation="execute", + backend="snowflake", + transient=True, + ) + backend = _backend() + with pytest.raises(BackendConnectionError) as info: + backend._handle_error(error) + mapped = info.value + assert mapped is error + assert mapped.transient is True + assert mapped.retryable is True + + def test_driver_timeout_maps_to_transient_operational_error(self): + """A network timeout is a retryable OperationalError.""" + error = _driver_error( + "OperationalError", + "Connection timeout occurred while waiting for a response.", + errno=250002, + ) + backend = _backend() + with pytest.raises(BackendOperationalError) as info: + backend._handle_error(error) + mapped = info.value + assert mapped.transient is True + assert mapped.retryable is True + assert mapped.backend == "snowflake" + assert mapped.__cause__ is error + + def test_auth_failure_stays_non_transient_connection_error(self): + """Bad credentials are a ConnectionError that retrying cannot fix.""" + error = _driver_error( + "DatabaseError", + "Incorrect username or password was specified.", + errno=390100, + ) + backend = _backend() + with pytest.raises(BackendConnectionError) as info: + backend._handle_error(error) + mapped = info.value + assert mapped.transient is False + assert mapped.retryable is False + assert mapped.backend == "snowflake" + + def test_lock_and_deadlock_are_not_invented(self): + """Snowflake execute has no lock/deadlock semantics to classify.""" + error = _driver_error( + "OperationalError", + "Deadlock detected while waiting for a lock", + errno=999999, + ) + backend = _backend() + with pytest.raises(BackendOperationalError): + backend._handle_error(error) + + def test_execute_has_no_auto_commit_hook(self): + """Snowflake inherits the no-op auto-commit hook from core.""" + backend = _backend() + assert backend._handle_auto_commit() is None + + def test_commit_failure_surfaces_as_transaction_error(self): + """A failing COMMIT must never be swallowed.""" + backend = _backend() + manager = SnowflakeTransactionManager(backend) + manager._transaction_level = 1 + manager._state = TransactionState.ACTIVE + + def fail_commit(): + raise RuntimeError("driver commit failed") + + manager._do_commit = fail_commit + with pytest.raises(TransactionError) as info: + manager.commit() + assert isinstance(info.value.__cause__, RuntimeError) + + +class TestAsyncExecuteErrorContract: + """Asynchronous execute-phase error contract (same cases as sync).""" + + @pytest.mark.asyncio + async def test_framework_type_conversion_error_is_preserved(self): + """A framework TypeConversionError must not become DatabaseError.""" + error = TypeConversionError("Cannot convert parameter value") + backend = _async_backend() + with pytest.raises(TypeConversionError) as info: + await backend._handle_error(error) + mapped = info.value + assert mapped is error + assert type(mapped) is TypeConversionError + assert not isinstance(mapped, BackendOperationalError) + assert not isinstance(mapped, BackendConnectionError) + + @pytest.mark.asyncio + async def test_framework_error_message_fallback_is_not_used(self): + """The driver-message fallback must leave framework errors alone.""" + error = QueryError("syntax error: connection refused by server") + backend = _async_backend() + assert backend._classify_error(error) == "framework" + assert backend._resolve_error_mapping(error, "framework") == ("framework", None) + with pytest.raises(QueryError) as info: + await backend._handle_error(error) + assert info.value is error + + @pytest.mark.asyncio + async def test_framework_connection_error_with_transient_flag_is_preserved(self): + """A framework ConnectionError keeps its type, diagnostics and flags.""" + error = attach_diagnostics( + BackendConnectionError("Session is invalid"), + operation="execute", + backend="snowflake", + transient=True, + ) + backend = _async_backend() + with pytest.raises(BackendConnectionError) as info: + await backend._handle_error(error) + mapped = info.value + assert mapped is error + assert mapped.transient is True + assert mapped.retryable is True + + @pytest.mark.asyncio + async def test_driver_timeout_maps_to_transient_operational_error(self): + """A network timeout is a retryable OperationalError.""" + error = _driver_error( + "OperationalError", + "Connection timeout occurred while waiting for a response.", + errno=250002, + ) + backend = _async_backend() + with pytest.raises(BackendOperationalError) as info: + await backend._handle_error(error) + mapped = info.value + assert mapped.transient is True + assert mapped.retryable is True + assert mapped.backend == "snowflake" + assert mapped.__cause__ is error + + @pytest.mark.asyncio + async def test_auth_failure_stays_non_transient_connection_error(self): + """Bad credentials are a ConnectionError that retrying cannot fix.""" + error = _driver_error( + "DatabaseError", + "Incorrect username or password was specified.", + errno=390100, + ) + backend = _async_backend() + with pytest.raises(BackendConnectionError) as info: + await backend._handle_error(error) + mapped = info.value + assert mapped.transient is False + assert mapped.retryable is False + assert mapped.backend == "snowflake" + + @pytest.mark.asyncio + async def test_lock_and_deadlock_are_not_invented(self): + """Snowflake execute has no lock/deadlock semantics to classify.""" + error = _driver_error( + "OperationalError", + "Deadlock detected while waiting for a lock", + errno=999999, + ) + backend = _async_backend() + with pytest.raises(BackendOperationalError): + await backend._handle_error(error) + + @pytest.mark.asyncio + async def test_execute_has_no_auto_commit_hook(self): + """Snowflake inherits the no-op async auto-commit hook from core.""" + backend = _async_backend() + assert await backend._handle_auto_commit() is None + + @pytest.mark.asyncio + async def test_commit_failure_surfaces_as_transaction_error(self): + """A failing COMMIT must never be swallowed.""" + backend = _async_backend() + manager = AsyncSnowflakeTransactionManager(backend) + manager._transaction_level = 1 + manager._state = TransactionState.ACTIVE + + async def fail_commit(): + raise RuntimeError("driver commit failed") + + manager._do_commit = fail_commit + with pytest.raises(TransactionError) as info: + await manager.commit() + assert isinstance(info.value.__cause__, RuntimeError)