Skip to content

Commit 0aed4f5

Browse files
authored
feat(errors): classify SQL Server driver errors by taxonomy (#15)
* feat(errors): classify SQL Server driver errors by taxonomy * fix(execute): align SQL Server execute error phases * test(execute): add SQL Server async contract parity * style(sqlserver): clean unused imports and wrap a long line * ci: pin core and testsuite feature/error-taxonomy branches * docs(changelog): add fragment for the error taxonomy PR (#15) * ci: mark feature-branch pip installs as reviewed (#15)
1 parent b1be045 commit 0aed4f5

9 files changed

Lines changed: 1230 additions & 173 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ jobs:
5454
5555
- name: Install activerecord and testsuite from PyPI
5656
run: |
57-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
58-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
57+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
58+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
5959
6060
- name: Install dependencies
6161
run: |
@@ -144,8 +144,8 @@ jobs:
144144
145145
- name: Install activerecord and testsuite from PyPI
146146
run: |
147-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
148-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
147+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
148+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
149149
150150
- name: Install dependencies
151151
run: |
@@ -234,8 +234,8 @@ jobs:
234234
235235
- name: Install activerecord and testsuite from PyPI
236236
run: |
237-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
238-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
237+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
238+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
239239
240240
- name: Install dependencies
241241
run: |
@@ -324,8 +324,8 @@ jobs:
324324
325325
- name: Install activerecord and testsuite from PyPI
326326
run: |
327-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
328-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
327+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
328+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
329329
330330
- name: Install dependencies
331331
run: |
@@ -414,8 +414,8 @@ jobs:
414414
415415
- name: Install activerecord and testsuite from PyPI
416416
run: |
417-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
418-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
417+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
418+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
419419
420420
- name: Install dependencies
421421
run: |
@@ -504,8 +504,8 @@ jobs:
504504
505505
- name: Install activerecord and testsuite from PyPI
506506
run: |
507-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
508-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
507+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
508+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
509509
510510
- name: Install dependencies
511511
run: |
@@ -595,8 +595,8 @@ jobs:
595595
596596
- name: Install activerecord and testsuite from PyPI
597597
run: |
598-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
599-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
598+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
599+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
600600
601601
- name: Install dependencies
602602
run: |
@@ -685,8 +685,8 @@ jobs:
685685
686686
- name: Install activerecord and testsuite from PyPI
687687
run: |
688-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
689-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
688+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
689+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
690690
691691
- name: Install dependencies
692692
run: |
@@ -784,8 +784,8 @@ jobs:
784784
785785
- name: Install activerecord and testsuite from PyPI
786786
run: |
787-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
788-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
787+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
788+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR
789789
790790
- name: Install dependencies
791791
run: |

‎changelog.d/15.added.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Classified SQL Server 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.

‎src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py‎

Lines changed: 93 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
using aioodbc as the async driver wrapper.
77
"""
88

9-
import asyncio
109
import logging
1110
import time
1211
from contextlib import asynccontextmanager
13-
from typing import Any, AsyncIterator, Dict, List, Optional, Set, Tuple
12+
from typing import Any, AsyncIterator, List, Optional, Set, Tuple
1413

1514
from rhosocial.activerecord.backend.base import AsyncStorageBackend
1615
from rhosocial.activerecord.backend.base.execution_logging import (
@@ -20,22 +19,18 @@
2019
log_execution_start,
2120
log_execution_summary,
2221
)
23-
from rhosocial.activerecord.backend.base.operations import AsyncSQLOperationsMixin
24-
from rhosocial.activerecord.backend.base.transaction_management import AsyncTransactionManagementMixin
2522
from rhosocial.activerecord.backend.errors import (
2623
ConnectionError,
2724
DatabaseError,
28-
DeadlockError,
29-
IntegrityError,
30-
OperationalError,
31-
QueryError,
25+
TransactionError,
3226
)
3327
from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType
3428
from rhosocial.activerecord.backend.result import QueryResult
3529
from rhosocial.activerecord.logging.redaction import unwrap_sensitive
3630
from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin
3731
from ..config import SQLServerConnectionConfig
3832
from ..dialect import SQLServerDialect
33+
from ..error_mapping import classify_sqlserver_error
3934
from ..async_transaction import AsyncSQLServerTransactionManager
4035
from .backend import SQLServerUnicodeDialect
4136
from ..mixins import SQLServerBackendMixin
@@ -271,21 +266,59 @@ async def _get_cursor(self):
271266

272267
return await self._connection.cursor()
273268

269+
async def _execute_control_sql_async(self, sql: str) -> None:
270+
"""Run a session-control statement through the mapped error path."""
271+
cursor = await self._get_cursor()
272+
try:
273+
await cursor.execute(sql)
274+
except Exception as exc:
275+
await self._handle_error(exc)
276+
finally:
277+
await self._close_cursor_quietly_async(cursor)
278+
279+
async def _close_cursor_quietly_async(self, cursor) -> None:
280+
"""Close an async cursor, logging rather than masking an in-flight error."""
281+
try:
282+
await cursor.close()
283+
except Exception as exc:
284+
self.log(logging.DEBUG, f"Failed to close cursor: {exc}")
285+
286+
async def _execute_control_sql_async_quietly(self, sql: str) -> None:
287+
"""Run a cleanup statement without masking the original failure."""
288+
try:
289+
await self._execute_control_sql_async(sql)
290+
except Exception as exc:
291+
self.log(logging.WARNING, f"Failed to reset session state ({sql}): {exc}")
292+
293+
async def _reset_identity_insert_quietly(self, table: str) -> None:
294+
"""Turn ``IDENTITY_INSERT`` off without replacing an in-flight failure."""
295+
try:
296+
await self.set_identity_insert(table, False)
297+
except Exception as exc:
298+
self.log(
299+
logging.WARNING,
300+
f"Failed to disable IDENTITY_INSERT for {table}: {exc}",
301+
)
302+
274303
async def _get_scope_identity_async(self) -> Optional[int]:
275304
"""Get the identity value of the last INSERT via @@IDENTITY."""
276305
sql, params = session_identity_sql(self.dialect)
277-
async with await self._get_cursor() as cursor:
306+
cursor = await self._get_cursor()
307+
try:
278308
await cursor.execute(sql, params)
279309
row = await cursor.fetchone()
310+
except Exception as exc:
311+
await self._handle_error(exc)
312+
finally:
313+
await self._close_cursor_quietly_async(cursor)
280314
if row and row[0] is not None:
281315
return int(row[0])
282316
return None
283317

284318
async def set_identity_insert(self, table: str, on: bool = True) -> None:
285319
"""Enable or disable IDENTITY_INSERT for a table (async)."""
286320
sql, _params = self.dialect.format_set_identity_insert(table, on)
287-
async with await self._get_cursor() as cursor:
288-
await cursor.execute(sql)
321+
await self._execute_control_sql_async(sql)
289322

290323
async def _get_identity_columns_async(self, table_name: str) -> Set[str]:
291324
"""Return the set of identity column names for a table (async, cached)."""
@@ -352,13 +385,11 @@ async def execute(
352385
explain_split = self._split_explain_statement(sql)
353386
if explain_split is not None:
354387
set_clause, statement = explain_split
355-
async with await self._get_cursor() as cursor:
356-
await cursor.execute(f"SET {set_clause} ON")
388+
await self._execute_control_sql_async(f"SET {set_clause} ON")
357389
try:
358390
return await self._execute_statement(statement, params, options)
359391
finally:
360-
async with await self._get_cursor() as cursor:
361-
await cursor.execute(f"SET {set_clause} OFF")
392+
await self._execute_control_sql_async_quietly(f"SET {set_clause} OFF")
362393

363394
return await self._execute_statement(sql, params, options)
364395

@@ -419,9 +450,13 @@ async def _execute_statement(
419450
await cursor.execute(final_sql, final_params or ())
420451
finally:
421452
if identity_table is not None:
422-
await self.set_identity_insert(identity_table, False)
453+
await self._reset_identity_insert_quietly(identity_table)
423454

424-
is_select = options.process_result_set if options.process_result_set is not None else options.stmt_type == StatementType.DQL
455+
is_select = (
456+
options.process_result_set
457+
if options.process_result_set is not None
458+
else options.stmt_type == StatementType.DQL
459+
)
425460
data = None
426461
if is_select and cursor.description:
427462
rows = await cursor.fetchall()
@@ -469,7 +504,10 @@ async def _execute_statement(
469504

470505
except Exception as e:
471506
log_execution_error(self, e, options)
507+
if isinstance(e, DatabaseError):
508+
raise
472509
await self._handle_error(e)
510+
raise DatabaseError(str(e)) from e
473511

474512
async def execute_many(
475513
self,
@@ -517,7 +555,11 @@ async def execute_many(
517555

518556
except Exception as e:
519557
log_execution_error(self, e, options)
558+
if isinstance(e, DatabaseError):
559+
raise
520560
await self._handle_error(e)
561+
# Same defensive fallback as the sync batch path.
562+
raise DatabaseError(str(e)) from e
521563

522564
async def get_server_version(self) -> Tuple[int, int, int]:
523565
"""Get SQL Server server version.
@@ -617,62 +659,60 @@ async def executescript(self, sql_script: str) -> None:
617659

618660
for batch in batches:
619661
batch = batch.strip()
620-
if batch:
621-
async with await self._get_cursor() as cursor:
622-
await cursor.execute(batch)
662+
if not batch:
663+
continue
664+
async with await self._get_cursor() as cursor:
665+
await cursor.execute(batch)
623666

624667
await self._handle_auto_commit_if_needed()
625668
duration = time.perf_counter() - start_time
626669
self.log(logging.INFO, f"SQL script executed successfully, duration={duration:.3f}s")
627670

628671
except Exception as e:
629672
self.log(logging.DEBUG, f"Error executing SQL script: {str(e)}")
673+
if isinstance(e, DatabaseError):
674+
raise
630675
await self._handle_error(e)
676+
raise DatabaseError(str(e)) from e
631677

632678
async def _handle_error(self, error: Exception) -> None:
633-
"""Handle SQL Server-specific errors.
634-
679+
"""Handle SQL Server-specific errors asynchronously.
680+
681+
Uses the same code -> class -> message recognition order as the sync
682+
backend, so both halves translate a driver failure identically.
683+
635684
Args:
636685
error: The exception to handle
637-
686+
638687
Raises:
639688
Appropriate backend exception
640689
"""
641-
error_msg = str(error).lower()
642-
643-
if (
644-
'integrity' in error_msg
645-
or 'constraint' in error_msg
646-
or 'does not allow nulls' in error_msg
647-
or 'cannot insert the value null' in error_msg
648-
):
649-
if "duplicate" in error_msg or "unique" in error_msg:
650-
self.log(logging.DEBUG, f"Unique constraint violation: {str(error)}")
651-
raise IntegrityError(f"Unique constraint violation: {str(error)}")
652-
elif "foreign key" in error_msg or "reference" in error_msg:
653-
self.log(logging.DEBUG, f"Foreign key constraint violation: {str(error)}")
654-
raise IntegrityError(f"Foreign key constraint violation: {str(error)}")
655-
self.log(logging.DEBUG, f"Integrity error: {str(error)}")
656-
raise IntegrityError(self._normalize_integrity_message(str(error)))
657-
658-
if 'deadlock' in error_msg or '1205' in error_msg:
659-
self.log(logging.DEBUG, f"Deadlock detected: {str(error)}")
660-
raise DeadlockError(str(error))
661-
662-
if 'timeout' in error_msg or 'connection' in error_msg:
663-
self.log(logging.DEBUG, f"Operational error: {str(error)}")
664-
raise OperationalError(str(error))
665-
666-
self.log(logging.DEBUG, f"Database error: {str(error)}")
667-
raise DatabaseError(str(error))
690+
# A framework error already carries its phase semantics; re-mapping it
691+
# would replace the original type with a generic driver error.
692+
if isinstance(error, DatabaseError):
693+
raise error
694+
mapped = classify_sqlserver_error(error)
695+
self._log_classified_error(error, mapped)
696+
raise mapped from error
668697

669698
async def _handle_auto_commit_if_needed(self) -> None:
670699
"""Handle auto-commit for async operations."""
671700
if not self.in_transaction and self._connection:
672701
if not self.config.autocommit:
673-
async with await self._get_cursor() as cursor:
674-
await cursor.execute("COMMIT")
675-
self.log(logging.DEBUG, "Auto-committed operation")
702+
await self._handle_auto_commit()
703+
704+
async def _handle_auto_commit(self) -> None:
705+
"""Commit outside a transaction, surfacing a failure as a transaction error."""
706+
if self._connection is None or self.config.autocommit:
707+
return
708+
cursor = await self._get_cursor()
709+
try:
710+
await cursor.execute("COMMIT")
711+
except Exception as exc:
712+
raise TransactionError("Auto-commit failed") from exc
713+
finally:
714+
await self._close_cursor_quietly_async(cursor)
715+
self.log(logging.DEBUG, "Auto-committed operation")
676716

677717
def _create_introspector(self):
678718
"""Create a SQL Server introspector."""

0 commit comments

Comments
 (0)