From eeb19647f5a0de4c3ad8db01d740c27861e09e1e Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Mon, 19 Jan 2026 17:01:58 -0500 Subject: [PATCH] Error Bubbling: Implement machine-readable rich error objects for CRUD operations --- app/lib_api_crud_v3.py | 51 +++++++++++++++++++++++++------ app/models/error_models.py | 23 ++++++++++++++ app/routers/api_crud_v3.py | 6 ++-- app/routers/api_crud_v3_nested.py | 3 +- 4 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 app/models/error_models.py diff --git a/app/lib_api_crud_v3.py b/app/lib_api_crud_v3.py index cf81606..097016d 100644 --- a/app/lib_api_crud_v3.py +++ b/app/lib_api_crud_v3.py @@ -1,27 +1,58 @@ -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union import json import logging import re from app.lib_general_v3 import AccountContext, StatusFilterParams +from app.models.error_models import StandardError log = logging.getLogger(__name__) -def format_db_error(raw_error: str) -> str: +def format_db_error(raw_error: str) -> StandardError: """ - Parses raw SQLAlchemy/MariaDB errors into user-friendly strings. + Parses raw SQLAlchemy/MariaDB errors into structured StandardError objects. """ if not raw_error: - return "" + return StandardError( + category="unknown", + message="An unspecified database error occurred." + ) + # 1. Extract Error Code and Message using regex # Standard MariaDB pattern: (code, "message") - match = re.search(r'\(\d+,\s*["\'](.*?)["\']\s*\)', raw_error) - if match: - return match.group(1).strip() + code = None + message = raw_error + recoverable = False - # Fallback: remove all (parenthesized) blocks which often contain codes - clean = re.sub(r'\(.*?\)', '', raw_error) - return clean.strip() + match = re.search(r'\((\d+),\s*["\'](.*?)["\']\s*\)', raw_error) + if match: + code = int(match.group(1)) + message = match.group(2).strip() + else: + # Fallback: remove all (parenthesized) blocks which often contain codes + message = re.sub(r'\(.*?\)', '', raw_error).strip() + + # 2. Categorize based on known MariaDB codes + # Ref: https://mariadb.com/kb/en/mariadb-error-codes/ + if code in [1062]: # Duplicate Entry + category = "database_duplicate" + elif code in [1451, 1452]: # Foreign Key Constraint + category = "database_constraint" + elif code in [1045, 2002, 2003, 2006]: # Connection / Auth issues + category = "database_connection" + recoverable = True + elif code in [1054, 1146]: # Unknown column / Table + category = "database_schema" + else: + category = "database" + + return StandardError( + category=category, + code=code, + message=message, + recoverable=recoverable, + details=raw_error if category == "database" else None # Only include raw details for uncategorized errors + ) def check_account_access(sql_result: Any, account: AccountContext, obj_name: str = None) -> bool: """ diff --git a/app/models/error_models.py b/app/models/error_models.py new file mode 100644 index 0000000..d315723 --- /dev/null +++ b/app/models/error_models.py @@ -0,0 +1,23 @@ +from typing import Optional, Any, Dict +from pydantic import BaseModel, Field + +class StandardError(BaseModel): + """ + Standardized machine-readable error structure for Aether. + Helps the frontend decide how to handle failures. + """ + category: str = Field(..., description="Error category (e.g., 'database', 'validation', 'security')") + code: Optional[int] = Field(None, description="Specific error code (e.g., MariaDB error code)") + message: str = Field(..., description="Developer-friendly error message") + recoverable: bool = Field(False, description="If True, the frontend might want to retry or ask for user input") + details: Optional[Any] = Field(None, description="Raw technical details or traceback (if permitted)") + + class Config: + schema_extra = { + "example": { + "category": "database", + "code": 1062, + "message": "Duplicate entry for key 'id_random'", + "recoverable": False + } + } diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 441f396..9806856 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -432,8 +432,9 @@ async def post_obj( return mk_resp(data=resp_data, response=response) return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response) else: + # Standardized rich error bubbling db_err = format_db_error(get_last_sql_error()) - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create object.", details=db_err) + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create object.", details=db_err.dict()) @router.patch('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) @@ -494,8 +495,9 @@ async def patch_obj( return mk_resp(data=resp_data, response=response) return mk_resp(data=True, response=response, status_message="Object updated successfully.") else: + # Standardized rich error bubbling db_err = format_db_error(get_last_sql_error()) - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to update object.", details=db_err) + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to update object.", details=db_err.dict()) @router.delete('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) diff --git a/app/routers/api_crud_v3_nested.py b/app/routers/api_crud_v3_nested.py index b76a248..0491e30 100644 --- a/app/routers/api_crud_v3_nested.py +++ b/app/routers/api_crud_v3_nested.py @@ -204,8 +204,9 @@ async def post_child_obj( return mk_resp(data=resp_data, response=response) return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response) else: + # Standardized rich error bubbling db_err = format_db_error(get_last_sql_error()) - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create child object.", details=db_err) + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create child object.", details=db_err.dict()) @router.get('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)