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
15 changes: 13 additions & 2 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@ important operational fixes.
Recent Updates
==============

Unreleased - Compiled async exception handling
------------------------------------------------------------------------------
Unreleased
-------------------------------------------------------------------------------

**Changed:**

* Explicit database cancellation now raises
:class:`~sqlspec.exceptions.OperationCancelledError`, while elapsed timeouts
and deadlines continue to raise
:class:`~sqlspec.exceptions.QueryTimeoutError`. The two exceptions are
siblings under :class:`~sqlspec.exceptions.OperationalError`. Applications
that used ``QueryTimeoutError`` for both outcomes should catch both exception
types, or catch ``OperationalError`` when they do not need to distinguish
cancellation from timeout.

**Fixed:**

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/adapters/adbc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ SQLSpec does not define a portable SQL statistics contract. It wraps
``adbc_get_statistics`` directly; unsupported drivers raise
:exc:`sqlspec.exceptions.OperationalError`.

In the replacement data dictionary, ADBC statistics are also exposed through the
In the data dictionary, ADBC statistics are also exposed through the
opt-in system metadata namespace as transport metadata. This does not make ADBC
a lossless DDL or dependency source; dialect query packs remain canonical for
DDL-grade metadata.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/driver.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Asynchronous Driver
Data Dictionary
===============

The shared data dictionary base classes define the replacement metadata
The shared data dictionary base classes define the metadata
contract used by adapter-local dictionaries. User-facing examples and the
support matrix live in :doc:`../usage/data_dictionary`. In short:

Expand Down
13 changes: 13 additions & 0 deletions docs/reference/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ Execution
:members:
:show-inheritance:

.. autoclass:: OperationCancelledError
:members:
:show-inheritance:

``OperationCancelledError`` and ``QueryTimeoutError`` are sibling operational
errors. Catch ``OperationCancelledError`` for explicit caller or operator
cancellation, and ``QueryTimeoutError`` for elapsed statement timeouts and
deadlines. ADBC ``CANCELLED`` and ``TIMEOUT`` statuses follow this distinction.
Callers that previously caught ``QueryTimeoutError`` for cancellation should
catch both exceptions during migration, or catch ``OperationalError`` when the
distinction is not relevant.

