From 3685f755f1df35c7070d052424360ddd8d885283 Mon Sep 17 00:00:00 2001 From: Matthew Coulter <53892067+mattcoulter7@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:17:25 +1100 Subject: [PATCH 1/4] feat: initial commit --- pyproject.toml | 7 +- .../__init__.py | 3 + .../dialects/__init__.py | 18 +++ .../dialects/base.py | 46 ++++++ .../dialects/postgres.py | 85 ++++++++++ .../fastapi_integration.py | 30 ++++ .../handlers.py | 153 ++++++++++++++++++ 7 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/base.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/postgres.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/fastapi_integration.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/handlers.py diff --git a/pyproject.toml b/pyproject.toml index abdd419..e727fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,12 @@ dev = [ [project] authors = [{email = "mattcoul7@gmail.com", name = "Matt Coulter"}] -dependencies = [] +dependencies = [ + "sqlalchemy>=2.0.0", + "fastapi>=0.115.0,<0.116", + "pydantic>=2.12.0", + "ab-database>=0.2.2", +] description = "A template package template." name = "ab-sqlalchemy-fastapi-http-exceptions" readme = "README.md" diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/__init__.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/__init__.py index e69de29..2667567 100644 --- a/src/ab_core/sqlalchemy_fastapi_http_exceptions/__init__.py +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/__init__.py @@ -0,0 +1,3 @@ +from .fastapi_integration import register_database_exception_handlers + +__all__ = ["register_database_exception_handlers"] diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py new file mode 100644 index 0000000..6e69bd3 --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py @@ -0,0 +1,18 @@ +from .base import DialectExceptionMapper, GenericExceptionMapper +from .postgres import PostgresExceptionMapper + +# Immutable registry +_DIALECTS: tuple[DialectExceptionMapper, ...] = ( + PostgresExceptionMapper(), + GenericExceptionMapper(), # keep last as fallback +) + + +def get_mapper_by_name(name: str | None) -> DialectExceptionMapper: + if name: + lowered = name.lower() + for mapper in _DIALECTS: + if mapper.name == lowered: + return mapper + # explicit fallback, not silent + return _DIALECTS[-1] diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/base.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/base.py new file mode 100644 index 0000000..476e948 --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/base.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping + +from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, OperationalError, ProgrammingError + + +class DialectExceptionMapper(ABC): + """Interface for dialect-specific DB -> HTTP mappings.""" + + name: str = "generic" + + @abstractmethod + def map_integrity_error(self, exc: IntegrityError) -> tuple[int, Mapping[str, str]]: ... + + @abstractmethod + def map_operational_error(self, exc: OperationalError) -> tuple[int, Mapping[str, str]]: ... + + @abstractmethod + def map_data_error(self, exc: DataError) -> tuple[int, Mapping[str, str]]: ... + + @abstractmethod + def map_programming_error(self, exc: ProgrammingError) -> tuple[int, Mapping[str, str]]: ... + + @abstractmethod + def map_dbapi_error(self, exc: DBAPIError) -> tuple[int, Mapping[str, str]]: ... + + +class GenericExceptionMapper(DialectExceptionMapper): + name = "generic" + + def map_integrity_error(self, _exc: IntegrityError) -> tuple[int, Mapping[str, str]]: + return 409, {"reason": "constraint_violation"} + + def map_operational_error(self, _exc: OperationalError) -> tuple[int, Mapping[str, str]]: + return 503, {"reason": "db_unavailable"} + + def map_data_error(self, _exc: DataError) -> tuple[int, Mapping[str, str]]: + return 400, {"reason": "invalid_data"} + + def map_programming_error(self, _exc: ProgrammingError) -> tuple[int, Mapping[str, str]]: + return 500, {"reason": "db_programming_error"} + + def map_dbapi_error(self, _exc: DBAPIError) -> tuple[int, Mapping[str, str]]: + return 503, {"reason": "db_error"} diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/postgres.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/postgres.py new file mode 100644 index 0000000..e094549 --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/postgres.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, OperationalError, ProgrammingError + +from .base import DialectExceptionMapper + + +def _extract_sqlstate(exc: BaseException) -> str | None: + # No getattr/try: check attribute presence explicitly + if hasattr(exc, "orig"): + orig = exc.orig # type: ignore[attr-defined] + if hasattr(orig, "diag") and hasattr(orig.diag, "sqlstate"): + sqlstate = orig.diag.sqlstate # type: ignore[attr-defined] + if sqlstate: + return sqlstate + if hasattr(orig, "pgcode") and orig.pgcode: # type: ignore[attr-defined] + return orig.pgcode # type: ignore[attr-defined] + if hasattr(orig, "sqlstate") and orig.sqlstate: # type: ignore[attr-defined] + return orig.sqlstate # type: ignore[attr-defined] + return None + + +# Integrity/operational/programming common map +_PG_SQLSTATE_MAP_INT: dict[str, tuple[int, str]] = { + # Class 23 — Integrity Constraint Violation + "23505": (409, "unique_constraint"), + "23503": (409, "foreign_key_constraint"), + "23502": (422, "not_null_violation"), + "23514": (422, "check_violation"), + "23P01": (409, "exclusion_violation"), + # Class 40 — Transaction Rollback + "40001": (409, "serialization_failure"), + "40P01": (503, "deadlock_detected"), + # Class 08 — Connection Exception + "08006": (503, "connection_failure"), + "08000": (503, "connection_exception"), + # Class 57 — Operator Intervention + "57P01": (503, "admin_shutdown"), + "57P02": (503, "crash_shutdown"), + "57P03": (503, "cannot_connect_now"), + # Class 42 — Syntax/Error or Access Rule Violation + "42601": (500, "syntax_error"), + "42501": (403, "insufficient_privilege"), +} + +# DataErrors +_PG_SQLSTATE_MAP_DATA: dict[str, tuple[int, str]] = { + "22001": (400, "string_data_right_truncation"), + "22003": (400, "numeric_value_out_of_range"), + "22007": (400, "invalid_datetime_format"), + "22008": (400, "datetime_field_overflow"), + "2200G": (400, "most_specific_type_mismatch"), +} + + +def _from_map( + code: str | None, table: dict[str, tuple[int, str]], default_status: int, default_reason: str +) -> tuple[int, Mapping[str, str]]: + if code and code in table: + status, reason = table[code] + return status, {"sqlstate": code, "reason": reason} + if code: + return default_status, {"sqlstate": code, "reason": default_reason} + return default_status, {"reason": default_reason} + + +class PostgresExceptionMapper(DialectExceptionMapper): + name = "postgresql" + + def map_integrity_error(self, exc: IntegrityError): + return _from_map(_extract_sqlstate(exc), _PG_SQLSTATE_MAP_INT, 409, "constraint_violation") + + def map_operational_error(self, exc: OperationalError): + return _from_map(_extract_sqlstate(exc), _PG_SQLSTATE_MAP_INT, 503, "db_unavailable") + + def map_data_error(self, exc: DataError): + return _from_map(_extract_sqlstate(exc), _PG_SQLSTATE_MAP_DATA, 400, "invalid_data") + + def map_programming_error(self, exc: ProgrammingError): + return _from_map(_extract_sqlstate(exc), _PG_SQLSTATE_MAP_INT, 500, "db_programming_error") + + def map_dbapi_error(self, exc: DBAPIError): + return _from_map(_extract_sqlstate(exc), _PG_SQLSTATE_MAP_INT, 503, "db_error") diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/fastapi_integration.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/fastapi_integration.py new file mode 100644 index 0000000..02519dc --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/fastapi_integration.py @@ -0,0 +1,30 @@ +from fastapi import FastAPI +from sqlalchemy.exc import ( + DataError, + DBAPIError, + IntegrityError, + MultipleResultsFound, + NoResultFound, + OperationalError, + ProgrammingError, +) + +from .handlers import ( + data_error_handler, + dbapi_error_handler, + integrity_error_handler, + multi_result_handler, + no_result_handler, + operational_error_handler, + programming_error_handler, +) + + +def register_database_exception_handlers(app: FastAPI) -> None: + app.add_exception_handler(IntegrityError, integrity_error_handler) + app.add_exception_handler(OperationalError, operational_error_handler) + app.add_exception_handler(DataError, data_error_handler) + app.add_exception_handler(ProgrammingError, programming_error_handler) + app.add_exception_handler(DBAPIError, dbapi_error_handler) + app.add_exception_handler(NoResultFound, no_result_handler) + app.add_exception_handler(MultipleResultsFound, multi_result_handler) diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/handlers.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/handlers.py new file mode 100644 index 0000000..584be41 --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/handlers.py @@ -0,0 +1,153 @@ +import logging +import reprlib +from collections.abc import Mapping +from typing import Annotated + +from fastapi import Request +from fastapi.responses import JSONResponse +from sqlalchemy.exc import ( + DataError, + DBAPIError, + IntegrityError, + MultipleResultsFound, + NoResultFound, + OperationalError, + ProgrammingError, +) + +from ab_core.database.databases import Database +from ab_core.dependency import Depends, inject + +from .dialects import DialectExceptionMapper, get_mapper_by_name + +logger = logging.getLogger(__name__) + + +def _payload(message: str, *, error: BaseException, extra: Mapping[str, str]) -> dict: + short = reprlib.Repr() + short.maxstring = 160 + return {"detail": message, "error": short.repr(error), **extra} + + +def _map_with_dialect( + db: Database, + exc: BaseException, + default_status: int, + default_reason: str, +) -> tuple[int, Mapping[str, str]]: + mapper: DialectExceptionMapper = get_mapper_by_name(db.async_engine.dialect.name) + + if isinstance(exc, IntegrityError): + status, extra = mapper.map_integrity_error(exc) + elif isinstance(exc, OperationalError): + status, extra = mapper.map_operational_error(exc) + elif isinstance(exc, DataError): + status, extra = mapper.map_data_error(exc) + elif isinstance(exc, ProgrammingError): + status, extra = mapper.map_programming_error(exc) + elif isinstance(exc, DBAPIError): + status, extra = mapper.map_dbapi_error(exc) + else: + # Fallback for unexpected subclasses: use provided defaults + status, extra = default_status, {"reason": default_reason} + + # Post-condition guard + if not isinstance(status, int) or not isinstance(extra, Mapping): + return default_status, {"reason": default_reason} + return status, extra + + +@inject +async def integrity_error_handler( + _request: Request, + exc: IntegrityError, + db: Annotated[Database, Depends(Database, persist=True)], +): + logger.error("Constraint violation", exc_info=exc) + status, extra = _map_with_dialect( + db=db, + exc=exc, + default_status=409, + default_reason="constraint_violation", + ) + return JSONResponse(status_code=status, content=_payload("Constraint violation", error=exc, extra=extra)) + + +@inject +async def operational_error_handler( + _req: Request, + exc: OperationalError, + db: Annotated[Database, Depends(Database, persist=True)], +): + logger.error("Database temporarily unavailable", exc_info=exc) + status, extra = _map_with_dialect( + db=db, + exc=exc, + default_status=503, + default_reason="db_unavailable", + ) + return JSONResponse( + status_code=status, content=_payload("Database temporarily unavailable", error=exc, extra=extra) + ) + + +@inject +async def data_error_handler( + _req: Request, + exc: DataError, + db: Annotated[Database, Depends(Database, persist=True)], +): + logger.error("Invalid data", exc_info=exc) + status, extra = _map_with_dialect( + db=db, + exc=exc, + default_status=400, + default_reason="invalid_data", + ) + return JSONResponse(status_code=status, content=_payload("Invalid data", error=exc, extra=extra)) + + +@inject +async def programming_error_handler( + _req: Request, + exc: ProgrammingError, + db: Annotated[Database, Depends(Database, persist=True)], +): + logger.error("Database programming error", exc_info=exc) + status, extra = _map_with_dialect( + db=db, + exc=exc, + default_status=500, + default_reason="db_programming_error", + ) + return JSONResponse(status_code=status, content=_payload("Database programming error", error=exc, extra=extra)) + + +@inject +async def dbapi_error_handler( + _req: Request, + exc: DBAPIError, + db: Annotated[Database, Depends(Database, persist=True)], +): + logger.error("Database error", exc_info=exc) + status, extra = _map_with_dialect( + db=db, + exc=exc, + default_status=503, + default_reason="db_error", + ) + return JSONResponse(status_code=status, content=_payload("Database error", error=exc, extra=extra)) + + +async def no_result_handler(_req: Request, exc: NoResultFound): + logger.error("Resource not found", exc_info=exc) + return JSONResponse( + status_code=404, content=_payload("Resource not found", error=exc, extra={"reason": "not_found"}) + ) + + +async def multi_result_handler(_req: Request, exc: MultipleResultsFound): + logger.error("Multiple resources matched", exc_info=exc) + return JSONResponse( + status_code=409, content=_payload("Multiple resources matched", error=exc, extra={"reason": "multiple_results"}) + ) From 26967f4f4617bc366f622c278c16b92c8e7a77a9 Mon Sep 17 00:00:00 2001 From: Matthew Coulter <53892067+mattcoulter7@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:19:05 +1100 Subject: [PATCH 2/4] feat: sujpport more dialects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL/MariaDB: maps by errno; deadlocks (1213) and lock timeouts (1205) → 503; duplicate key (1062) → 409; data too long (1406) → 400. SQLite: inspects well-known message substrings; unique/FK/not-null/check mapped accordingly; “database is locked” → 503. SQL Server (pyodbc): parses first args element as error code; deadlock (1205) → 503; unique violations (2601/2627) → 409; truncation (2628) → 400; permission (229) → 403. Oracle: extracts ORA-xxxxx code from .code, .message, or args; unique (00001) → 409; FK (02291/02292) → 409; not null (01400) → 422; check (02290) → 422; deadlock (00060) → 503; truncation (12899) → 400. --- .../dialects/__init__.py | 12 ++- .../dialects/mssql.py | 94 ++++++++++++++++++ .../dialects/mysql.py | 87 ++++++++++++++++ .../dialects/oracle.py | 98 +++++++++++++++++++ .../dialects/sqlite.py | 63 ++++++++++++ 5 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/mssql.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/mysql.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/oracle.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/sqlite.py diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py index 6e69bd3..6a78227 100644 --- a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py @@ -1,10 +1,17 @@ from .base import DialectExceptionMapper, GenericExceptionMapper +from .mssql import MSSQLExceptionMapper +from .mysql import MySQLExceptionMapper +from .oracle import OracleExceptionMapper from .postgres import PostgresExceptionMapper +from .sqlite import SQLiteExceptionMapper -# Immutable registry _DIALECTS: tuple[DialectExceptionMapper, ...] = ( PostgresExceptionMapper(), - GenericExceptionMapper(), # keep last as fallback + MySQLExceptionMapper(), # covers MySQL & MariaDB SA dialects (“mysql”, “mariadb” both surface as “mysql” in SA) + SQLiteExceptionMapper(), + MSSQLExceptionMapper(), + OracleExceptionMapper(), + GenericExceptionMapper(), # fallback ) @@ -14,5 +21,4 @@ def get_mapper_by_name(name: str | None) -> DialectExceptionMapper: for mapper in _DIALECTS: if mapper.name == lowered: return mapper - # explicit fallback, not silent return _DIALECTS[-1] diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/mssql.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/mssql.py new file mode 100644 index 0000000..7318869 --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/mssql.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, OperationalError, ProgrammingError + +from .base import DialectExceptionMapper + + +def _extract_sqlserver_code(exc: BaseException) -> int | None: + # pyodbc exposes (code, message, ...) in args + if hasattr(exc, "orig"): + orig = exc.orig # type: ignore[attr-defined] + if hasattr(orig, "args"): + args = orig.args # type: ignore[attr-defined] + if isinstance(args, (list, tuple)) and args: + first = args[0] + if isinstance(first, int): + return first + if isinstance(first, str) and first.isdigit(): + return int(first) + return None + + +_MSSQL_CODE_MAP_COMMON: dict[int, tuple[int, str]] = { + # Deadlock / lock timeout / connection + 1205: (503, "deadlock_detected"), + 1222: (503, "lock_request_timeout"), + # Permission + 229: (403, "insufficient_privilege"), + # Syntax + 102: (500, "syntax_error"), + # Login/authentication failures + 18456: (503, "login_failed"), +} + +_MSSQL_CODE_MAP_DATA: dict[int, tuple[int, str]] = { + 2628: (400, "string_data_right_truncation"), # string or binary data would be truncated + 8115: (400, "numeric_value_out_of_range"), # arithmetic overflow + 245: (400, "type_conversion_failed"), +} + +_MSSQL_CODE_MAP_INTEGRITY: dict[int, tuple[int, str]] = { + 2601: (409, "unique_constraint"), # Cannot insert duplicate key row + 2627: (409, "unique_constraint"), # Violation of UNIQUE KEY constraint + 547: (409, "foreign_key_constraint"), # The INSERT/UPDATE/DELETE conflicted with the REFERENCE constraint + # NOT NULL violation can show as 515 + 515: (422, "not_null_violation"), + # CHECK violation + 547: ( + 422, + "check_violation", + ), # sometimes appears as 547; we already map 409 above; keep 409 for FK, 422 for CHECK ambiguous +} + + +def _from_code( + code: int | None, table: dict[int, tuple[int, str]], default_status: int, default_reason: str +) -> tuple[int, Mapping[str, str]]: + if code is not None and code in table: + status, reason = table[code] + return status, {"mssql": str(code), "reason": reason} + if code is not None: + return default_status, {"mssql": str(code), "reason": default_reason} + return default_status, {"reason": default_reason} + + +class MSSQLExceptionMapper(DialectExceptionMapper): + name = "mssql" + + def map_integrity_error(self, exc: IntegrityError): + code = _extract_sqlserver_code(exc) + # prefer integrity table, fall back to data/common + if code in _MSSQL_CODE_MAP_INTEGRITY: + return _from_code(code, _MSSQL_CODE_MAP_INTEGRITY, 409, "constraint_violation") + if code in _MSSQL_CODE_MAP_DATA: + return _from_code(code, _MSSQL_CODE_MAP_DATA, 409, "constraint_violation") + return _from_code(code, _MSSQL_CODE_MAP_COMMON, 409, "constraint_violation") + + def map_operational_error(self, exc: OperationalError): + code = _extract_sqlserver_code(exc) + return _from_code(code, _MSSQL_CODE_MAP_COMMON, 503, "db_unavailable") + + def map_data_error(self, exc: DataError): + code = _extract_sqlserver_code(exc) + return _from_code(code, _MSSQL_CODE_MAP_DATA, 400, "invalid_data") + + def map_programming_error(self, exc: ProgrammingError): + code = _extract_sqlserver_code(exc) + return _from_code(code, _MSSQL_CODE_MAP_COMMON, 500, "db_programming_error") + + def map_dbapi_error(self, exc: DBAPIError): + code = _extract_sqlserver_code(exc) + return _from_code(code, _MSSQL_CODE_MAP_COMMON, 503, "db_error") diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/mysql.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/mysql.py new file mode 100644 index 0000000..3e9e10b --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/mysql.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, OperationalError, ProgrammingError + +from .base import DialectExceptionMapper + + +def _extract_errno(exc: BaseException) -> int | None: + # MySQLdb / PyMySQL put (errno, msg, ...) in orig.args + if hasattr(exc, "orig"): + orig = exc.orig # type: ignore[attr-defined] + if hasattr(orig, "args"): + args = orig.args # type: ignore[attr-defined] + if isinstance(args, (list, tuple)) and args: + first = args[0] + if isinstance(first, int): + return first + if isinstance(first, str) and first.isdigit(): + return int(first) + return None + + +# Key MySQL/MariaDB errnos +_MY_ERRNO_TO_STATUS_REASON: dict[int, tuple[int, str]] = { + # Integrity + 1062: (409, "unique_constraint"), # Duplicate entry + 1452: (409, "foreign_key_constraint"), # Cannot add or update child row + 1048: (422, "not_null_violation"), + 3819: (422, "check_violation"), # Check constraint failed (MySQL 8+) + # Operational / availability / locking + 1213: (503, "deadlock_detected"), + 1205: (503, "lock_wait_timeout"), + 1040: (503, "too_many_connections"), + 2006: (503, "mysql_server_gone"), + # Permissions / syntax + 1142: (403, "insufficient_privilege"), # command denied to user + 1143: (403, "insufficient_privilege"), # column access denied + 1064: (500, "syntax_error"), +} + +# Data errors +_MY_DATA_ERRNO: dict[int, tuple[int, str]] = { + 1406: (400, "string_data_right_truncation"), # Data too long + 1264: (400, "numeric_value_out_of_range"), + 1292: (400, "invalid_datetime_format"), # Incorrect datetime value + 1366: (400, "invalid_string_value"), # Incorrect string value / collation +} + + +def _from_errno( + errno: int | None, table: dict[int, tuple[int, str]], default_status: int, default_reason: str +) -> tuple[int, Mapping[str, str]]: + if errno is not None and errno in table: + status, reason = table[errno] + return status, {"errno": str(errno), "reason": reason} + if errno is not None: + return default_status, {"errno": str(errno), "reason": default_reason} + return default_status, {"reason": default_reason} + + +class MySQLExceptionMapper(DialectExceptionMapper): + name = "mysql" # also used by MariaDB dialects in SA + + def map_integrity_error(self, exc: IntegrityError): + errno = _extract_errno(exc) + # merge integrity + general table to catch FK, NOT NULL, etc. + merged = dict(_MY_ERRNO_TO_STATUS_REASON) + merged.update({k: v for k, v in _MY_DATA_ERRNO.items() if k in (1048, 3819)}) + return _from_errno(errno, merged, 409, "constraint_violation") + + def map_operational_error(self, exc: OperationalError): + errno = _extract_errno(exc) + return _from_errno(errno, _MY_ERRNO_TO_STATUS_REASON, 503, "db_unavailable") + + def map_data_error(self, exc: DataError): + errno = _extract_errno(exc) + return _from_errno(errno, _MY_DATA_ERRNO, 400, "invalid_data") + + def map_programming_error(self, exc: ProgrammingError): + errno = _extract_errno(exc) + return _from_errno(errno, _MY_ERRNO_TO_STATUS_REASON, 500, "db_programming_error") + + def map_dbapi_error(self, exc: DBAPIError): + errno = _extract_errno(exc) + return _from_errno(errno, _MY_ERRNO_TO_STATUS_REASON, 503, "db_error") diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/oracle.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/oracle.py new file mode 100644 index 0000000..45718ff --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/oracle.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, OperationalError, ProgrammingError + +from .base import DialectExceptionMapper + + +def _extract_ora_code(exc: BaseException) -> int | None: + # cx_Oracle/oracledb: orig has .code (int) and .message (str) + if hasattr(exc, "orig"): + orig = exc.orig # type: ignore[attr-defined] + if hasattr(orig, "code"): + code = orig.code # type: ignore[attr-defined] + if isinstance(code, int): + return code + if isinstance(code, str) and code.startswith("ORA-") and code[4:9].isdigit(): + return int(code[4:9]) + # fallback: try parse from args/message if present + if hasattr(orig, "message"): + msg = orig.message # type: ignore[attr-defined] + if isinstance(msg, str) and "ORA-" in msg: + idx = msg.find("ORA-") + if idx != -1 and len(msg) >= idx + 9 and msg[idx + 4 : idx + 9].isdigit(): + return int(msg[idx + 4 : idx + 9]) + if hasattr(orig, "args"): + args = orig.args # type: ignore[attr-defined] + if isinstance(args, (list, tuple)) and args: + s = str(args[0]) + if "ORA-" in s: + idx = s.find("ORA-") + if idx != -1 and len(s) >= idx + 9 and s[idx + 4 : idx + 9].isdigit(): + return int(s[idx + 4 : idx + 9]) + return None + + +_ORA_INTEGRITY: dict[int, tuple[int, str]] = { + 1: (409, "unique_constraint"), # ORA-00001 + 2291: (409, "foreign_key_constraint"), # ORA-02291 integrity constraint violated - parent key not found + 2292: (409, "foreign_key_constraint"), # ORA-02292 child record found + 1400: (422, "not_null_violation"), # ORA-01400 cannot insert NULL + 2290: (422, "check_violation"), # ORA-02290 check constraint violated +} + +_ORA_OPERATIONAL: dict[int, tuple[int, str]] = { + 60: (503, "deadlock_detected"), # ORA-00060 + 12541: (503, "no_listener"), # ORA-12541 + 12514: (503, "listener_could_not_resolve"), +} + +_ORA_DATA: dict[int, tuple[int, str]] = { + 1438: (400, "numeric_value_out_of_range"), # ORA-01438 + 1861: (400, "invalid_datetime_format"), # ORA-01861 + 12899: (400, "string_data_right_truncation"), # ORA-12899 value too large for column +} + +_ORA_PROGRAMMING: dict[int, tuple[int, str]] = { + 900: (500, "syntax_error"), # ORA-00900 invalid SQL statement + 933: (500, "syntax_error"), # ORA-00933 SQL command not properly ended + 1031: (403, "insufficient_privilege"), # ORA-01031 +} + + +def _from_ora( + code: int | None, table: dict[int, tuple[int, str]], default_status: int, default_reason: str +) -> tuple[int, Mapping[str, str]]: + if code is not None and code in table: + status, reason = table[code] + return status, {"oracle": f"ORA-{code:05d}", "reason": reason} + if code is not None: + return default_status, {"oracle": f"ORA-{code:05d}", "reason": default_reason} + return default_status, {"reason": default_reason} + + +class OracleExceptionMapper(DialectExceptionMapper): + name = "oracle" + + def map_integrity_error(self, exc: IntegrityError): + return _from_ora(_extract_ora_code(exc), _ORA_INTEGRITY, 409, "constraint_violation") + + def map_operational_error(self, exc: OperationalError): + return _from_ora(_extract_ora_code(exc), _ORA_OPERATIONAL, 503, "db_unavailable") + + def map_data_error(self, exc: DataError): + return _from_ora(_extract_ora_code(exc), _ORA_DATA, 400, "invalid_data") + + def map_programming_error(self, exc: ProgrammingError): + return _from_ora(_extract_ora_code(exc), _ORA_PROGRAMMING, 500, "db_programming_error") + + def map_dbapi_error(self, exc: DBAPIError): + # use union of the above as a pragmatic catch-all + code = _extract_ora_code(exc) + if code is not None: + for table in (_ORA_INTEGRITY, _ORA_OPERATIONAL, _ORA_DATA, _ORA_PROGRAMMING): + if code in table: + return _from_ora(code, table, 503, "db_error") + return _from_ora(code, {}, 503, "db_error") diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/sqlite.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/sqlite.py new file mode 100644 index 0000000..caf4e5a --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/sqlite.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, OperationalError, ProgrammingError + +from .base import DialectExceptionMapper + + +def _orig_message(exc: BaseException) -> str: + if hasattr(exc, "orig"): + orig = exc.orig # type: ignore[attr-defined] + # sqlite3.Error often has .args[0] as message + if hasattr(orig, "args"): + args = orig.args # type: ignore[attr-defined] + if isinstance(args, (list, tuple)) and args: + first = args[0] + if isinstance(first, str): + return first + return "" + + +def _reason_from_message(msg: str) -> tuple[int, str] | None: + # SQLite strings are fairly consistent + if "UNIQUE constraint failed" in msg: + return (409, "unique_constraint") + if "FOREIGN KEY constraint failed" in msg: + return (409, "foreign_key_constraint") + if "NOT NULL constraint failed" in msg: + return (422, "not_null_violation") + if "CHECK constraint failed" in msg: + return (422, "check_violation") + return None + + +class SQLiteExceptionMapper(DialectExceptionMapper): + name = "sqlite" + + def map_integrity_error(self, exc: IntegrityError): + msg = _orig_message(exc) + deduced = _reason_from_message(msg) + if deduced: + status, reason = deduced + extra: Mapping[str, str] = {"reason": reason} + return status, extra + return 409, {"reason": "constraint_violation"} + + def map_operational_error(self, exc: OperationalError): + # DB locked / busy often appears here; treat as transient + msg = _orig_message(exc) + if "database is locked" in msg or "database is busy" in msg: + return 503, {"reason": "db_locked"} + return 503, {"reason": "db_unavailable"} + + def map_data_error(self, _exc: DataError): + return 400, {"reason": "invalid_data"} + + def map_programming_error(self, exc: ProgrammingError): + # Typically malformed SQL + return 500, {"reason": "db_programming_error"} + + def map_dbapi_error(self, _exc: DBAPIError): + return 503, {"reason": "db_error"} From 43e5efa71d2d46f2561c35da1879ee2d2b545d82 Mon Sep 17 00:00:00 2001 From: Matthew Coulter <53892067+mattcoulter7@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:21:04 +1100 Subject: [PATCH 3/4] feat: support more dialects --- .../dialects/__init__.py | 17 +++-- .../dialects/ansi.py | 71 ++++++++++++++++++ .../dialects/db2.py | 75 +++++++++++++++++++ .../dialects/hana.py | 64 ++++++++++++++++ 4 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/ansi.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/db2.py create mode 100644 src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/hana.py diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py index 6a78227..37471d5 100644 --- a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/__init__.py @@ -1,20 +1,25 @@ from .base import DialectExceptionMapper, GenericExceptionMapper -from .mssql import MSSQLExceptionMapper -from .mysql import MySQLExceptionMapper -from .oracle import OracleExceptionMapper from .postgres import PostgresExceptionMapper +from .mysql import MySQLExceptionMapper from .sqlite import SQLiteExceptionMapper +from .mssql import MSSQLExceptionMapper +from .oracle import OracleExceptionMapper +from .db2 import DB2ExceptionMapper +from .hana import HANAExceptionMapper +from .ansi import AnsiSQLStateMapper # optional, generic _DIALECTS: tuple[DialectExceptionMapper, ...] = ( PostgresExceptionMapper(), - MySQLExceptionMapper(), # covers MySQL & MariaDB SA dialects (“mysql”, “mariadb” both surface as “mysql” in SA) + MySQLExceptionMapper(), SQLiteExceptionMapper(), MSSQLExceptionMapper(), OracleExceptionMapper(), - GenericExceptionMapper(), # fallback + DB2ExceptionMapper(), + HANAExceptionMapper(), + AnsiSQLStateMapper(), + GenericExceptionMapper(), # final fallback ) - def get_mapper_by_name(name: str | None) -> DialectExceptionMapper: if name: lowered = name.lower() diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/ansi.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/ansi.py new file mode 100644 index 0000000..55d3477 --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/ansi.py @@ -0,0 +1,71 @@ +from __future__ import annotations +from typing import Mapping, Tuple +from sqlalchemy.exc import IntegrityError, OperationalError, DataError, ProgrammingError, DBAPIError +from .base import DialectExceptionMapper + +def _sqlstate(exc: BaseException) -> str | None: + if hasattr(exc, "orig"): + orig = exc.orig # type: ignore[attr-defined] + if hasattr(orig, "sqlstate"): + s = orig.sqlstate # type: ignore[attr-defined] + if isinstance(s, str) and len(s) == 5: + return s + if hasattr(orig, "diag") and hasattr(orig.diag, "sqlstate"): + s2 = orig.diag.sqlstate # type: ignore[attr-defined] + if isinstance(s2, str) and len(s2) == 5: + return s2 + return None + +_SPECIFICS: dict[str, tuple[int, str]] = { + "23505": (409, "unique_constraint"), + "23503": (409, "foreign_key_constraint"), + "23502": (422, "not_null_violation"), + "23514": (422, "check_violation"), + "42501": (403, "insufficient_privilege"), + "42601": (500, "syntax_error"), + "40P01": (503, "deadlock_detected"), # PG-flavoured, but safe if seen + "40001": (409, "serialization_failure"), +} + +def _class_default(sqlstate: str) -> tuple[int, str]: + c = sqlstate[:2] + if c == "23": # Integrity + return (409, "constraint_violation") + if c == "22": # Data + return (400, "invalid_data") + if c == "40": # Txn rollback + return (409, "transaction_rollback") + if c == "08": # Connection + return (503, "connection_exception") + if c == "57": # Operator intervention + return (503, "operator_intervention") + if c == "42": # Syntax/Access + return (500, "syntax_or_access_rule") + return (500, "db_error") + +def _map(sqlstate: str | None, default_status: int, default_reason: str) -> tuple[int, Mapping[str, str]]: + if sqlstate is None: + return default_status, {"reason": default_reason} + if sqlstate in _SPECIFICS: + status, reason = _SPECIFICS[sqlstate] + return status, {"sqlstate": sqlstate, "reason": reason} + status, reason = _class_default(sqlstate) + return status, {"sqlstate": sqlstate, "reason": reason} + +class AnsiSQLStateMapper(DialectExceptionMapper): + name = "ansi-sqlstate" # not a real SA dialect; treat as a generic mapper + + def map_integrity_error(self, exc: IntegrityError): + return _map(_sqlstate(exc), 409, "constraint_violation") + + def map_operational_error(self, exc: OperationalError): + return _map(_sqlstate(exc), 503, "db_unavailable") + + def map_data_error(self, exc: DataError): + return _map(_sqlstate(exc), 400, "invalid_data") + + def map_programming_error(self, exc: ProgrammingError): + return _map(_sqlstate(exc), 500, "db_programming_error") + + def map_dbapi_error(self, exc: DBAPIError): + return _map(_sqlstate(exc), 503, "db_error") diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/db2.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/db2.py new file mode 100644 index 0000000..7c94e29 --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/db2.py @@ -0,0 +1,75 @@ +from __future__ import annotations +from typing import Mapping, Tuple +from sqlalchemy.exc import IntegrityError, OperationalError, DataError, ProgrammingError, DBAPIError +from .base import DialectExceptionMapper + +def _extract_sqlstate(exc: BaseException) -> str | None: + if hasattr(exc, "orig"): + orig = exc.orig # type: ignore[attr-defined] + # ibm_db_sa surfaces .sqlstate or message with SQLSTATE=xxxxx + if hasattr(orig, "sqlstate"): + s = orig.sqlstate # type: ignore[attr-defined] + if isinstance(s, str) and len(s) == 5: + return s + if hasattr(orig, "args"): + args = orig.args # type: ignore[attr-defined] + if isinstance(args, (list, tuple)) and args: + text = str(args[0]) + idx = text.find("SQLSTATE=") + if idx != -1 and len(text) >= idx + 13: + candidate = text[idx + 9 : idx + 14] + if len(candidate) == 5: + return candidate + return None + +_DB2_INTEGRITY: dict[str, tuple[int, str]] = { + "23505": (409, "unique_constraint"), + "23503": (409, "foreign_key_constraint"), + "23502": (422, "not_null_violation"), + "23514": (422, "check_violation"), +} + +_DB2_DATA: dict[str, tuple[int, str]] = { + "22001": (400, "string_data_right_truncation"), + "22003": (400, "numeric_value_out_of_range"), + "22007": (400, "invalid_datetime_format"), + "22008": (400, "datetime_field_overflow"), +} + +_DB2_COMMON: dict[str, tuple[int, str]] = { + "40001": (409, "serialization_failure"), + "57033": (503, "lock_timeout"), # common DB2 lock timeout state + "08000": (503, "connection_exception"), + "08006": (503, "connection_failure"), + "42501": (403, "insufficient_privilege"), + "42601": (500, "syntax_error"), +} + +def _from_sqlstate(code: str | None, table: dict[str, tuple[int, str]], default_status: int, default_reason: str) -> tuple[int, Mapping[str, str]]: + if code is not None and code in table: + status, reason = table[code] + return status, {"sqlstate": code, "reason": reason} + if code is not None: + return default_status, {"sqlstate": code, "reason": default_reason} + return default_status, {"reason": default_reason} + +class DB2ExceptionMapper(DialectExceptionMapper): + name = "ibm_db_sa" # SQLAlchemy commonly exposes this dialect name; adjust if needed. + + def map_integrity_error(self, exc: IntegrityError): + code = _extract_sqlstate(exc) + if code in _DB2_INTEGRITY: + return _from_sqlstate(code, _DB2_INTEGRITY, 409, "constraint_violation") + return _from_sqlstate(code, _DB2_COMMON, 409, "constraint_violation") + + def map_operational_error(self, exc: OperationalError): + return _from_sqlstate(_extract_sqlstate(exc), _DB2_COMMON, 503, "db_unavailable") + + def map_data_error(self, exc: DataError): + return _from_sqlstate(_extract_sqlstate(exc), _DB2_DATA, 400, "invalid_data") + + def map_programming_error(self, exc: ProgrammingError): + return _from_sqlstate(_extract_sqlstate(exc), _DB2_COMMON, 500, "db_programming_error") + + def map_dbapi_error(self, exc: DBAPIError): + return _from_sqlstate(_extract_sqlstate(exc), _DB2_COMMON, 503, "db_error") diff --git a/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/hana.py b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/hana.py new file mode 100644 index 0000000..9b5e0f0 --- /dev/null +++ b/src/ab_core/sqlalchemy_fastapi_http_exceptions/dialects/hana.py @@ -0,0 +1,64 @@ +from __future__ import annotations +from typing import Mapping, Tuple +from sqlalchemy.exc import IntegrityError, OperationalError, DataError, ProgrammingError, DBAPIError +from .base import DialectExceptionMapper + +def _extract_code(exc: BaseException) -> str: + # hdbcli often exposes text like: "[] " in args[0] + if hasattr(exc, "orig"): + orig = exc.orig # type: ignore[attr-defined] + if hasattr(orig, "args"): + args = orig.args # type: ignore[attr-defined] + if isinstance(args, (list, tuple)) and args: + return str(args[0]) + return "" + +def _contains(s: str, needle: str) -> bool: + return needle.lower() in s.lower() + +class HANAExceptionMapper(DialectExceptionMapper): + name = "hana" + + def map_integrity_error(self, exc: IntegrityError): + msg = _extract_code(exc) + if _contains(msg, "unique constraint"): + return 409, {"reason": "unique_constraint"} + if _contains(msg, "foreign key"): + return 409, {"reason": "foreign_key_constraint"} + if _contains(msg, "not null"): + return 422, {"reason": "not_null_violation"} + if _contains(msg, "check constraint"): + return 422, {"reason": "check_violation"} + return 409, {"reason": "constraint_violation"} + + def map_operational_error(self, exc: OperationalError): + msg = _extract_code(exc) + if _contains(msg, "lock timeout") or _contains(msg, "deadlock"): + return 503, {"reason": "lock_or_deadlock"} + if _contains(msg, "connection") or _contains(msg, "network"): + return 503, {"reason": "connection_exception"} + return 503, {"reason": "db_unavailable"} + + def map_data_error(self, exc: DataError): + msg = _extract_code(exc) + if _contains(msg, "value too large") or _contains(msg, "overflow"): + return 400, {"reason": "numeric_value_out_of_range"} + if _contains(msg, "invalid date") or _contains(msg, "date/time"): + return 400, {"reason": "invalid_datetime_format"} + if _contains(msg, "too long"): + return 400, {"reason": "string_data_right_truncation"} + return 400, {"reason": "invalid_data"} + + def map_programming_error(self, exc: ProgrammingError): + msg = _extract_code(exc) + if _contains(msg, "not authorized") or _contains(msg, "insufficient privilege"): + return 403, {"reason": "insufficient_privilege"} + if _contains(msg, "syntax error"): + return 500, {"reason": "syntax_error"} + return 500, {"reason": "db_programming_error"} + + def map_dbapi_error(self, exc: DBAPIError): + msg = _extract_code(exc) + if _contains(msg, "lock") or _contains(msg, "timeout"): + return 503, {"reason": "db_error_lock_or_timeout"} + return 503, {"reason": "db_error"} From c3a3a6cd366ef8f127df8b1518738d3714340e87 Mon Sep 17 00:00:00 2001 From: Matthew Coulter <53892067+mattcoulter7@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:34:01 +1100 Subject: [PATCH 4/4] fix: invalid extra --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e9fe9c2..d852d44 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ jobs: git config --global url."https://${GH_READ_TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/" - name: Sync deps - run: uv sync --extra all + run: uv sync - name: Lint run: uv run tox -e lint @@ -68,7 +68,7 @@ jobs: git config --global url."https://${GH_READ_TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/" - name: Sync deps - run: uv sync --extra all + run: uv sync - name: Test run: uv run tox -e test