af-fastapi-exceptions 0.0.2__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.
- af_fastapi_exceptions-0.0.2.dist-info/METADATA +127 -0
- af_fastapi_exceptions-0.0.2.dist-info/RECORD +7 -0
- af_fastapi_exceptions-0.0.2.dist-info/WHEEL +4 -0
- allfly/fastapi/exceptions/__init__.py +41 -0
- allfly/fastapi/exceptions/exceptions.py +70 -0
- allfly/fastapi/exceptions/middleware.py +116 -0
- allfly/fastapi/exceptions/py.typed +0 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: af-fastapi-exceptions
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Reusable FastAPI base exceptions, ApiMessage, and a unified exception handler
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Allfly
|
|
7
|
+
Author-email: engineering@allfly.io
|
|
8
|
+
Requires-Python: >=3.13,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: fastapi (>=0.100.0)
|
|
14
|
+
Requires-Dist: loguru (>=0.7.0)
|
|
15
|
+
Requires-Dist: starlette (>=0.27.0)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# af-fastapi-exceptions
|
|
19
|
+
|
|
20
|
+
Reusable exception handling for FastAPI apps: a base `ServiceError` hierarchy you can raise
|
|
21
|
+
(and extend), plus a middleware + validation handler so that **every** error — raised before,
|
|
22
|
+
during, or after the route — reaches the client as the same JSON body.
|
|
23
|
+
|
|
24
|
+
**Bring your own response model.** The library never defines or imposes a response schema —
|
|
25
|
+
you pass your own model down, and the handlers use only the slice they need (construct it with
|
|
26
|
+
`message` + `errors`, then call `.model_dump()`). Your app keeps one model for both success and
|
|
27
|
+
error responses; nothing is coupled across the boundary.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install af-fastapi-exceptions
|
|
33
|
+
# or with Poetry:
|
|
34
|
+
poetry add af-fastapi-exceptions
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Raising errors
|
|
38
|
+
|
|
39
|
+
Raise a `ServiceError` (or a subclass) anywhere; the middleware turns it into your response
|
|
40
|
+
model with the right status code.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from allfly.fastapi.exceptions import EntityNotFoundError, ForbiddenError
|
|
44
|
+
|
|
45
|
+
def get_user(user_id: str):
|
|
46
|
+
user = repo.find(user_id)
|
|
47
|
+
if not user:
|
|
48
|
+
raise EntityNotFoundError(f"No user {user_id}") # -> 404
|
|
49
|
+
if not user.active:
|
|
50
|
+
raise ForbiddenError() # -> 403
|
|
51
|
+
return user
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Built-in classes: `UnauthorizedRequestError` (401), `ForbiddenError` (403),
|
|
55
|
+
`BadRequestError` (400), `EntityNotFoundError` (404), `ResourceConflictError` (409),
|
|
56
|
+
`UnprocessableRequestError` (422), `UnsupportedFeatureError` (501),
|
|
57
|
+
`DownstreamServiceError` (502).
|
|
58
|
+
|
|
59
|
+
`ServiceWarning` is a `ServiceError` subclass for **expected/recoverable** conditions — logged
|
|
60
|
+
at `warning` level instead of `error`. `ForbiddenError` and `UnsupportedFeatureError` are
|
|
61
|
+
warnings.
|
|
62
|
+
|
|
63
|
+
### Extend them
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from allfly.fastapi.exceptions import UnprocessableRequestError
|
|
67
|
+
|
|
68
|
+
class FopError(UnprocessableRequestError):
|
|
69
|
+
def __init__(self, message="Payment method was rejected."):
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Your response model
|
|
74
|
+
|
|
75
|
+
Provide any model whose instances have a `.model_dump()` and that can be constructed with
|
|
76
|
+
`message=` and `errors=` (a pydantic model with those two fields — plus whatever else you want,
|
|
77
|
+
e.g. `timestamp`, `metadata` — is the common case). The library only ever sets `message` and
|
|
78
|
+
`errors`; the rest come from your model's defaults. This is the `ApiErrorBody` protocol:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
class ApiErrorBody(Protocol):
|
|
82
|
+
def __init__(self, *, message: str, errors: list[str]) -> None: ...
|
|
83
|
+
def model_dump(self, *, mode: str = "json", exclude_none: bool = True) -> dict: ...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Wiring it into your app
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from fastapi import FastAPI
|
|
90
|
+
from fastapi.exceptions import RequestValidationError
|
|
91
|
+
from allfly.fastapi.exceptions import (
|
|
92
|
+
ExceptionMiddleware,
|
|
93
|
+
DefaultExceptionHandlerSettings,
|
|
94
|
+
build_validation_handler,
|
|
95
|
+
build_error_responses,
|
|
96
|
+
)
|
|
97
|
+
from myapp.models import ApiMessage # <-- YOUR model
|
|
98
|
+
|
|
99
|
+
app = FastAPI(responses=build_error_responses(ApiMessage)) # OpenAPI error schemas
|
|
100
|
+
|
|
101
|
+
# Catches ServiceError (-> its status) and any unexpected Exception (-> 500).
|
|
102
|
+
app.add_middleware(
|
|
103
|
+
ExceptionMiddleware,
|
|
104
|
+
error_model=ApiMessage,
|
|
105
|
+
settings=DefaultExceptionHandlerSettings(production=is_production()),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# RequestValidationError is raised during request parsing, before the middleware runs,
|
|
109
|
+
# so register it as an exception handler too — same body, built from your model.
|
|
110
|
+
app.add_exception_handler(RequestValidationError, build_validation_handler(ApiMessage))
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Settings (why not read your env directly?)
|
|
114
|
+
|
|
115
|
+
The middleware only needs to know one thing: **are we in production?** (In production the 500
|
|
116
|
+
handler hides the raw exception string; otherwise it includes it to aid debugging.)
|
|
117
|
+
|
|
118
|
+
Rather than force a settings system on you, it takes any object matching the
|
|
119
|
+
`ExceptionHandlerSettings` protocol — a single `production: bool`. Use the provided
|
|
120
|
+
`DefaultExceptionHandlerSettings`, or pass your own object exposing `production`.
|
|
121
|
+
|
|
122
|
+
## Logging
|
|
123
|
+
|
|
124
|
+
Handlers log via [loguru](https://github.com/Delgan/loguru) — `ServiceWarning` at `warning`,
|
|
125
|
+
real errors at `error`/`exception`. If your app doesn't configure loguru, the messages are
|
|
126
|
+
simply not emitted at higher levels by default.
|
|
127
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
allfly/fastapi/exceptions/__init__.py,sha256=V7vElyhWtXV56z3h78CbH4OHS3k9Y8ORYz7wob8zpXg,1008
|
|
2
|
+
allfly/fastapi/exceptions/exceptions.py,sha256=QajjDtTfhYnpj_l22hwbaxfDuH0NuKCmuKF8Y3nzBJQ,2067
|
|
3
|
+
allfly/fastapi/exceptions/middleware.py,sha256=hUYW0Uk96UE1PyO0vWaUqBcd9RnLwqTMIRpKtWRY7mY,4736
|
|
4
|
+
allfly/fastapi/exceptions/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
af_fastapi_exceptions-0.0.2.dist-info/METADATA,sha256=CpXvYi8lTJcjhLNk2KLIDWNYP4VKu_03t7pvENe9Rpw,4753
|
|
6
|
+
af_fastapi_exceptions-0.0.2.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
7
|
+
af_fastapi_exceptions-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from allfly.fastapi.exceptions.exceptions import (
|
|
2
|
+
BadRequestError,
|
|
3
|
+
DownstreamServiceError,
|
|
4
|
+
EntityNotFoundError,
|
|
5
|
+
ForbiddenError,
|
|
6
|
+
ResourceConflictError,
|
|
7
|
+
ServiceError,
|
|
8
|
+
ServiceWarning,
|
|
9
|
+
UnauthorizedRequestError,
|
|
10
|
+
UnprocessableRequestError,
|
|
11
|
+
UnsupportedFeatureError,
|
|
12
|
+
)
|
|
13
|
+
from allfly.fastapi.exceptions.middleware import (
|
|
14
|
+
ApiErrorBody,
|
|
15
|
+
DefaultExceptionHandlerSettings,
|
|
16
|
+
ExceptionHandlerSettings,
|
|
17
|
+
ExceptionMiddleware,
|
|
18
|
+
build_error_responses,
|
|
19
|
+
build_validation_handler,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.0.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ServiceError",
|
|
26
|
+
"ServiceWarning",
|
|
27
|
+
"UnauthorizedRequestError",
|
|
28
|
+
"ForbiddenError",
|
|
29
|
+
"BadRequestError",
|
|
30
|
+
"EntityNotFoundError",
|
|
31
|
+
"ResourceConflictError",
|
|
32
|
+
"UnprocessableRequestError",
|
|
33
|
+
"UnsupportedFeatureError",
|
|
34
|
+
"DownstreamServiceError",
|
|
35
|
+
"ExceptionMiddleware",
|
|
36
|
+
"build_validation_handler",
|
|
37
|
+
"build_error_responses",
|
|
38
|
+
"ApiErrorBody",
|
|
39
|
+
"ExceptionHandlerSettings",
|
|
40
|
+
"DefaultExceptionHandlerSettings",
|
|
41
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from starlette import status
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ServiceError(Exception):
|
|
8
|
+
message: str
|
|
9
|
+
status_code: int
|
|
10
|
+
|
|
11
|
+
def __post_init__(self):
|
|
12
|
+
super().__init__()
|
|
13
|
+
|
|
14
|
+
def __str__(self):
|
|
15
|
+
return self.message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ServiceWarning(ServiceError): # noqa: N818 — intentionally not named *Error; these are expected conditions
|
|
20
|
+
"""Raised for expected/recoverable conditions that don't warrant error-level logging."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnauthorizedRequestError(ServiceError):
|
|
26
|
+
def __init__(self, message="Unauthorized Request"):
|
|
27
|
+
status_code = status.HTTP_401_UNAUTHORIZED
|
|
28
|
+
super().__init__(message, status_code)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ForbiddenError(ServiceWarning):
|
|
32
|
+
def __init__(self, message="Forbidden Request"):
|
|
33
|
+
status_code = status.HTTP_403_FORBIDDEN
|
|
34
|
+
super().__init__(message, status_code)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BadRequestError(ServiceError):
|
|
38
|
+
def __init__(self, message="Bad Request"):
|
|
39
|
+
status_code = status.HTTP_400_BAD_REQUEST
|
|
40
|
+
super().__init__(message, status_code)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EntityNotFoundError(ServiceError):
|
|
44
|
+
def __init__(self, message="Entity not found"):
|
|
45
|
+
status_code = status.HTTP_404_NOT_FOUND
|
|
46
|
+
super().__init__(message, status_code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResourceConflictError(ServiceError):
|
|
50
|
+
def __init__(self, message="Request creates a conflict"):
|
|
51
|
+
status_code = status.HTTP_409_CONFLICT
|
|
52
|
+
super().__init__(message, status_code)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UnprocessableRequestError(ServiceError):
|
|
56
|
+
def __init__(self, message="Unprocessable Request"):
|
|
57
|
+
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
58
|
+
super().__init__(message, status_code)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UnsupportedFeatureError(ServiceWarning):
|
|
62
|
+
def __init__(self, message="Not implemented"):
|
|
63
|
+
status_code = status.HTTP_501_NOT_IMPLEMENTED
|
|
64
|
+
super().__init__(message, status_code)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DownstreamServiceError(ServiceError):
|
|
68
|
+
def __init__(self, message="Downstream service error"):
|
|
69
|
+
status_code = status.HTTP_502_BAD_GATEWAY
|
|
70
|
+
super().__init__(message, status_code)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Protocol
|
|
3
|
+
|
|
4
|
+
from fastapi.exceptions import RequestValidationError
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from starlette import status
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from allfly.fastapi.exceptions.exceptions import ServiceError, ServiceWarning
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiErrorBody(Protocol):
|
|
15
|
+
def __init__(self, *, message: str, errors: list[str]) -> None: ...
|
|
16
|
+
|
|
17
|
+
def model_dump(self, *, mode: str = "json", exclude_none: bool = True) -> dict[str, Any]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExceptionHandlerSettings(Protocol):
|
|
21
|
+
production: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class DefaultExceptionHandlerSettings:
|
|
26
|
+
production: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _handle_unexpected_error(
|
|
30
|
+
request: Request, e: Exception, settings: ExceptionHandlerSettings, error_model: type[ApiErrorBody]
|
|
31
|
+
) -> JSONResponse:
|
|
32
|
+
logger.opt(exception=e).exception("Internal server error occurred", extra={"path": request.url.path}, exc_info=True)
|
|
33
|
+
errors = [] if settings.production else [str(e)]
|
|
34
|
+
body = error_model(message="internal server error", errors=errors)
|
|
35
|
+
return JSONResponse(
|
|
36
|
+
content=body.model_dump(mode="json", exclude_none=True),
|
|
37
|
+
media_type="application/json",
|
|
38
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _handle_service_error(request: Request, e: ServiceError, error_model: type[ApiErrorBody]) -> JSONResponse:
|
|
43
|
+
if isinstance(e, ServiceWarning):
|
|
44
|
+
logger.warning(
|
|
45
|
+
"Expected service condition",
|
|
46
|
+
extra={"path": request.url.path, "error_type": type(e).__name__},
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
logger.opt(exception=e).exception(
|
|
50
|
+
"Service error occurred",
|
|
51
|
+
extra={"path": request.url.path, "error_type": type(e).__name__},
|
|
52
|
+
)
|
|
53
|
+
body = error_model(message="service error", errors=[e.message])
|
|
54
|
+
response_content = body.model_dump(mode="json", exclude_none=True)
|
|
55
|
+
if not isinstance(e, ServiceWarning):
|
|
56
|
+
logger.error(f"ServiceError response - status: {e.status_code}, content: {response_content}")
|
|
57
|
+
return JSONResponse(
|
|
58
|
+
content=response_content,
|
|
59
|
+
media_type="application/json",
|
|
60
|
+
status_code=e.status_code,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_validation_handler(error_model: type[ApiErrorBody]):
|
|
65
|
+
async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
66
|
+
logger.warning(f"Validation error details: {exc.errors()}")
|
|
67
|
+
errors = []
|
|
68
|
+
for error in exc.errors():
|
|
69
|
+
location = ".".join(str(loc) for loc in error["loc"] if loc != "body")
|
|
70
|
+
message = error["msg"]
|
|
71
|
+
error_type = error.get("type", "unknown")
|
|
72
|
+
detailed_error = (
|
|
73
|
+
f"Field: {location if location else 'N/A'}, Error: {message}, Type: {error_type}, Received: "
|
|
74
|
+
f"{error.get('input', 'not provided')}"
|
|
75
|
+
)
|
|
76
|
+
errors.append(detailed_error)
|
|
77
|
+
logger.warning(detailed_error)
|
|
78
|
+
body = error_model(message="Request Validation Error", errors=errors)
|
|
79
|
+
return JSONResponse(
|
|
80
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
81
|
+
content=body.model_dump(mode="json", exclude_none=True),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return handle_validation_error
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_error_responses(error_model: type[ApiErrorBody]) -> dict:
|
|
88
|
+
return {
|
|
89
|
+
status.HTTP_401_UNAUTHORIZED: {
|
|
90
|
+
"model": error_model,
|
|
91
|
+
"description": "Authentication data in the request is invalid",
|
|
92
|
+
},
|
|
93
|
+
status.HTTP_403_FORBIDDEN: {"model": error_model, "description": "Not authorized"},
|
|
94
|
+
status.HTTP_404_NOT_FOUND: {"model": error_model, "description": "Not found"},
|
|
95
|
+
status.HTTP_409_CONFLICT: {"model": error_model, "description": "Resource conflict detected"},
|
|
96
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
|
97
|
+
"model": error_model,
|
|
98
|
+
"description": "Understood the request, but can't and won't process it",
|
|
99
|
+
},
|
|
100
|
+
status.HTTP_400_BAD_REQUEST: {"model": error_model, "description": "Bad request"},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ExceptionMiddleware(BaseHTTPMiddleware):
|
|
105
|
+
def __init__(self, app, error_model: type[ApiErrorBody], settings: ExceptionHandlerSettings | None = None):
|
|
106
|
+
super().__init__(app)
|
|
107
|
+
self._error_model = error_model
|
|
108
|
+
self._settings = settings or DefaultExceptionHandlerSettings()
|
|
109
|
+
|
|
110
|
+
async def dispatch(self, request: Request, call_next):
|
|
111
|
+
try:
|
|
112
|
+
return await call_next(request)
|
|
113
|
+
except ServiceError as e:
|
|
114
|
+
return _handle_service_error(request, e, self._error_model)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return _handle_unexpected_error(request, e, self._settings, self._error_model)
|
|
File without changes
|