awesome-errors 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- awesome_errors/__init__.py +232 -0
- awesome_errors/analysis/__init__.py +8 -0
- awesome_errors/analysis/decorators.py +251 -0
- awesome_errors/analysis/error_analyzer.py +463 -0
- awesome_errors/client/__init__.py +7 -0
- awesome_errors/client/exceptions.py +97 -0
- awesome_errors/client/response_parser.py +175 -0
- awesome_errors/converters/__init__.py +31 -0
- awesome_errors/converters/generic.py +23 -0
- awesome_errors/converters/pydantic_converter.py +75 -0
- awesome_errors/converters/python_converter.py +80 -0
- awesome_errors/converters/sql_converter.py +258 -0
- awesome_errors/converters/universal_converter.py +123 -0
- awesome_errors/core/__init__.py +0 -0
- awesome_errors/core/error_codes.py +104 -0
- awesome_errors/core/error_response.py +61 -0
- awesome_errors/core/exceptions.py +613 -0
- awesome_errors/core/renderers.py +115 -0
- awesome_errors/i18n/__init__.py +0 -0
- awesome_errors/i18n/locales/en/errors.json +32 -0
- awesome_errors/i18n/locales/test/errors.json +4 -0
- awesome_errors/i18n/locales/uk/errors.json +26 -0
- awesome_errors/i18n/translator.py +150 -0
- awesome_errors/integrations/__init__.py +11 -0
- awesome_errors/integrations/fastapi_auto_docs.py +220 -0
- awesome_errors/litestar_utils.py +55 -0
- awesome_errors/middleware/__init__.py +0 -0
- awesome_errors/middleware/fastapi.py +247 -0
- awesome_errors/middleware/litestar.py +333 -0
- awesome_errors/py.typed +1 -0
- awesome_errors/websocket/__init__.py +35 -0
- awesome_errors/websocket/error_handler.py +231 -0
- awesome_errors/websocket/exceptions.py +286 -0
- awesome_errors-0.3.0.dist-info/METADATA +239 -0
- awesome_errors-0.3.0.dist-info/RECORD +37 -0
- awesome_errors-0.3.0.dist-info/WHEEL +4 -0
- awesome_errors-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from ..core.error_codes import ErrorCode
|
|
4
|
+
from ..core.exceptions import AppError
|
|
5
|
+
from .generic import generic_error_handler
|
|
6
|
+
from .python_converter import PythonErrorConverter
|
|
7
|
+
from .sql_converter import SQLErrorConverter
|
|
8
|
+
|
|
9
|
+
SQLAlchemyErrorType: type[Exception] | None = None
|
|
10
|
+
try: # pragma: no cover - optional dependency
|
|
11
|
+
from sqlalchemy.exc import SQLAlchemyError as _LoadedSQLAlchemyError
|
|
12
|
+
except ImportError: # pragma: no cover
|
|
13
|
+
pass
|
|
14
|
+
else: # pragma: no cover
|
|
15
|
+
SQLAlchemyErrorType = _LoadedSQLAlchemyError
|
|
16
|
+
|
|
17
|
+
PydanticValidationErrorType: type[Exception] | None = None
|
|
18
|
+
try: # pragma: no cover - optional dependency
|
|
19
|
+
from pydantic import ValidationError as _LoadedPydanticValidationError
|
|
20
|
+
except ImportError: # pragma: no cover
|
|
21
|
+
pass
|
|
22
|
+
else: # pragma: no cover
|
|
23
|
+
PydanticValidationErrorType = _LoadedPydanticValidationError
|
|
24
|
+
|
|
25
|
+
_PydanticErrorConverter: Any = None
|
|
26
|
+
try: # pragma: no cover - optional dependency
|
|
27
|
+
from .pydantic_converter import PydanticErrorConverter as _LoadedPydanticErrorConverter
|
|
28
|
+
except ImportError: # pragma: no cover
|
|
29
|
+
pass
|
|
30
|
+
else: # pragma: no cover
|
|
31
|
+
_PydanticErrorConverter = _LoadedPydanticErrorConverter
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UniversalErrorConverter:
|
|
35
|
+
"""Universal error converter that handles any type of exception."""
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def convert(cls, error: Exception, debug: bool = False) -> AppError:
|
|
39
|
+
"""
|
|
40
|
+
Convert any exception to AppError.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
error: Any exception
|
|
44
|
+
debug: Include debug information
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
AppError instance with appropriate details
|
|
48
|
+
"""
|
|
49
|
+
# Check if it's already an AppError
|
|
50
|
+
if isinstance(error, AppError):
|
|
51
|
+
return error
|
|
52
|
+
|
|
53
|
+
# Pydantic validation errors
|
|
54
|
+
if PydanticValidationErrorType is not None and isinstance(
|
|
55
|
+
error, PydanticValidationErrorType
|
|
56
|
+
):
|
|
57
|
+
if _PydanticErrorConverter is None:
|
|
58
|
+
raise ImportError(
|
|
59
|
+
"Install 'awesome-errors[pydantic]' to convert Pydantic validation errors."
|
|
60
|
+
) from None
|
|
61
|
+
return _PydanticErrorConverter.convert(error)
|
|
62
|
+
|
|
63
|
+
# SQLAlchemy errors
|
|
64
|
+
if SQLAlchemyErrorType is not None and isinstance(error, SQLAlchemyErrorType):
|
|
65
|
+
return SQLErrorConverter.convert(error)
|
|
66
|
+
|
|
67
|
+
# Standard Python exceptions
|
|
68
|
+
if type(error) in PythonErrorConverter.EXCEPTION_MAP:
|
|
69
|
+
return PythonErrorConverter.convert(error)
|
|
70
|
+
|
|
71
|
+
# Handle other specific error types
|
|
72
|
+
app_error = cls._handle_special_cases(error)
|
|
73
|
+
if app_error:
|
|
74
|
+
return app_error
|
|
75
|
+
|
|
76
|
+
# Default handling for unknown errors
|
|
77
|
+
return generic_error_handler(error, debug)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def _handle_special_cases(cls, error: Exception) -> Optional[AppError]:
|
|
81
|
+
"""Handle special error cases."""
|
|
82
|
+
error_type = type(error).__name__
|
|
83
|
+
error_str = str(error)
|
|
84
|
+
|
|
85
|
+
# Handle HTTP-related errors
|
|
86
|
+
if "HTTPError" in error_type:
|
|
87
|
+
return AppError(
|
|
88
|
+
code=ErrorCode.INTERNAL_ERROR,
|
|
89
|
+
message="HTTP request failed",
|
|
90
|
+
details={"error_type": error_type, "error": error_str},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Handle JSON errors
|
|
94
|
+
if "JSONDecodeError" in error_type:
|
|
95
|
+
return AppError(
|
|
96
|
+
code=ErrorCode.INVALID_FORMAT,
|
|
97
|
+
message="Invalid JSON format",
|
|
98
|
+
details={"error_type": error_type, "error": error_str},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Handle import errors
|
|
102
|
+
if isinstance(error, (ImportError, ModuleNotFoundError)):
|
|
103
|
+
return AppError(
|
|
104
|
+
code=ErrorCode.INTERNAL_ERROR,
|
|
105
|
+
message="Missing required module",
|
|
106
|
+
details={
|
|
107
|
+
"module": error.name if hasattr(error, "name") else "unknown",
|
|
108
|
+
"error": error_str,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _is_serializable(value: Any) -> bool:
|
|
116
|
+
"""Check if value can be safely serialized."""
|
|
117
|
+
try:
|
|
118
|
+
import json
|
|
119
|
+
|
|
120
|
+
json.dumps(value)
|
|
121
|
+
return True
|
|
122
|
+
except Exception:
|
|
123
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ErrorCode(StrEnum):
|
|
6
|
+
# General errors
|
|
7
|
+
UNKNOWN_ERROR = "UNKNOWN_ERROR"
|
|
8
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
9
|
+
|
|
10
|
+
# Validation errors (400)
|
|
11
|
+
VALIDATION_FAILED = "VALIDATION_FAILED"
|
|
12
|
+
INVALID_INPUT = "INVALID_INPUT"
|
|
13
|
+
MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD"
|
|
14
|
+
INVALID_FORMAT = "INVALID_FORMAT"
|
|
15
|
+
|
|
16
|
+
# Authentication errors (401)
|
|
17
|
+
AUTH_REQUIRED = "AUTH_REQUIRED"
|
|
18
|
+
AUTH_INVALID_TOKEN = "AUTH_INVALID_TOKEN"
|
|
19
|
+
AUTH_TOKEN_EXPIRED = "AUTH_TOKEN_EXPIRED"
|
|
20
|
+
REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE"
|
|
21
|
+
SESSION_EXPIRED = "SESSION_EXPIRED"
|
|
22
|
+
|
|
23
|
+
# Authorization errors (403)
|
|
24
|
+
AUTH_PERMISSION_DENIED = "AUTH_PERMISSION_DENIED"
|
|
25
|
+
AUTH_INSUFFICIENT_PRIVILEGES = "AUTH_INSUFFICIENT_PRIVILEGES"
|
|
26
|
+
|
|
27
|
+
# Not found errors (404)
|
|
28
|
+
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
|
|
29
|
+
RESOURCE_ALREADY_EXISTS = "RESOURCE_ALREADY_EXISTS"
|
|
30
|
+
USER_NOT_FOUND = "USER_NOT_FOUND"
|
|
31
|
+
ENTITY_NOT_FOUND = "ENTITY_NOT_FOUND"
|
|
32
|
+
OAUTH_PROVIDER_UNKNOWN = "OAUTH_PROVIDER_UNKNOWN"
|
|
33
|
+
|
|
34
|
+
# Database errors (500)
|
|
35
|
+
DB_CONNECTION_ERROR = "DB_CONNECTION_ERROR"
|
|
36
|
+
DB_QUERY_ERROR = "DB_QUERY_ERROR"
|
|
37
|
+
DB_TRANSACTION_ERROR = "DB_TRANSACTION_ERROR"
|
|
38
|
+
DB_CONSTRAINT_VIOLATION = "DB_CONSTRAINT_VIOLATION"
|
|
39
|
+
DB_DUPLICATE_ENTRY = "DB_DUPLICATE_ENTRY"
|
|
40
|
+
DB_INVALID_REFERENCE = "DB_INVALID_REFERENCE"
|
|
41
|
+
DB_MISSING_REQUIRED = "DB_MISSING_REQUIRED"
|
|
42
|
+
|
|
43
|
+
# Business logic errors
|
|
44
|
+
BUSINESS_RULE_VIOLATION = "BUSINESS_RULE_VIOLATION"
|
|
45
|
+
INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
|
|
46
|
+
OPERATION_NOT_ALLOWED = "OPERATION_NOT_ALLOWED"
|
|
47
|
+
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
|
|
48
|
+
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def _missing_(cls, value):
|
|
52
|
+
"""Create new ErrorCode for unknown values."""
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
# Create a pseudo member for custom values
|
|
55
|
+
pseudo_member = str.__new__(cls, value)
|
|
56
|
+
pseudo_member._name_ = value
|
|
57
|
+
pseudo_member._value_ = value
|
|
58
|
+
return pseudo_member
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
ERROR_HTTP_STATUS_MAP: Dict[ErrorCode, int] = {
|
|
63
|
+
# 400 Bad Request
|
|
64
|
+
ErrorCode.VALIDATION_FAILED: 400,
|
|
65
|
+
ErrorCode.INVALID_INPUT: 400,
|
|
66
|
+
ErrorCode.MISSING_REQUIRED_FIELD: 400,
|
|
67
|
+
ErrorCode.INVALID_FORMAT: 400,
|
|
68
|
+
# 401 Unauthorized
|
|
69
|
+
ErrorCode.AUTH_REQUIRED: 401,
|
|
70
|
+
ErrorCode.AUTH_INVALID_TOKEN: 401,
|
|
71
|
+
ErrorCode.AUTH_TOKEN_EXPIRED: 401,
|
|
72
|
+
ErrorCode.REFRESH_TOKEN_REUSE: 401,
|
|
73
|
+
ErrorCode.SESSION_EXPIRED: 401,
|
|
74
|
+
# 403 Forbidden
|
|
75
|
+
ErrorCode.AUTH_PERMISSION_DENIED: 403,
|
|
76
|
+
ErrorCode.AUTH_INSUFFICIENT_PRIVILEGES: 403,
|
|
77
|
+
# 404 Not Found
|
|
78
|
+
ErrorCode.RESOURCE_NOT_FOUND: 404,
|
|
79
|
+
ErrorCode.RESOURCE_ALREADY_EXISTS: 409,
|
|
80
|
+
ErrorCode.USER_NOT_FOUND: 404,
|
|
81
|
+
ErrorCode.ENTITY_NOT_FOUND: 404,
|
|
82
|
+
ErrorCode.OAUTH_PROVIDER_UNKNOWN: 404,
|
|
83
|
+
# 422 Unprocessable Entity
|
|
84
|
+
ErrorCode.BUSINESS_RULE_VIOLATION: 422,
|
|
85
|
+
ErrorCode.INSUFFICIENT_BALANCE: 422,
|
|
86
|
+
ErrorCode.OPERATION_NOT_ALLOWED: 422,
|
|
87
|
+
ErrorCode.RATE_LIMIT_EXCEEDED: 429,
|
|
88
|
+
# 500 Internal Server Error
|
|
89
|
+
ErrorCode.UNKNOWN_ERROR: 500,
|
|
90
|
+
ErrorCode.INTERNAL_ERROR: 500,
|
|
91
|
+
ErrorCode.SERVICE_UNAVAILABLE: 503,
|
|
92
|
+
ErrorCode.DB_CONNECTION_ERROR: 500,
|
|
93
|
+
ErrorCode.DB_QUERY_ERROR: 500,
|
|
94
|
+
ErrorCode.DB_TRANSACTION_ERROR: 500,
|
|
95
|
+
ErrorCode.DB_CONSTRAINT_VIOLATION: 409, # Conflict
|
|
96
|
+
ErrorCode.DB_DUPLICATE_ENTRY: 409,
|
|
97
|
+
ErrorCode.DB_INVALID_REFERENCE: 422,
|
|
98
|
+
ErrorCode.DB_MISSING_REQUIRED: 422,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_http_status(error_code: ErrorCode) -> int:
|
|
103
|
+
"""Get HTTP status code for an error code."""
|
|
104
|
+
return ERROR_HTTP_STATUS_MAP.get(error_code, 500)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Core error response models built on msgspec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, Mapping
|
|
7
|
+
|
|
8
|
+
import msgspec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _now_utc() -> datetime:
|
|
12
|
+
return datetime.now(timezone.utc)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ErrorDetail(msgspec.Struct, kw_only=True, omit_defaults=True):
|
|
16
|
+
"""Serializable representation of an application error."""
|
|
17
|
+
|
|
18
|
+
code: str
|
|
19
|
+
message: str
|
|
20
|
+
request_id: str
|
|
21
|
+
details: Dict[str, Any] = msgspec.field(default_factory=dict)
|
|
22
|
+
timestamp: datetime = msgspec.field(default_factory=_now_utc)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
25
|
+
"""Convert to builtin types, ensuring ISO timestamps."""
|
|
26
|
+
data = msgspec.to_builtins(self, builtin_types=None)
|
|
27
|
+
timestamp = data.get("timestamp")
|
|
28
|
+
if isinstance(timestamp, datetime):
|
|
29
|
+
data["timestamp"] = timestamp.isoformat().replace("+00:00", "Z")
|
|
30
|
+
elif isinstance(timestamp, str):
|
|
31
|
+
data["timestamp"] = timestamp
|
|
32
|
+
else:
|
|
33
|
+
data["timestamp"] = _now_utc().isoformat().replace("+00:00", "Z")
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ErrorResponse(msgspec.Struct, kw_only=True, omit_defaults=True):
|
|
38
|
+
"""Legacy `{"error": {...}}` envelope."""
|
|
39
|
+
|
|
40
|
+
error: ErrorDetail
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
43
|
+
return {"error": self.error.to_dict()}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def error_detail_from_mapping(data: Mapping[str, Any]) -> ErrorDetail:
|
|
47
|
+
"""Construct an ``ErrorDetail`` from mapping data."""
|
|
48
|
+
timestamp = data.get("timestamp")
|
|
49
|
+
if isinstance(timestamp, str):
|
|
50
|
+
try:
|
|
51
|
+
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
52
|
+
except ValueError:
|
|
53
|
+
timestamp = _now_utc()
|
|
54
|
+
|
|
55
|
+
return ErrorDetail(
|
|
56
|
+
code=str(data.get("code", "UNKNOWN_ERROR")),
|
|
57
|
+
message=str(data.get("message", "")),
|
|
58
|
+
details=dict(data.get("details") or {}),
|
|
59
|
+
timestamp=timestamp if isinstance(timestamp, datetime) else _now_utc(),
|
|
60
|
+
request_id=str(data.get("request_id", "")),
|
|
61
|
+
)
|