Skip to content

Commit e14094e

Browse files
authored
feat: more granular exception mapping (#330)
- Introduced new exception classes: PermissionDeniedError, ConnectionTimeoutError, DeadlockError, and QueryTimeoutError to better categorize specific error scenarios. - Updated create_mapped_exception functions in various adapters (BigQuery, DuckDB, MySQL, OracleDB, PostgreSQL, PyMySQL, Spanner, SQLite) to utilize the new exception classes for improved error handling. - Enhanced error mapping logic to prioritize specific error codes and messages, ensuring more accurate exception classification. - Added SQLSTATE to exception mapping for better database-agnostic error translation. - Improved documentation for new exceptions and their use cases.
1 parent 1b0bf33 commit e14094e

15 files changed

Lines changed: 687 additions & 127 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }]
1313
name = "sqlspec"
1414
readme = "README.md"
1515
requires-python = ">=3.10, <4.0"
16-
version = "0.37.1"
16+
version = "0.38.0"
1717

1818
[project.urls]
1919
Discord = "https://discord.gg/litestar"
@@ -182,6 +182,7 @@ exclude = [
182182
include = [
183183
"sqlspec/core/**/*.py", # Core module
184184
"sqlspec/builder/**/*.py", # Builder module
185+
"sqlspec/exceptions.py", # Exceptions module
185186
"sqlspec/loader.py", # Loader module
186187
"sqlspec/observability/**/*.py", # Observability utilities
187188
"sqlspec/driver/**/*.py", # Driver module
@@ -222,7 +223,7 @@ opt_level = "3" # Maximum optimization (0-3)
222223
allow_dirty = true
223224
commit = false
224225
commit_args = "--no-verify"
225-
current_version = "0.37.1"
226+
current_version = "0.38.0"
226227
ignore_missing_files = false
227228
ignore_missing_version = false
228229
message = "chore(release): bump to v{new_version}"

sqlspec/adapters/adbc/core.py

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@
1515
from sqlspec.exceptions import (
1616
CheckViolationError,
1717
DatabaseConnectionError,
18-
DataError,
18+
DeadlockError,
1919
ForeignKeyViolationError,
2020
ImproperConfigurationError,
2121
IntegrityError,
2222
NotNullViolationError,
23+
PermissionDeniedError,
24+
QueryTimeoutError,
2325
SQLParsingError,
2426
SQLSpecError,
25-
TransactionError,
2627
UniqueViolationError,
28+
map_sqlstate_to_exception,
2729
)
2830
from sqlspec.typing import Empty
2931
from sqlspec.utils.module_loader import import_string
@@ -424,15 +426,22 @@ def create_mapped_exception(error: Any) -> SQLSpecError:
424426
raising. This pattern is more robust for use in __exit__ handlers and
425427
avoids issues with exception control flow in different Python versions.
426428
429+
Mapping priority:
430+
1. SQLSTATE codes (most reliable for ADBC drivers)
431+
2. Error message patterns
432+
3. Default SQLSpecError fallback
433+
427434
Args:
428435
error: The ADBC exception to map
429436
430437
Returns:
431438
A SQLSpec exception that wraps the original error
432439
"""
433-
sqlstate = error.sqlstate if has_sqlstate(error) and error.sqlstate is not None else None
440+
sqlstate_attr = error.sqlstate if has_sqlstate(error) else None
441+
sqlstate = sqlstate_attr if sqlstate_attr is not None else None
434442

435443
if sqlstate:
444+
# Use centralized SQLSTATE mapping for specific codes
436445
if sqlstate == "23505":
437446
return _create_adbc_error(error, UniqueViolationError, "unique constraint violation")
438447
if sqlstate == "23503":
@@ -441,20 +450,36 @@ def create_mapped_exception(error: Any) -> SQLSpecError:
441450
return _create_adbc_error(error, NotNullViolationError, "not-null constraint violation")
442451
if sqlstate == "23514":
443452
return _create_adbc_error(error, CheckViolationError, "check constraint violation")
444-
if sqlstate.startswith("23"):
445-
return _create_adbc_error(error, IntegrityError, "integrity constraint violation")
446-
if sqlstate.startswith("42"):
447-
return _create_adbc_error(error, SQLParsingError, "SQL parsing error")
448-
if sqlstate.startswith("08"):
449-
return _create_adbc_error(error, DatabaseConnectionError, "connection error")
450-
if sqlstate.startswith("40"):
451-
return _create_adbc_error(error, TransactionError, "transaction error")
452-
if sqlstate.startswith("22"):
453-
return _create_adbc_error(error, DataError, "data error")
453+
454+
# Deadlock and serialization errors
455+
if sqlstate == "40P01":
456+
return _create_adbc_error(error, DeadlockError, "deadlock detected")
457+
if sqlstate == "40001":
458+
return _create_adbc_error(error, DeadlockError, "serialization failure")
459+
460+
# Query timeout/cancellation
461+
if sqlstate == "57014":
462+
return _create_adbc_error(error, QueryTimeoutError, "query canceled")
463+
464+
# Permission errors
465+
if sqlstate == "42501":
466+
return _create_adbc_error(error, PermissionDeniedError, "insufficient privilege")
467+
if sqlstate == "28000":
468+
return _create_adbc_error(error, PermissionDeniedError, "invalid authorization")
469+
470+
# Use centralized mapping for SQLSTATE class prefixes
471+
exc_class = map_sqlstate_to_exception(sqlstate)
472+
if exc_class is not None and exc_class is not SQLSpecError:
473+
description = _get_sqlstate_description(sqlstate)
474+
return _create_adbc_error(error, exc_class, description)
475+
476+
# Fallback for unmapped SQLSTATE codes
454477
return _create_adbc_error(error, SQLSpecError, "database error")
455478

479+
# Message-based fallback when no SQLSTATE is available
456480
error_msg = str(error).lower()
457481

482+
# Constraint violations
458483
if "unique" in error_msg or "duplicate" in error_msg:
459484
return _create_adbc_error(error, UniqueViolationError, "unique constraint violation")
460485
if "foreign key" in error_msg:
@@ -465,13 +490,53 @@ def create_mapped_exception(error: Any) -> SQLSpecError:
465490
return _create_adbc_error(error, CheckViolationError, "check constraint violation")
466491
if "constraint" in error_msg:
467492
return _create_adbc_error(error, IntegrityError, "integrity constraint violation")
493+
494+
# Deadlock/lock patterns
495+
if "deadlock" in error_msg:
496+
return _create_adbc_error(error, DeadlockError, "deadlock detected")
497+
if "serialization" in error_msg or "concurrent update" in error_msg:
498+
return _create_adbc_error(error, DeadlockError, "serialization failure")
499+
500+
# Timeout/cancellation patterns
501+
if "timeout" in error_msg or "cancel" in error_msg or "interrupt" in error_msg:
502+
return _create_adbc_error(error, QueryTimeoutError, "query timeout")
503+
504+
# Permission patterns
505+
if "permission" in error_msg or "denied" in error_msg or "unauthorized" in error_msg:
506+
return _create_adbc_error(error, PermissionDeniedError, "permission denied")
507+
508+
# Syntax errors
468509
if "syntax" in error_msg:
469510
return _create_adbc_error(error, SQLParsingError, "SQL parsing error")
511+
512+
# Connection errors
470513
if "connection" in error_msg or "connect" in error_msg:
471514
return _create_adbc_error(error, DatabaseConnectionError, "connection error")
515+
472516
return _create_adbc_error(error, SQLSpecError, "database error")
473517

474518

519+
_SQLSTATE_CLASS_CODE_LEN = 2
520+
521+
# Module-level SQLSTATE descriptions (mypyc optimization - avoid dict creation per call)
522+
_SQLSTATE_DESCRIPTIONS: dict[str, str] = {
523+
"23": "integrity constraint violation",
524+
"40": "transaction error",
525+
"42": "SQL syntax error",
526+
"08": "connection error",
527+
"22": "data error",
528+
"28": "authorization error",
529+
"57": "operational error",
530+
"02": "no data",
531+
}
532+
533+
534+
def _get_sqlstate_description(sqlstate: str) -> str:
535+
"""Get a human-readable description for a SQLSTATE code class."""
536+
class_code = sqlstate[:_SQLSTATE_CLASS_CODE_LEN] if len(sqlstate) >= _SQLSTATE_CLASS_CODE_LEN else sqlstate
537+
return _SQLSTATE_DESCRIPTIONS.get(class_code, "database error")
538+
539+
475540
def _identity(value: Any) -> Any:
476541
return value
477542

sqlspec/adapters/asyncmy/core.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
from sqlspec.core import DriverParameterProfile, ParameterStyle, StatementConfig, build_statement_config_from_profile
66
from sqlspec.exceptions import (
77
CheckViolationError,
8+
ConnectionTimeoutError,
89
DatabaseConnectionError,
910
DataError,
11+
DeadlockError,
1012
ForeignKeyViolationError,
1113
IntegrityError,
1214
NotNullViolationError,
15+
PermissionDeniedError,
16+
QueryTimeoutError,
1317
SQLParsingError,
1418
SQLSpecError,
1519
TransactionError,
@@ -38,10 +42,27 @@
3842
"resolve_rowcount",
3943
)
4044

45+
# MySQL error codes for constraint violations
4146
MYSQL_ER_DUP_ENTRY = 1062
4247
MYSQL_ER_NO_DEFAULT_FOR_FIELD = 1364
4348
MYSQL_ER_CHECK_CONSTRAINT_VIOLATED = 3819
4449

50+
# MySQL error codes for permission/access errors
51+
MYSQL_ER_DBACCESS_DENIED = 1044
52+
MYSQL_ER_ACCESS_DENIED = 1045
53+
MYSQL_ER_TABLEACCESS_DENIED = 1142
54+
55+
# MySQL error codes for transaction errors
56+
MYSQL_ER_LOCK_WAIT_TIMEOUT = 1205
57+
MYSQL_ER_LOCK_DEADLOCK = 1213
58+
59+
# MySQL error codes for connection errors
60+
MYSQL_CR_CONNECTION_ERROR = 2002
61+
MYSQL_CR_CONN_HOST_ERROR = 2003
62+
MYSQL_CR_UNKNOWN_HOST = 2005
63+
MYSQL_CR_SERVER_GONE_ERROR = 2006
64+
MYSQL_CR_SERVER_LOST = 2013
65+
4566

4667
def _bool_to_int(value: bool) -> int:
4768
return int(value)
@@ -171,6 +192,12 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> "SQLSpe
171192
raising. This pattern is more robust for use in __exit__ handlers and
172193
avoids issues with exception control flow in different Python versions.
173194
195+
Mapping priority:
196+
1. Specific error codes (most reliable for MySQL)
197+
2. SQLSTATE codes (where available)
198+
3. Generic error code ranges
199+
4. Default SQLSpecError fallback
200+
174201
Args:
175202
error: The AsyncMy exception to map
176203
logger: Optional logger for migration warnings
@@ -179,13 +206,16 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> "SQLSpe
179206
True to suppress expected migration errors, or a SQLSpec exception
180207
"""
181208
error_code = error.args[0] if len(error.args) >= 1 and isinstance(error.args[0], int) else None
182-
sqlstate = error.sqlstate if has_sqlstate(error) and error.sqlstate is not None else None
209+
sqlstate_attr = error.sqlstate if has_sqlstate(error) else None
210+
sqlstate = sqlstate_attr if sqlstate_attr is not None else None
183211

212+
# Migration-specific errors to suppress
184213
if error_code in {1061, 1091}:
185214
if logger is not None:
186215
logger.warning("AsyncMy MySQL expected migration error (ignoring): %s", error)
187216
return True
188217

218+
# Integrity constraint violations
189219
if sqlstate == "23505" or error_code == MYSQL_ER_DUP_ENTRY:
190220
return _create_mysql_error(error, sqlstate, error_code, UniqueViolationError, "unique constraint violation")
191221
if sqlstate == "23503" or error_code in {1216, 1217, 1451, 1452}:
@@ -198,20 +228,44 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> "SQLSpe
198228
return _create_mysql_error(error, sqlstate, error_code, CheckViolationError, "check constraint violation")
199229
if sqlstate and sqlstate.startswith("23"):
200230
return _create_mysql_error(error, sqlstate, error_code, IntegrityError, "integrity constraint violation")
231+
232+
# Permission/access errors (check specific codes first)
233+
if error_code in {MYSQL_ER_DBACCESS_DENIED, MYSQL_ER_ACCESS_DENIED, MYSQL_ER_TABLEACCESS_DENIED}:
234+
return _create_mysql_error(error, sqlstate, error_code, PermissionDeniedError, "access denied")
235+
if sqlstate and sqlstate.startswith("28"):
236+
return _create_mysql_error(error, sqlstate, error_code, PermissionDeniedError, "authorization error")
237+
238+
# Transaction errors (deadlock vs lock wait timeout)
239+
if error_code == MYSQL_ER_LOCK_DEADLOCK:
240+
return _create_mysql_error(error, sqlstate, error_code, DeadlockError, "deadlock detected")
241+
if error_code == MYSQL_ER_LOCK_WAIT_TIMEOUT:
242+
return _create_mysql_error(error, sqlstate, error_code, QueryTimeoutError, "lock wait timeout")
243+
if sqlstate and sqlstate.startswith("40"):
244+
return _create_mysql_error(error, sqlstate, error_code, TransactionError, "transaction error")
245+
246+
# SQL syntax errors
201247
if sqlstate and sqlstate.startswith("42"):
202248
return _create_mysql_error(error, sqlstate, error_code, SQLParsingError, "SQL syntax error")
249+
if error_code in range(1064, 1100):
250+
return _create_mysql_error(error, sqlstate, error_code, SQLParsingError, "SQL syntax error")
251+
252+
# Connection errors
203253
if sqlstate and sqlstate.startswith("08"):
204254
return _create_mysql_error(error, sqlstate, error_code, DatabaseConnectionError, "connection error")
205-
if sqlstate and sqlstate.startswith("40"):
206-
return _create_mysql_error(error, sqlstate, error_code, TransactionError, "transaction error")
255+
if error_code == MYSQL_CR_SERVER_LOST:
256+
return _create_mysql_error(error, sqlstate, error_code, ConnectionTimeoutError, "connection lost")
257+
if error_code in {
258+
MYSQL_CR_CONNECTION_ERROR,
259+
MYSQL_CR_CONN_HOST_ERROR,
260+
MYSQL_CR_UNKNOWN_HOST,
261+
MYSQL_CR_SERVER_GONE_ERROR,
262+
}:
263+
return _create_mysql_error(error, sqlstate, error_code, DatabaseConnectionError, "connection error")
264+
265+
# Data errors
207266
if sqlstate and sqlstate.startswith("22"):
208267
return _create_mysql_error(error, sqlstate, error_code, DataError, "data error")
209-
if error_code in {2002, 2003, 2005, 2006, 2013}:
210-
return _create_mysql_error(error, sqlstate, error_code, DatabaseConnectionError, "connection error")
211-
if error_code in {1205, 1213}:
212-
return _create_mysql_error(error, sqlstate, error_code, TransactionError, "transaction error")
213-
if error_code in range(1064, 1100):
214-
return _create_mysql_error(error, sqlstate, error_code, SQLParsingError, "SQL syntax error")
268+
215269
return _create_mysql_error(error, sqlstate, error_code, SQLSpecError, "database error")
216270

217271

0 commit comments

Comments
 (0)