.. autoclass:: DataError
:members:
:show-inheritance:
Expand Down Expand Up @@ -223,6 +235,7 @@ Inheritance Tree
+-- DataError
+-- OperationalError
| +-- QueryTimeoutError
| +-- OperationCancelledError
+-- StackExecutionError
+-- StorageOperationFailedError
| +-- FileNotFoundInStorageError
Expand Down
39 changes: 34 additions & 5 deletions sqlspec/adapters/adbc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@
ImproperConfigurationError,
IntegrityError,
NotNullViolationError,
OperationalError,
OperationCancelledError,
PermissionDeniedError,
QueryTimeoutError,
SerializationConflictError,
SQLParsingError,
SQLSpecError,
UniqueViolationError,
_classify_timeout_or_cancellation,
map_sqlstate_to_exception,
)
from sqlspec.typing import PGVECTOR_INSTALLED, Empty
Expand Down Expand Up @@ -538,6 +541,13 @@ def prepare_postgres_parameters(
) -> Any:
"""Prepare Postgres parameters with cast-aware coercion."""
postgres_compatible = normalize_postgres_empty_parameters(dialect, parameters)
converter = get_adbc_type_converter(dialect)
if isinstance(postgres_compatible, (list, tuple)) and hasattr(converter, "convert_sequence"):
seq = [
converter.convert_sequence(item) if isinstance(item, (list, tuple)) else item
for item in postgres_compatible
]
postgres_compatible = tuple(seq) if isinstance(postgres_compatible, tuple) else seq
if not parameter_casts:
return postgres_compatible
return prepare_parameters_with_casts(
Expand Down Expand Up @@ -565,6 +575,12 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec
A SQLSpec exception that wraps the original error
"""
del logger
status_code = getattr(error, "status_code", None)
status_name = getattr(status_code, "name", str(status_code)).upper()
if status_name == "TIMEOUT":
return _create_adbc_error(error, QueryTimeoutError, "query timeout")
if status_name == "CANCELLED":
return _create_adbc_error(error, OperationCancelledError, "operation cancelled")
sqlstate_attr = error.sqlstate if has_sqlstate(error) else None
sqlstate = sqlstate_attr if sqlstate_attr is not None else None

Expand All @@ -585,9 +601,9 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec
if sqlstate == "40001":
return _create_adbc_error(error, SerializationConflictError, "serialization failure")

# Query timeout/cancellation
if sqlstate == "57014":
return _create_adbc_error(error, QueryTimeoutError, "query canceled")
termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError
return _create_adbc_error(error, termination_class, "query terminated")

# Permission errors
if sqlstate == "42501":
Expand Down Expand Up @@ -625,9 +641,8 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec
if "serialization" in error_msg or "concurrent update" in error_msg:
return _create_adbc_error(error, DeadlockError, "serialization failure")

# Timeout/cancellation patterns
if "timeout" in error_msg or "cancel" in error_msg or "interrupt" in error_msg:
return _create_adbc_error(error, QueryTimeoutError, "query timeout")
if message_class := _classify_timeout_or_cancellation(error_msg):
return _create_adbc_error(error, message_class, "query terminated")

# Permission patterns
if "permission" in error_msg or "denied" in error_msg or "unauthorized" in error_msg:
Expand Down Expand Up @@ -1226,6 +1241,20 @@ def _prepare_parameter_sequence_with_casts(
result.append(param)
elif isinstance(param, dict):
result.append(converter.convert_dict(param))
elif isinstance(param, (list, tuple)):
if type_map and dispatcher is not None:
exact_converter = type_map.get(type(param))
if exact_converter is not None:
param = exact_converter(param)
else:
converter_func = dispatcher.get(param)
if converter_func is not None:
param = converter_func(param)
elif hasattr(converter, "convert_sequence"):
param = converter.convert_sequence(param)
elif hasattr(converter, "convert_sequence"):
param = converter.convert_sequence(param)
result.append(param)
else:
if type_map and dispatcher is not None:
exact_converter = type_map.get(type(param))
Expand Down
17 changes: 17 additions & 0 deletions sqlspec/adapters/adbc/type_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ def convert_dict(self, value: "dict[str, Any]") -> Any:
return to_json(value)
return value

def convert_sequence(self, value: "list[Any] | tuple[Any, ...]") -> "list[Any]":
"""Convert sequence/array parameter values with dialect awareness.

Preserves None elements within sequence parameters instead of converting
them to empty strings for PostgreSQL-family dialects.

Args:
value: Sequence to convert.

Returns:
Converted list parameter appropriate for the dialect.
"""
items = list(value)
if self.dialect in {"postgres", "postgresql", "pgvector", "paradedb"}:
return [item if item is not None else None for item in items]
return items


def get_adbc_type_converter(dialect: str) -> ADBCOutputConverter:
"""Factory function to create dialect-specific ADBC type converter.
Expand Down
2 changes: 1 addition & 1 deletion sqlspec/adapters/aiomysql/data_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ async def get_feature_flag(self, driver: "AiomysqlDriver", feature: str) -> bool
async def get_metadata_capabilities(
self, driver: "AiomysqlDriver", domains: "Sequence[str] | None" = None
) -> "MetadataCapabilityProfile":
"""Get replacement data-dictionary capability profile."""
"""Get data-dictionary capability profile."""
engine_version = await self._get_engine_version(driver)
dialect = engine_version.engine_family if engine_version is not None else type(self).dialect
requested_domains = None if domains is None else tuple(domains)
Expand Down
6 changes: 3 additions & 3 deletions sqlspec/adapters/aiosqlite/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
IntegrityError,
NotNullViolationError,
OperationalError,
OperationCancelledError,
PermissionDeniedError,
QueryTimeoutError,
SQLParsingError,
SQLSpecError,
UniqueViolationError,
Expand Down Expand Up @@ -360,9 +360,9 @@ def create_mapped_exception(error: BaseException, *, logger: Any | None = None)

# Query interruption (timeout-like behavior)
if error_code == SQLITE_INTERRUPT_CODE or error_name == "SQLITE_INTERRUPT":
return _create_aiosqlite_error(error, error_code, QueryTimeoutError, "query interrupted")
return _create_aiosqlite_error(error, error_code, OperationCancelledError, "query interrupted")
if "interrupt" in error_msg:
return _create_aiosqlite_error(error, error_code or 0, QueryTimeoutError, "query interrupted")
return _create_aiosqlite_error(error, error_code or 0, OperationCancelledError, "query interrupted")

# Permission errors
if error_code == SQLITE_PERM_CODE or error_name == "SQLITE_PERM":
Expand Down
2 changes: 1 addition & 1 deletion sqlspec/adapters/asyncmy/data_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async def get_feature_flag(self, driver: "AsyncmyDriver", feature: str) -> bool:
async def get_metadata_capabilities(
self, driver: "AsyncmyDriver", domains: "Sequence[str] | None" = None
) -> "MetadataCapabilityProfile":
"""Get replacement data-dictionary capability profile."""
"""Get data-dictionary capability profile."""
engine_version = await self._get_engine_version(driver)
dialect = engine_version.engine_family if engine_version is not None else type(self).dialect
requested_domains = None if domains is None else tuple(domains)
Expand Down
12 changes: 10 additions & 2 deletions sqlspec/adapters/asyncpg/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
ForeignKeyViolationError,
IntegrityError,
NotNullViolationError,
OperationalError,
OperationCancelledError,
PermissionDeniedError,
QueryTimeoutError,
SerializationConflictError,
SQLParsingError,
SQLSpecError,
UniqueViolationError,
_classify_timeout_or_cancellation,
map_sqlstate_to_exception,
)
from sqlspec.typing import PGVECTOR_INSTALLED
Expand Down Expand Up @@ -302,7 +304,7 @@ def resolve_many_rowcount(parameter_sets: Any, *, fallback_count: "int | None" =
asyncpg.exceptions.SerializationError, ("40001", SerializationConflictError, "serialization failure")
)
_EXCEPTION_MAPPING_DISPATCHER.register(
asyncpg.exceptions.QueryCanceledError, ("57014", QueryTimeoutError, "query canceled")
asyncpg.exceptions.QueryCanceledError, ("57014", OperationCancelledError, "query canceled")
)
_EXCEPTION_MAPPING_DISPATCHER.register(
asyncpg.exceptions.InsufficientPrivilegeError, ("42501", PermissionDeniedError, "insufficient privilege")
Expand Down Expand Up @@ -347,12 +349,18 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec
mapped_error = _EXCEPTION_MAPPING_DISPATCHER.get(error)
if mapped_error is not None:
error_code, error_class, description = mapped_error
if error_code == "57014":
termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError
return _create_postgres_error(error, error_code, termination_class, description)
return _create_postgres_error(error, error_code, error_class, description)

# Priority 2: Fall back to SQLSTATE code mapping using centralized utility
sqlstate_attr = error.sqlstate if has_sqlstate(error) else None
sqlstate_code: str | None = sqlstate_attr if sqlstate_attr is not None else None
if sqlstate_code:
if sqlstate_code == "57014":
termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError
return _create_postgres_error(error, sqlstate_code, termination_class, "query terminated")
exc_class = map_sqlstate_to_exception(sqlstate_code)
if exc_class:
return _create_postgres_error(error, sqlstate_code, exc_class, "database error")
Expand Down
2 changes: 1 addition & 1 deletion sqlspec/adapters/asyncpg/data_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ class AsyncpgDataDictionary(AsyncDataDictionaryBase):
async def get_metadata_capabilities(
self, driver: "AsyncpgDriver", domains: Sequence[str] | None = None
) -> MetadataCapabilityProfile:
"""Get PostgreSQL replacement data-dictionary capability profile."""
"""Get PostgreSQL data-dictionary capability profile."""
return _postgres_metadata_profile(type(self).__name__, domains)

async def get_system_metadata_capabilities(
Expand Down
13 changes: 9 additions & 4 deletions sqlspec/adapters/bigquery/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from urllib.parse import urlparse

import sqlglot
from google.api_core import exceptions as api_exceptions
from sqlglot import exp

from sqlspec.core import (
Expand All @@ -23,13 +24,14 @@
DataError,
NotFoundError,
OperationalError,
OperationCancelledError,
PermissionDeniedError,
QueryTimeoutError,
SQLParsingError,
SQLSpecError,
StorageCapabilityError,
StorageOperationFailedError,
UniqueViolationError,
_classify_timeout_or_cancellation,
)
from sqlspec.utils.logging import get_logger
from sqlspec.utils.serializers import to_json
Expand Down Expand Up @@ -688,7 +690,7 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec
Mapped Statuses:
* UniqueViolationError: HTTP 409 (Conflict) or "already exists" in message
* NotFoundError: HTTP 404 (Not Found) or "not found" in message
* QueryTimeoutError: "timeout", "deadline exceeded", or "cancelled" in message
* QueryTimeoutError or OperationCancelledError for terminated queries
* SQLParsingError / DataError / SQLSpecError: HTTP 400 (Bad Request)
* PermissionDeniedError: HTTP 403 (Forbidden) or "access denied" / "permission denied" in message
* OperationalError: HTTP 500+ (Server error)
Expand All @@ -713,8 +715,11 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec
if status_code == HTTP_NOT_FOUND or "not found" in error_msg:
return _create_bigquery_error(error, status_code, NotFoundError, "resource not found")

if "timeout" in error_msg or "deadline exceeded" in error_msg or "cancelled" in error_msg:
return _create_bigquery_error(error, status_code, QueryTimeoutError, "query timeout or cancelled")
if isinstance(error, api_exceptions.Cancelled):
return _create_bigquery_error(error, status_code, OperationCancelledError, "query cancelled")

if error_class := _classify_timeout_or_cancellation(error_msg):
return _create_bigquery_error(error, status_code, error_class, "query terminated")

if status_code == HTTP_BAD_REQUEST:
if "syntax" in error_msg or "invalid query" in error_msg:
Expand Down
2 changes: 1 addition & 1 deletion sqlspec/adapters/bigquery/data_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ def get_foreign_keys(
def get_metadata_capabilities(
self, driver: Any, domains: "Sequence[str] | None" = None
) -> "MetadataCapabilityProfile":
"""Get BigQuery replacement data-dictionary capability profile."""
"""Get BigQuery data-dictionary capability profile."""
_ = driver
requested_domains = tuple(domains) if domains is not None else _DEFAULT_METADATA_DOMAINS
capabilities = tuple(_bigquery_capability_for_domain(domain) for domain in requested_domains)
Expand Down
2 changes: 1 addition & 1 deletion sqlspec/adapters/cockroach_asyncpg/data_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ class CockroachAsyncpgDataDictionary(AsyncDataDictionaryBase):
async def get_metadata_capabilities(
self, driver: "CockroachAsyncpgDriver", domains: Sequence[str] | None = None
) -> MetadataCapabilityProfile:
"""Get CockroachDB replacement data-dictionary capability profile."""
"""Get CockroachDB data-dictionary capability profile."""
return _cockroach_metadata_profile(type(self).__name__, domains)

async def get_system_metadata_capabilities(
Expand Down
4 changes: 2 additions & 2 deletions sqlspec/adapters/cockroach_psycopg/data_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def __init__(self) -> None:
def get_metadata_capabilities(
self, driver: "CockroachPsycopgSyncDriver", domains: Sequence[str] | None = None
) -> MetadataCapabilityProfile:
"""Get CockroachDB replacement data-dictionary capability profile."""
"""Get CockroachDB data-dictionary capability profile."""
return _cockroach_metadata_profile(type(self).__name__, domains)

def get_system_metadata_capabilities(
Expand Down Expand Up @@ -401,7 +401,7 @@ def __init__(self) -> None:
async def get_metadata_capabilities(
self, driver: "CockroachPsycopgAsyncDriver", domains: Sequence[str] | None = None
) -> MetadataCapabilityProfile:
"""Get CockroachDB replacement data-dictionary capability profile."""
"""Get CockroachDB data-dictionary capability profile."""
return _cockroach_metadata_profile(type(self).__name__, domains)

async def get_system_metadata_capabilities(
Expand Down
8 changes: 4 additions & 4 deletions sqlspec/adapters/duckdb/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
NotFoundError,
NotNullViolationError,
OperationalError,
OperationCancelledError,
PermissionDeniedError,
QueryTimeoutError,
SQLParsingError,
SQLSpecError,
UniqueViolationError,
Expand Down Expand Up @@ -229,7 +229,7 @@ def _register_duckdb_exception_mappings() -> None:
("ParserException", (SQLParsingError, "SQL parsing error")),
("BinderException", (SQLParsingError, "SQL parsing error")),
("PermissionException", (PermissionDeniedError, "permission denied")),
("InterruptException", (QueryTimeoutError, "query interrupted")),
("InterruptException", (OperationCancelledError, "query interrupted")),
("IOException", (OperationalError, "operational error")),
("ConversionException", (DataError, "data error")),
)
Expand Down Expand Up @@ -312,7 +312,7 @@ def create_mapped_exception(error: "BaseException", *, logger: Any | None = None
if "permissionexception" in exc_name:
return _create_duckdb_error(error, PermissionDeniedError, "permission denied")
if "interruptexception" in exc_name:
return _create_duckdb_error(error, QueryTimeoutError, "query interrupted")
return _create_duckdb_error(error, OperationCancelledError, "query interrupted")
if "ioexception" in exc_name:
return _create_duckdb_error(error, OperationalError, "operational error")
if "conversionexception" in exc_name:
Expand All @@ -322,7 +322,7 @@ def create_mapped_exception(error: "BaseException", *, logger: Any | None = None
if "permission denied" in error_msg or "access denied" in error_msg:
return _create_duckdb_error(error, PermissionDeniedError, "permission denied")
if "interrupt" in error_msg or "cancel" in error_msg:
return _create_duckdb_error(error, QueryTimeoutError, "query canceled")
return _create_duckdb_error(error, OperationCancelledError, "query canceled")
if "type mismatch" in error_msg:
return _create_duckdb_error(error, DataError, "data error")

Expand Down
Loading
Loading