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,247 @@
|
|
|
1
|
+
"""FastAPI integration for awesome-errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import traceback
|
|
7
|
+
from typing import Any, Callable, Dict, Optional, cast
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, Request
|
|
10
|
+
from fastapi.exceptions import HTTPException, RequestValidationError
|
|
11
|
+
from fastapi.responses import JSONResponse
|
|
12
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
13
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
14
|
+
|
|
15
|
+
from ..core.error_codes import ErrorCode
|
|
16
|
+
from ..core.exceptions import AppError, ValidationError
|
|
17
|
+
from ..core.renderers import ErrorResponseFormat, ErrorResponseRenderer, RenderResult
|
|
18
|
+
from ..converters.sql_converter import SQLErrorConverter
|
|
19
|
+
from ..i18n.translator import ErrorTranslator
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ErrorHandlerMiddleware:
|
|
25
|
+
"""FastAPI middleware that converts exceptions into structured responses."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
app: FastAPI,
|
|
30
|
+
translator: Optional[ErrorTranslator] = None,
|
|
31
|
+
debug: bool = False,
|
|
32
|
+
log_errors: bool = True,
|
|
33
|
+
locales_dir: Optional[str] = None,
|
|
34
|
+
default_locale: str = "en",
|
|
35
|
+
response_format: ErrorResponseFormat = ErrorResponseFormat.LEGACY,
|
|
36
|
+
problem_type_resolver: Optional[Callable[[AppError], str]] = None,
|
|
37
|
+
problem_extension_builder: Optional[
|
|
38
|
+
Callable[[AppError], Dict[str, object]]
|
|
39
|
+
] = None,
|
|
40
|
+
message_resolver: Optional[
|
|
41
|
+
Callable[[AppError, Optional[str], Optional[ErrorTranslator]], str]
|
|
42
|
+
] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.app = app
|
|
45
|
+
|
|
46
|
+
if translator is None:
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
locales_path = Path(locales_dir) if locales_dir else None
|
|
50
|
+
self.translator = ErrorTranslator(
|
|
51
|
+
locales_dir=locales_path, default_locale=default_locale
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
self.translator = translator
|
|
55
|
+
|
|
56
|
+
self.message_resolver = message_resolver
|
|
57
|
+
self.debug = debug
|
|
58
|
+
self.log_errors = log_errors
|
|
59
|
+
self.renderer = ErrorResponseRenderer(
|
|
60
|
+
format=response_format,
|
|
61
|
+
problem_type_resolver=problem_type_resolver,
|
|
62
|
+
problem_extension_builder=problem_extension_builder,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self._register_handlers()
|
|
66
|
+
|
|
67
|
+
def _register_handlers(self) -> None:
|
|
68
|
+
self.app.add_exception_handler(AppError, cast(Any, self._handle_app_error))
|
|
69
|
+
self.app.add_exception_handler(
|
|
70
|
+
RequestValidationError, cast(Any, self._handle_validation_error)
|
|
71
|
+
)
|
|
72
|
+
self.app.add_exception_handler(HTTPException, cast(Any, self._handle_http_exception))
|
|
73
|
+
self.app.add_exception_handler(
|
|
74
|
+
StarletteHTTPException, cast(Any, self._handle_http_exception)
|
|
75
|
+
)
|
|
76
|
+
self.app.add_exception_handler(SQLAlchemyError, cast(Any, self._handle_sqlalchemy_error))
|
|
77
|
+
self.app.add_exception_handler(Exception, cast(Any, self._handle_generic_error))
|
|
78
|
+
|
|
79
|
+
async def _handle_app_error(self, request: Request, exc: AppError) -> JSONResponse:
|
|
80
|
+
locale = self._get_locale(request)
|
|
81
|
+
translated_message = self._resolve_message(exc, locale)
|
|
82
|
+
|
|
83
|
+
if self.log_errors:
|
|
84
|
+
logger.error(
|
|
85
|
+
f"App error: {exc.code.value} - {exc.message}",
|
|
86
|
+
extra={
|
|
87
|
+
"error_code": exc.code.value,
|
|
88
|
+
"details": exc.details,
|
|
89
|
+
"request_id": exc.request_id,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if exc.status_code >= 500:
|
|
94
|
+
self._print_stacktrace(
|
|
95
|
+
"500 ERROR",
|
|
96
|
+
Error_Code=exc.code.value,
|
|
97
|
+
Message=exc.message,
|
|
98
|
+
Request_ID=exc.request_id,
|
|
99
|
+
Details=exc.details,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
rendered: RenderResult = self.renderer.render(
|
|
103
|
+
exc, message=translated_message, request=request
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return JSONResponse(
|
|
107
|
+
content=rendered.payload,
|
|
108
|
+
status_code=exc.status_code,
|
|
109
|
+
media_type=rendered.media_type,
|
|
110
|
+
headers={"X-Request-ID": exc.request_id or "unknown"},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def _handle_validation_error(
|
|
114
|
+
self, request: Request, exc: RequestValidationError
|
|
115
|
+
) -> JSONResponse:
|
|
116
|
+
details = {"errors": exc.errors()}
|
|
117
|
+
|
|
118
|
+
error = ValidationError(
|
|
119
|
+
message="Request validation failed",
|
|
120
|
+
code=ErrorCode.VALIDATION_FAILED,
|
|
121
|
+
)
|
|
122
|
+
error.details = details
|
|
123
|
+
|
|
124
|
+
return await self._handle_app_error(request, error)
|
|
125
|
+
|
|
126
|
+
async def _handle_http_exception(
|
|
127
|
+
self, request: Request, exc: HTTPException
|
|
128
|
+
) -> JSONResponse:
|
|
129
|
+
status_to_code = {
|
|
130
|
+
400: ErrorCode.INVALID_INPUT,
|
|
131
|
+
401: ErrorCode.AUTH_REQUIRED,
|
|
132
|
+
403: ErrorCode.AUTH_PERMISSION_DENIED,
|
|
133
|
+
404: ErrorCode.RESOURCE_NOT_FOUND,
|
|
134
|
+
422: ErrorCode.VALIDATION_FAILED,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
error_code = status_to_code.get(exc.status_code, ErrorCode.UNKNOWN_ERROR)
|
|
138
|
+
details = {"http_detail": exc.detail}
|
|
139
|
+
|
|
140
|
+
error = AppError(
|
|
141
|
+
code=error_code,
|
|
142
|
+
message=str(exc.detail),
|
|
143
|
+
status_code=exc.status_code,
|
|
144
|
+
details=details,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if exc.status_code >= 500:
|
|
148
|
+
self._print_stacktrace(
|
|
149
|
+
"500 HTTP ERROR",
|
|
150
|
+
HTTP_Status=exc.status_code,
|
|
151
|
+
Error_Code=error_code.value,
|
|
152
|
+
Message=exc.detail,
|
|
153
|
+
Details=details,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return await self._handle_app_error(request, error)
|
|
157
|
+
|
|
158
|
+
async def _handle_sqlalchemy_error(
|
|
159
|
+
self, request: Request, exc: SQLAlchemyError
|
|
160
|
+
) -> JSONResponse:
|
|
161
|
+
error = SQLErrorConverter.convert(exc)
|
|
162
|
+
return await self._handle_app_error(request, error)
|
|
163
|
+
|
|
164
|
+
async def _handle_generic_error(
|
|
165
|
+
self, request: Request, exc: Exception
|
|
166
|
+
) -> JSONResponse:
|
|
167
|
+
if self.log_errors:
|
|
168
|
+
logger.exception("Unhandled exception")
|
|
169
|
+
|
|
170
|
+
self._print_stacktrace(
|
|
171
|
+
"UNHANDLED ERROR",
|
|
172
|
+
Exception_Type=type(exc).__name__,
|
|
173
|
+
Exception_Message=str(exc),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
error = AppError(
|
|
177
|
+
code=ErrorCode.INTERNAL_ERROR,
|
|
178
|
+
message="An internal error occurred" if not self.debug else str(exc),
|
|
179
|
+
status_code=500,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if self.debug:
|
|
183
|
+
error.details["traceback"] = traceback.format_exc()
|
|
184
|
+
|
|
185
|
+
return await self._handle_app_error(request, error)
|
|
186
|
+
|
|
187
|
+
def _get_locale(self, request: Request) -> Optional[str]:
|
|
188
|
+
accept_language = request.headers.get("Accept-Language", "")
|
|
189
|
+
if accept_language:
|
|
190
|
+
return accept_language.split(",")[0].split("-")[0]
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def _resolve_message(self, error: AppError, locale: Optional[str]) -> str:
|
|
194
|
+
if self.message_resolver:
|
|
195
|
+
return self.message_resolver(error, locale, self.translator)
|
|
196
|
+
|
|
197
|
+
translated = self.translator.translate(
|
|
198
|
+
error.code.value,
|
|
199
|
+
locale=locale,
|
|
200
|
+
params=error.details,
|
|
201
|
+
)
|
|
202
|
+
if translated == error.code.value:
|
|
203
|
+
return error.message
|
|
204
|
+
return translated
|
|
205
|
+
|
|
206
|
+
def _print_stacktrace(self, error_type: str, **kwargs: object) -> None:
|
|
207
|
+
logger.error(f"\n=== {error_type} STACKTRACE ===")
|
|
208
|
+
for key, value in kwargs.items():
|
|
209
|
+
logger.error(f"{key}: {value}")
|
|
210
|
+
logger.error("Stacktrace:")
|
|
211
|
+
traceback.print_exc(limit=30)
|
|
212
|
+
logger.error(f"=== END {error_type} STACKTRACE ===\n")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def setup_error_handling(
|
|
216
|
+
app: FastAPI,
|
|
217
|
+
translator: Optional[ErrorTranslator] = None,
|
|
218
|
+
debug: bool = False,
|
|
219
|
+
log_errors: bool = True,
|
|
220
|
+
locales_dir: Optional[str] = None,
|
|
221
|
+
default_locale: str = "en",
|
|
222
|
+
custom_translations: Optional[Dict[str, Dict[str, str]]] = None,
|
|
223
|
+
response_format: ErrorResponseFormat = ErrorResponseFormat.LEGACY,
|
|
224
|
+
problem_type_resolver: Optional[Callable[[AppError], str]] = None,
|
|
225
|
+
problem_extension_builder: Optional[Callable[[AppError], Dict[str, object]]] = None,
|
|
226
|
+
message_resolver: Optional[
|
|
227
|
+
Callable[[AppError, Optional[str], Optional[ErrorTranslator]], str]
|
|
228
|
+
] = None,
|
|
229
|
+
) -> ErrorHandlerMiddleware:
|
|
230
|
+
middleware = ErrorHandlerMiddleware(
|
|
231
|
+
app=app,
|
|
232
|
+
translator=translator,
|
|
233
|
+
debug=debug,
|
|
234
|
+
log_errors=log_errors,
|
|
235
|
+
locales_dir=locales_dir,
|
|
236
|
+
default_locale=default_locale,
|
|
237
|
+
response_format=response_format,
|
|
238
|
+
problem_type_resolver=problem_type_resolver,
|
|
239
|
+
problem_extension_builder=problem_extension_builder,
|
|
240
|
+
message_resolver=message_resolver,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if custom_translations:
|
|
244
|
+
for locale, translations in custom_translations.items():
|
|
245
|
+
middleware.translator.add_translations(locale, translations, persist=False)
|
|
246
|
+
|
|
247
|
+
return middleware
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Litestar integration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import traceback
|
|
7
|
+
from typing import TYPE_CHECKING, Callable, Dict, Mapping, Optional, Tuple, Type
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from litestar import Request
|
|
11
|
+
from litestar import Litestar
|
|
12
|
+
from litestar.exceptions import HTTPException, ValidationException
|
|
13
|
+
from litestar.response import Response
|
|
14
|
+
from litestar.types import ExceptionHandler
|
|
15
|
+
|
|
16
|
+
from ..core.error_codes import ErrorCode
|
|
17
|
+
from ..core.exceptions import AppError, ValidationError as CoreValidationError
|
|
18
|
+
from ..core.renderers import ErrorResponseFormat, ErrorResponseRenderer, RenderResult
|
|
19
|
+
from ..converters.sql_converter import SQLErrorConverter
|
|
20
|
+
from ..i18n.translator import ErrorTranslator
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_litestar_exception_handlers(
|
|
26
|
+
*,
|
|
27
|
+
translator: Optional[ErrorTranslator] = None,
|
|
28
|
+
debug: bool = False,
|
|
29
|
+
log_errors: bool = True,
|
|
30
|
+
log_level_resolver: Optional[Callable[[AppError], Optional[int]]] = None,
|
|
31
|
+
suppress_error_codes: Optional[Tuple[ErrorCode | str, ...]] = None,
|
|
32
|
+
locales_dir: Optional[str] = None,
|
|
33
|
+
default_locale: str = "en",
|
|
34
|
+
response_format: ErrorResponseFormat = ErrorResponseFormat.RFC7807,
|
|
35
|
+
problem_type_resolver: Optional[Callable[[AppError], str]] = None,
|
|
36
|
+
problem_extension_builder: Optional[Callable[[AppError], Dict[str, object]]] = None,
|
|
37
|
+
message_resolver: Optional[
|
|
38
|
+
Callable[[AppError, Optional[str], Optional[ErrorTranslator]], str]
|
|
39
|
+
] = None,
|
|
40
|
+
) -> Dict[Type[Exception], "ExceptionHandler"]:
|
|
41
|
+
"""Return a mapping of exception handlers configured for Litestar."""
|
|
42
|
+
|
|
43
|
+
if translator is None:
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
locales_path = Path(locales_dir) if locales_dir else None
|
|
47
|
+
translator = ErrorTranslator(
|
|
48
|
+
locales_dir=locales_path, default_locale=default_locale
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
renderer = ErrorResponseRenderer(
|
|
52
|
+
format=response_format,
|
|
53
|
+
problem_type_resolver=problem_type_resolver,
|
|
54
|
+
problem_extension_builder=problem_extension_builder,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
from litestar.exceptions import HTTPException, ValidationException # type: ignore
|
|
58
|
+
from litestar.response import Response # type: ignore
|
|
59
|
+
|
|
60
|
+
def resolve_message(error: AppError, locale: Optional[str]) -> str:
|
|
61
|
+
if message_resolver:
|
|
62
|
+
return message_resolver(error, locale, translator)
|
|
63
|
+
|
|
64
|
+
translated = translator.translate(
|
|
65
|
+
error.code.value,
|
|
66
|
+
locale=locale,
|
|
67
|
+
params=error.details,
|
|
68
|
+
)
|
|
69
|
+
if translated == error.code.value:
|
|
70
|
+
return error.message
|
|
71
|
+
return translated
|
|
72
|
+
|
|
73
|
+
suppressed_codes = {
|
|
74
|
+
code.value if isinstance(code, ErrorCode) else str(code)
|
|
75
|
+
for code in (suppress_error_codes or ())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def handle_app_error(request: "Request", exc: AppError) -> "Response":
|
|
79
|
+
locale = request.headers.get("Accept-Language")
|
|
80
|
+
if locale:
|
|
81
|
+
locale = locale.split(",")[0].split("-")[0]
|
|
82
|
+
|
|
83
|
+
if log_errors:
|
|
84
|
+
if exc.code.value not in suppressed_codes:
|
|
85
|
+
level = log_level_resolver(exc) if log_level_resolver else logging.ERROR
|
|
86
|
+
if level is not None:
|
|
87
|
+
logger.log(
|
|
88
|
+
level,
|
|
89
|
+
f"App error: {exc.code.value} - {exc.message}",
|
|
90
|
+
extra={
|
|
91
|
+
"error_code": exc.code.value,
|
|
92
|
+
"details": exc.details,
|
|
93
|
+
"request_id": exc.request_id,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if exc.status_code >= 500:
|
|
98
|
+
_print_stacktrace(
|
|
99
|
+
"500 ERROR",
|
|
100
|
+
Error_Code=exc.code.value,
|
|
101
|
+
Message=exc.message,
|
|
102
|
+
Request_ID=exc.request_id,
|
|
103
|
+
Details=exc.details,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
rendered: RenderResult = renderer.render(
|
|
107
|
+
exc, message=resolve_message(exc, locale), request=request
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return Response(
|
|
111
|
+
content=rendered.payload,
|
|
112
|
+
status_code=exc.status_code,
|
|
113
|
+
media_type=rendered.media_type,
|
|
114
|
+
headers={"X-Request-ID": exc.request_id or "unknown"},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def handle_validation_error(
|
|
118
|
+
request: "Request", exc: "ValidationException"
|
|
119
|
+
) -> "Response":
|
|
120
|
+
error = CoreValidationError(
|
|
121
|
+
message="Request validation failed",
|
|
122
|
+
code=ErrorCode.VALIDATION_FAILED,
|
|
123
|
+
)
|
|
124
|
+
errors = exc.extra or []
|
|
125
|
+
first_error = next(
|
|
126
|
+
(err for err in errors if isinstance(err, dict) and "path" in err),
|
|
127
|
+
None,
|
|
128
|
+
)
|
|
129
|
+
path = first_error.get("path") if first_error else None
|
|
130
|
+
if path is None:
|
|
131
|
+
path = getattr(exc, "path", None)
|
|
132
|
+
error.details = {
|
|
133
|
+
"errors": errors,
|
|
134
|
+
"path": path,
|
|
135
|
+
}
|
|
136
|
+
return handle_app_error(request, error)
|
|
137
|
+
|
|
138
|
+
def handle_http_exception(request: "Request", exc: "HTTPException") -> "Response":
|
|
139
|
+
status_to_code = {
|
|
140
|
+
400: ErrorCode.INVALID_INPUT,
|
|
141
|
+
401: ErrorCode.AUTH_REQUIRED,
|
|
142
|
+
403: ErrorCode.AUTH_PERMISSION_DENIED,
|
|
143
|
+
404: ErrorCode.RESOURCE_NOT_FOUND,
|
|
144
|
+
422: ErrorCode.VALIDATION_FAILED,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
error_code = status_to_code.get(exc.status_code, ErrorCode.UNKNOWN_ERROR)
|
|
148
|
+
error = AppError(
|
|
149
|
+
code=error_code,
|
|
150
|
+
message=str(exc.detail or exc.extra or exc.__class__.__name__),
|
|
151
|
+
status_code=exc.status_code,
|
|
152
|
+
details={
|
|
153
|
+
"detail": exc.detail,
|
|
154
|
+
"extra": exc.extra,
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if exc.status_code >= 500:
|
|
159
|
+
_print_stacktrace(
|
|
160
|
+
"500 HTTP ERROR",
|
|
161
|
+
HTTP_Status=exc.status_code,
|
|
162
|
+
Error_Code=error_code.value,
|
|
163
|
+
Message=exc.detail,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return handle_app_error(request, error)
|
|
167
|
+
|
|
168
|
+
def handle_sqlalchemy_error(request: "Request", exc: Exception) -> "Response":
|
|
169
|
+
error = SQLErrorConverter.convert(exc)
|
|
170
|
+
return handle_app_error(request, error)
|
|
171
|
+
|
|
172
|
+
def handle_generic_error(request: "Request", exc: Exception) -> "Response":
|
|
173
|
+
if log_errors:
|
|
174
|
+
logger.exception("Unhandled exception")
|
|
175
|
+
|
|
176
|
+
_print_stacktrace(
|
|
177
|
+
"UNHANDLED ERROR",
|
|
178
|
+
Exception_Type=type(exc).__name__,
|
|
179
|
+
Exception_Message=str(exc),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
error = AppError(
|
|
183
|
+
code=ErrorCode.INTERNAL_ERROR,
|
|
184
|
+
message="An internal error occurred" if not debug else str(exc),
|
|
185
|
+
status_code=500,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if debug:
|
|
189
|
+
error.details["traceback"] = traceback.format_exc()
|
|
190
|
+
|
|
191
|
+
return handle_app_error(request, error)
|
|
192
|
+
|
|
193
|
+
handlers: Dict[Type[Exception], "ExceptionHandler"] = {
|
|
194
|
+
AppError: handle_app_error,
|
|
195
|
+
ValidationException: handle_validation_error,
|
|
196
|
+
HTTPException: handle_http_exception,
|
|
197
|
+
Exception: handle_generic_error,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
from sqlalchemy.exc import SQLAlchemyError # type: ignore
|
|
202
|
+
|
|
203
|
+
handlers[SQLAlchemyError] = handle_sqlalchemy_error # type: ignore
|
|
204
|
+
except Exception: # pragma: no cover - SQLAlchemy already a dependency
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
return handlers
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _print_stacktrace(error_type: str, **kwargs: object) -> None:
|
|
211
|
+
logger.error(f"\n=== {error_type} STACKTRACE ===")
|
|
212
|
+
for key, value in kwargs.items():
|
|
213
|
+
logger.error(f"{key}: {value}")
|
|
214
|
+
logger.error("Stacktrace:")
|
|
215
|
+
traceback.print_exc(limit=30)
|
|
216
|
+
logger.error(f"=== END {error_type} STACKTRACE ===\n")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
_DEFAULT_PROBLEM_DETAILS: Dict[int, Tuple[str, str, str]] = {
|
|
220
|
+
400: ("VALIDATION_FAILED", "Request validation failed", "Bad Request"),
|
|
221
|
+
401: ("AUTH_REQUIRED", "Authentication required", "Unauthorized"),
|
|
222
|
+
403: ("AUTH_PERMISSION_DENIED", "Access denied", "Forbidden"),
|
|
223
|
+
404: ("RESOURCE_NOT_FOUND", "Resource not found", "Not Found"),
|
|
224
|
+
409: ("DB_DUPLICATE_ENTRY", "Resource conflict", "Conflict"),
|
|
225
|
+
422: ("VALIDATION_FAILED", "Unprocessable entity", "Unprocessable Entity"),
|
|
226
|
+
429: ("RATE_LIMIT_EXCEEDED", "Too many requests", "Too Many Requests"),
|
|
227
|
+
500: ("INTERNAL_ERROR", "Internal server error", "Internal Server Error"),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def apply_litestar_openapi_problem_details(
|
|
232
|
+
app: "Litestar",
|
|
233
|
+
*,
|
|
234
|
+
service_name: str,
|
|
235
|
+
status_defaults: Optional[Mapping[int, Tuple[str, str, str]]] = None,
|
|
236
|
+
example_instance: str = "/docs/openapi.json",
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Ensure generated OpenAPI documentation reflects RFC 7807 error payloads."""
|
|
239
|
+
try: # pragma: no cover - optional dependency
|
|
240
|
+
from litestar.openapi.spec.enums import OpenAPIFormat, OpenAPIType
|
|
241
|
+
from litestar.openapi.spec.media_type import OpenAPIMediaType
|
|
242
|
+
from litestar.openapi.spec.schema import Schema
|
|
243
|
+
except ImportError as exc: # pragma: no cover
|
|
244
|
+
raise ImportError("litestar must be installed to use this helper") from exc
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
schema = app.openapi_schema
|
|
248
|
+
except Exception: # pragma: no cover - OpenAPI disabled or misconfigured
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
if not schema or not getattr(schema, "paths", None):
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
defaults = dict(_DEFAULT_PROBLEM_DETAILS)
|
|
255
|
+
if status_defaults:
|
|
256
|
+
defaults.update(status_defaults)
|
|
257
|
+
|
|
258
|
+
problem_schema = Schema(
|
|
259
|
+
type=OpenAPIType.OBJECT,
|
|
260
|
+
required=[
|
|
261
|
+
"type",
|
|
262
|
+
"title",
|
|
263
|
+
"status",
|
|
264
|
+
"detail",
|
|
265
|
+
"instance",
|
|
266
|
+
"code",
|
|
267
|
+
"timestamp",
|
|
268
|
+
"request_id",
|
|
269
|
+
],
|
|
270
|
+
properties={
|
|
271
|
+
"type": Schema(type=OpenAPIType.STRING, format=OpenAPIFormat.URI),
|
|
272
|
+
"title": Schema(type=OpenAPIType.STRING),
|
|
273
|
+
"status": Schema(type=OpenAPIType.INTEGER),
|
|
274
|
+
"detail": Schema(type=OpenAPIType.STRING),
|
|
275
|
+
"instance": Schema(type=OpenAPIType.STRING, format=OpenAPIFormat.URI),
|
|
276
|
+
"code": Schema(type=OpenAPIType.STRING),
|
|
277
|
+
"timestamp": Schema(type=OpenAPIType.STRING, format=OpenAPIFormat.DATE_TIME),
|
|
278
|
+
"request_id": Schema(type=OpenAPIType.STRING),
|
|
279
|
+
"service": Schema(type=OpenAPIType.STRING),
|
|
280
|
+
"details": Schema(type=OpenAPIType.OBJECT, additional_properties=True),
|
|
281
|
+
},
|
|
282
|
+
additional_properties=True,
|
|
283
|
+
description="RFC 7807 compatible error payload produced by awesome-errors.",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
operations = ("delete", "get", "head", "options", "patch", "post", "put", "trace")
|
|
287
|
+
|
|
288
|
+
for path_item in (schema.paths or {}).values():
|
|
289
|
+
for operation_name in operations:
|
|
290
|
+
operation = getattr(path_item, operation_name, None)
|
|
291
|
+
if not operation or not getattr(operation, "responses", None):
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
for status, response in operation.responses.items():
|
|
295
|
+
try:
|
|
296
|
+
status_code = int(status)
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
if status_code < 400:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
error_code, detail, title = defaults.get(
|
|
304
|
+
status_code,
|
|
305
|
+
("UNKNOWN_ERROR", "An unexpected error occurred", "Error"),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
example_payload = {
|
|
309
|
+
"type": f"urn:{service_name}:error:{error_code.lower()}",
|
|
310
|
+
"title": title,
|
|
311
|
+
"status": status_code,
|
|
312
|
+
"detail": detail,
|
|
313
|
+
"instance": example_instance,
|
|
314
|
+
"code": error_code,
|
|
315
|
+
"timestamp": "2024-01-08T12:00:00Z",
|
|
316
|
+
"request_id": "req_example123",
|
|
317
|
+
"service": service_name,
|
|
318
|
+
"details": {"info": "Example payload"},
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
existing = getattr(response, "content", None) or {}
|
|
322
|
+
media_type = existing.get("application/problem+json")
|
|
323
|
+
if media_type:
|
|
324
|
+
media_type.schema = problem_schema
|
|
325
|
+
media_type.example = example_payload
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
response.content = {
|
|
329
|
+
"application/problem+json": OpenAPIMediaType(
|
|
330
|
+
schema=problem_schema,
|
|
331
|
+
example=example_payload,
|
|
332
|
+
)
|
|
333
|
+
}
|
awesome_errors/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""WebSocket support for awesome-errors"""
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
# Base classes
|
|
5
|
+
WebSocketError,
|
|
6
|
+
JSONRPCErrorCode,
|
|
7
|
+
# Specific errors
|
|
8
|
+
WebSocketAuthError,
|
|
9
|
+
WebSocketTokenExpiredError,
|
|
10
|
+
WebSocketRateLimitError,
|
|
11
|
+
WebSocketValidationError,
|
|
12
|
+
WebSocketMethodNotFoundError,
|
|
13
|
+
WebSocketInternalError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .error_handler import (
|
|
17
|
+
WebSocketErrorHandler,
|
|
18
|
+
setup_websocket_error_handling,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
# Base classes
|
|
23
|
+
"WebSocketError",
|
|
24
|
+
"JSONRPCErrorCode",
|
|
25
|
+
# Specific errors
|
|
26
|
+
"WebSocketAuthError",
|
|
27
|
+
"WebSocketTokenExpiredError",
|
|
28
|
+
"WebSocketRateLimitError",
|
|
29
|
+
"WebSocketValidationError",
|
|
30
|
+
"WebSocketMethodNotFoundError",
|
|
31
|
+
"WebSocketInternalError",
|
|
32
|
+
# Error handling
|
|
33
|
+
"WebSocketErrorHandler",
|
|
34
|
+
"setup_websocket_error_handling",
|
|
35
|
+
]
|