voltwire-fastapi-exceptions 0.0.1__tar.gz
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.
- voltwire_fastapi_exceptions-0.0.1/.gitignore +221 -0
- voltwire_fastapi_exceptions-0.0.1/PKG-INFO +141 -0
- voltwire_fastapi_exceptions-0.0.1/README.md +130 -0
- voltwire_fastapi_exceptions-0.0.1/pyproject.toml +22 -0
- voltwire_fastapi_exceptions-0.0.1/src/voltwire/fastapi/exceptions/__init__.py +33 -0
- voltwire_fastapi_exceptions-0.0.1/src/voltwire/fastapi/exceptions/exceptions.py +83 -0
- voltwire_fastapi_exceptions-0.0.1/src/voltwire/fastapi/exceptions/middleware.py +117 -0
- voltwire_fastapi_exceptions-0.0.1/src/voltwire/fastapi/exceptions/py.typed +0 -0
- voltwire_fastapi_exceptions-0.0.1/tests/test_exceptions.py +43 -0
- voltwire_fastapi_exceptions-0.0.1/tests/test_middleware.py +89 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
.idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
|
|
220
|
+
# Local tool state
|
|
221
|
+
.omc/
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: voltwire-fastapi-exceptions
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Reusable FastAPI base exceptions, ApiMessage, and a unified exception handler
|
|
5
|
+
Author-email: Hermann Steidel <hsteidel.software@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: <4.0,>=3.13
|
|
8
|
+
Requires-Dist: fastapi>=0.100.0
|
|
9
|
+
Requires-Dist: starlette>=0.27.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
<img src="https://raw.githubusercontent.com/hsteidel/voltwire/main/assets/icons/fastapi-exceptions.svg" alt="" width="56" height="56" align="left">
|
|
13
|
+
|
|
14
|
+
# voltwire-fastapi-exceptions
|
|
15
|
+
|
|
16
|
+
Reusable exception handling for FastAPI apps: a base `AppError` hierarchy you can raise
|
|
17
|
+
(and extend), plus a middleware + validation handler so that **every** error — raised before,
|
|
18
|
+
during, or after the route — reaches the client as the same JSON body.
|
|
19
|
+
|
|
20
|
+
**Bring your own response model.** The library never defines or imposes a response schema —
|
|
21
|
+
you pass your own model down, and the handlers use only the slice they need (construct it with
|
|
22
|
+
`message` + `errors`, then call `.model_dump()`). Your app keeps one model for both success and
|
|
23
|
+
error responses; nothing is coupled across the boundary.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install voltwire-fastapi-exceptions
|
|
29
|
+
# or with Poetry:
|
|
30
|
+
poetry add voltwire-fastapi-exceptions
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Raising errors
|
|
34
|
+
|
|
35
|
+
Raise an `AppError` (or a subclass) anywhere; the middleware turns it into your response
|
|
36
|
+
model with the right status code.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from voltwire.fastapi.exceptions import EntityNotFoundError, ForbiddenError
|
|
40
|
+
|
|
41
|
+
def get_user(user_id: str):
|
|
42
|
+
user = repo.find(user_id)
|
|
43
|
+
if not user:
|
|
44
|
+
raise EntityNotFoundError(f"No user {user_id}") # -> 404
|
|
45
|
+
if not user.active:
|
|
46
|
+
raise ForbiddenError() # -> 403
|
|
47
|
+
return user
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Built-in classes: `UnauthorizedRequestError` (401), `ForbiddenError` (403),
|
|
51
|
+
`BadRequestError` (400), `EntityNotFoundError` (404), `ResourceConflictError` (409),
|
|
52
|
+
`UnprocessableRequestError` (422), `UnsupportedFeatureError` (501),
|
|
53
|
+
`DownstreamServiceError` (502).
|
|
54
|
+
|
|
55
|
+
`AppWarning` is an `AppError` subclass for **expected/recoverable** conditions — logged
|
|
56
|
+
at `warning` level instead of `error`. `ForbiddenError` and `UnsupportedFeatureError` are
|
|
57
|
+
warnings.
|
|
58
|
+
|
|
59
|
+
### Extend them
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from voltwire.fastapi.exceptions import UnprocessableRequestError
|
|
63
|
+
|
|
64
|
+
class DivideByZeroError(UnprocessableRequestError):
|
|
65
|
+
def __init__(self, message="Cannot divide by zero"):
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Your response model
|
|
70
|
+
|
|
71
|
+
Provide any model whose instances have a `.model_dump()` and that can be constructed with
|
|
72
|
+
`message=` and `errors=` (a pydantic model with those two fields — plus whatever else you want,
|
|
73
|
+
e.g. `timestamp`, `metadata` — is the common case). The library only ever sets `message` and
|
|
74
|
+
`errors`; the rest come from your model's defaults. This is the `ApiErrorBody` protocol:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
class ApiErrorBody(Protocol):
|
|
78
|
+
def __init__(self, *, message: str, errors: list[str]) -> None: ...
|
|
79
|
+
def model_dump(self, *, mode: str = "json", exclude_none: bool = True) -> dict: ...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Wiring it into your app
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from fastapi import FastAPI
|
|
86
|
+
from fastapi.exceptions import RequestValidationError
|
|
87
|
+
from voltwire.fastapi.exceptions import (
|
|
88
|
+
ExceptionMiddleware,
|
|
89
|
+
DefaultExceptionHandlerSettings,
|
|
90
|
+
build_validation_handler,
|
|
91
|
+
build_error_responses,
|
|
92
|
+
)
|
|
93
|
+
from myapp.models import ApiMessage # <-- YOUR model
|
|
94
|
+
|
|
95
|
+
app = FastAPI(responses=build_error_responses(ApiMessage)) # OpenAPI error schemas
|
|
96
|
+
|
|
97
|
+
# Catches AppError (-> its status) and any unexpected Exception (-> 500).
|
|
98
|
+
app.add_middleware(
|
|
99
|
+
ExceptionMiddleware,
|
|
100
|
+
error_model=ApiMessage,
|
|
101
|
+
settings=DefaultExceptionHandlerSettings(production=is_production()),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# RequestValidationError is raised during request parsing, before the middleware runs,
|
|
105
|
+
# so register it as an exception handler too — same body, built from your model.
|
|
106
|
+
app.add_exception_handler(RequestValidationError, build_validation_handler(ApiMessage))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Settings (why not read your env directly?)
|
|
110
|
+
|
|
111
|
+
The middleware only needs to know one thing: **are we in production?** (In production the 500
|
|
112
|
+
handler hides the raw exception string; otherwise it includes it to aid debugging.)
|
|
113
|
+
|
|
114
|
+
Rather than force a settings system on you, it takes any object matching the
|
|
115
|
+
`ExceptionHandlerSettings` protocol — a single `production: bool`. Use the provided
|
|
116
|
+
`DefaultExceptionHandlerSettings`, or pass your own object exposing `production`.
|
|
117
|
+
|
|
118
|
+
## Logging
|
|
119
|
+
|
|
120
|
+
Handlers log via `logging.getLogger(__name__)` (Python's standard `logging` module) —
|
|
121
|
+
`AppWarning` at `warning`, real errors at `error`/`exception`. To activate debug output:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import logging
|
|
125
|
+
logging.getLogger("voltwire.fastapi.exceptions").setLevel(logging.DEBUG)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
If your app uses [loguru](https://github.com/Delgan/loguru), intercept stdlib logging once at startup:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
import logging
|
|
132
|
+
from loguru import logger
|
|
133
|
+
|
|
134
|
+
class InterceptHandler(logging.Handler):
|
|
135
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
136
|
+
logger.opt(depth=6, exception=record.exc_info).log(
|
|
137
|
+
record.levelname, record.getMessage()
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
logging.getLogger("voltwire.fastapi.exceptions").addHandler(InterceptHandler())
|
|
141
|
+
```
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/hsteidel/voltwire/main/assets/icons/fastapi-exceptions.svg" alt="" width="56" height="56" align="left">
|
|
2
|
+
|
|
3
|
+
# voltwire-fastapi-exceptions
|
|
4
|
+
|
|
5
|
+
Reusable exception handling for FastAPI apps: a base `AppError` hierarchy you can raise
|
|
6
|
+
(and extend), plus a middleware + validation handler so that **every** error — raised before,
|
|
7
|
+
during, or after the route — reaches the client as the same JSON body.
|
|
8
|
+
|
|
9
|
+
**Bring your own response model.** The library never defines or imposes a response schema —
|
|
10
|
+
you pass your own model down, and the handlers use only the slice they need (construct it with
|
|
11
|
+
`message` + `errors`, then call `.model_dump()`). Your app keeps one model for both success and
|
|
12
|
+
error responses; nothing is coupled across the boundary.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install voltwire-fastapi-exceptions
|
|
18
|
+
# or with Poetry:
|
|
19
|
+
poetry add voltwire-fastapi-exceptions
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Raising errors
|
|
23
|
+
|
|
24
|
+
Raise an `AppError` (or a subclass) anywhere; the middleware turns it into your response
|
|
25
|
+
model with the right status code.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from voltwire.fastapi.exceptions import EntityNotFoundError, ForbiddenError
|
|
29
|
+
|
|
30
|
+
def get_user(user_id: str):
|
|
31
|
+
user = repo.find(user_id)
|
|
32
|
+
if not user:
|
|
33
|
+
raise EntityNotFoundError(f"No user {user_id}") # -> 404
|
|
34
|
+
if not user.active:
|
|
35
|
+
raise ForbiddenError() # -> 403
|
|
36
|
+
return user
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Built-in classes: `UnauthorizedRequestError` (401), `ForbiddenError` (403),
|
|
40
|
+
`BadRequestError` (400), `EntityNotFoundError` (404), `ResourceConflictError` (409),
|
|
41
|
+
`UnprocessableRequestError` (422), `UnsupportedFeatureError` (501),
|
|
42
|
+
`DownstreamServiceError` (502).
|
|
43
|
+
|
|
44
|
+
`AppWarning` is an `AppError` subclass for **expected/recoverable** conditions — logged
|
|
45
|
+
at `warning` level instead of `error`. `ForbiddenError` and `UnsupportedFeatureError` are
|
|
46
|
+
warnings.
|
|
47
|
+
|
|
48
|
+
### Extend them
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from voltwire.fastapi.exceptions import UnprocessableRequestError
|
|
52
|
+
|
|
53
|
+
class DivideByZeroError(UnprocessableRequestError):
|
|
54
|
+
def __init__(self, message="Cannot divide by zero"):
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Your response model
|
|
59
|
+
|
|
60
|
+
Provide any model whose instances have a `.model_dump()` and that can be constructed with
|
|
61
|
+
`message=` and `errors=` (a pydantic model with those two fields — plus whatever else you want,
|
|
62
|
+
e.g. `timestamp`, `metadata` — is the common case). The library only ever sets `message` and
|
|
63
|
+
`errors`; the rest come from your model's defaults. This is the `ApiErrorBody` protocol:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
class ApiErrorBody(Protocol):
|
|
67
|
+
def __init__(self, *, message: str, errors: list[str]) -> None: ...
|
|
68
|
+
def model_dump(self, *, mode: str = "json", exclude_none: bool = True) -> dict: ...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Wiring it into your app
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from fastapi import FastAPI
|
|
75
|
+
from fastapi.exceptions import RequestValidationError
|
|
76
|
+
from voltwire.fastapi.exceptions import (
|
|
77
|
+
ExceptionMiddleware,
|
|
78
|
+
DefaultExceptionHandlerSettings,
|
|
79
|
+
build_validation_handler,
|
|
80
|
+
build_error_responses,
|
|
81
|
+
)
|
|
82
|
+
from myapp.models import ApiMessage # <-- YOUR model
|
|
83
|
+
|
|
84
|
+
app = FastAPI(responses=build_error_responses(ApiMessage)) # OpenAPI error schemas
|
|
85
|
+
|
|
86
|
+
# Catches AppError (-> its status) and any unexpected Exception (-> 500).
|
|
87
|
+
app.add_middleware(
|
|
88
|
+
ExceptionMiddleware,
|
|
89
|
+
error_model=ApiMessage,
|
|
90
|
+
settings=DefaultExceptionHandlerSettings(production=is_production()),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# RequestValidationError is raised during request parsing, before the middleware runs,
|
|
94
|
+
# so register it as an exception handler too — same body, built from your model.
|
|
95
|
+
app.add_exception_handler(RequestValidationError, build_validation_handler(ApiMessage))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Settings (why not read your env directly?)
|
|
99
|
+
|
|
100
|
+
The middleware only needs to know one thing: **are we in production?** (In production the 500
|
|
101
|
+
handler hides the raw exception string; otherwise it includes it to aid debugging.)
|
|
102
|
+
|
|
103
|
+
Rather than force a settings system on you, it takes any object matching the
|
|
104
|
+
`ExceptionHandlerSettings` protocol — a single `production: bool`. Use the provided
|
|
105
|
+
`DefaultExceptionHandlerSettings`, or pass your own object exposing `production`.
|
|
106
|
+
|
|
107
|
+
## Logging
|
|
108
|
+
|
|
109
|
+
Handlers log via `logging.getLogger(__name__)` (Python's standard `logging` module) —
|
|
110
|
+
`AppWarning` at `warning`, real errors at `error`/`exception`. To activate debug output:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import logging
|
|
114
|
+
logging.getLogger("voltwire.fastapi.exceptions").setLevel(logging.DEBUG)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
If your app uses [loguru](https://github.com/Delgan/loguru), intercept stdlib logging once at startup:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import logging
|
|
121
|
+
from loguru import logger
|
|
122
|
+
|
|
123
|
+
class InterceptHandler(logging.Handler):
|
|
124
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
125
|
+
logger.opt(depth=6, exception=record.exc_info).log(
|
|
126
|
+
record.levelname, record.getMessage()
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
logging.getLogger("voltwire.fastapi.exceptions").addHandler(InterceptHandler())
|
|
130
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "voltwire-fastapi-exceptions"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Reusable FastAPI base exceptions, ApiMessage, and a unified exception handler"
|
|
5
|
+
authors = [{name = "Hermann Steidel", email = "hsteidel.software@gmail.com"}]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.13,<4.0"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"fastapi>=0.100.0",
|
|
11
|
+
"starlette>=0.27.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = ["pytest>=8.0"]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/voltwire"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from voltwire.fastapi.exceptions.exceptions import * # noqa: F401, F403
|
|
2
|
+
from voltwire.fastapi.exceptions.middleware import (
|
|
3
|
+
ApiErrorBody,
|
|
4
|
+
DefaultExceptionHandlerSettings,
|
|
5
|
+
ExceptionHandlerSettings,
|
|
6
|
+
ExceptionMiddleware,
|
|
7
|
+
build_error_responses,
|
|
8
|
+
build_validation_handler,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__version__ = "0.0.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AppError",
|
|
15
|
+
"AppWarning",
|
|
16
|
+
"UnauthorizedRequestError",
|
|
17
|
+
"ForbiddenError",
|
|
18
|
+
"BadRequestError",
|
|
19
|
+
"EntityNotFoundError",
|
|
20
|
+
"EntityNotFoundWarning",
|
|
21
|
+
"ResourceConflictError",
|
|
22
|
+
"ResourceConflictWarning",
|
|
23
|
+
"UnprocessableRequestError",
|
|
24
|
+
"UnprocessableRequestWarning",
|
|
25
|
+
"UnsupportedFeatureError",
|
|
26
|
+
"DownstreamServiceError",
|
|
27
|
+
"ExceptionMiddleware",
|
|
28
|
+
"build_validation_handler",
|
|
29
|
+
"build_error_responses",
|
|
30
|
+
"ApiErrorBody",
|
|
31
|
+
"ExceptionHandlerSettings",
|
|
32
|
+
"DefaultExceptionHandlerSettings",
|
|
33
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from starlette import status
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class AppError(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 AppWarning(AppError): # 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(AppError):
|
|
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(AppWarning):
|
|
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(AppError):
|
|
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(AppError):
|
|
44
|
+
def __init__(self, message="Entity not found"):
|
|
45
|
+
status_code = status.HTTP_404_NOT_FOUND
|
|
46
|
+
super().__init__(message, status_code)
|
|
47
|
+
|
|
48
|
+
class EntityNotFoundWarning(AppWarning):
|
|
49
|
+
def __init__(self, message="Entity not found"):
|
|
50
|
+
super().__init__(message, status.HTTP_404_NOT_FOUND)
|
|
51
|
+
|
|
52
|
+
class ResourceConflictError(AppError):
|
|
53
|
+
def __init__(self, message="Request creates a conflict"):
|
|
54
|
+
status_code = status.HTTP_409_CONFLICT
|
|
55
|
+
super().__init__(message, status_code)
|
|
56
|
+
|
|
57
|
+
class ResourceConflictWarning(AppWarning):
|
|
58
|
+
def __init__(self, message="Request creates a conflict"):
|
|
59
|
+
status_code = status.HTTP_409_CONFLICT
|
|
60
|
+
super().__init__(message, status_code)
|
|
61
|
+
|
|
62
|
+
class UnprocessableRequestError(AppError):
|
|
63
|
+
def __init__(self, message="Unprocessable Request"):
|
|
64
|
+
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
65
|
+
super().__init__(message, status_code)
|
|
66
|
+
|
|
67
|
+
class UnprocessableRequestWarning(AppWarning):
|
|
68
|
+
def __init__(self, message="Unprocessable Request"):
|
|
69
|
+
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
70
|
+
super().__init__(message, status_code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UnsupportedFeatureError(AppWarning):
|
|
74
|
+
def __init__(self, message="Not implemented"):
|
|
75
|
+
status_code = status.HTTP_501_NOT_IMPLEMENTED
|
|
76
|
+
super().__init__(message, status_code)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DownstreamServiceError(AppError):
|
|
80
|
+
def __init__(self, message="Downstream service error"):
|
|
81
|
+
status_code = status.HTTP_502_BAD_GATEWAY
|
|
82
|
+
super().__init__(message, status_code)
|
|
83
|
+
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from fastapi.exceptions import RequestValidationError
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from voltwire.fastapi.exceptions.exceptions import AppError, AppWarning
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiErrorBody(Protocol):
|
|
16
|
+
def __init__(self, *, message: str, errors: list[str]) -> None: ...
|
|
17
|
+
|
|
18
|
+
def model_dump(self, *, mode: str = "json", exclude_none: bool = True) -> dict[str, Any]: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExceptionHandlerSettings(Protocol):
|
|
22
|
+
production: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class DefaultExceptionHandlerSettings:
|
|
27
|
+
production: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _handle_unexpected_error(
|
|
31
|
+
request: Request, e: Exception, settings: ExceptionHandlerSettings, error_model: type[ApiErrorBody]
|
|
32
|
+
) -> JSONResponse:
|
|
33
|
+
logger.exception("Internal server error occurred", extra={"path": request.url.path})
|
|
34
|
+
errors = [] if settings.production else [str(e)]
|
|
35
|
+
body = error_model(message="internal server error", errors=errors)
|
|
36
|
+
return JSONResponse(
|
|
37
|
+
content=body.model_dump(mode="json", exclude_none=True),
|
|
38
|
+
media_type="application/json",
|
|
39
|
+
status_code=500,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _handle_service_error(request: Request, e: AppError, error_model: type[ApiErrorBody]) -> JSONResponse:
|
|
44
|
+
if isinstance(e, AppWarning):
|
|
45
|
+
logger.warning(
|
|
46
|
+
"Expected service condition",
|
|
47
|
+
extra={"path": request.url.path, "error_type": type(e).__name__},
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
logger.exception(
|
|
51
|
+
"Service error occurred",
|
|
52
|
+
extra={"path": request.url.path, "error_type": type(e).__name__},
|
|
53
|
+
)
|
|
54
|
+
body = error_model(message="service error", errors=[e.message])
|
|
55
|
+
response_content = body.model_dump(mode="json", exclude_none=True)
|
|
56
|
+
if not isinstance(e, AppWarning):
|
|
57
|
+
logger.error(f"AppError response - status: {e.status_code}, content: {response_content}")
|
|
58
|
+
return JSONResponse(
|
|
59
|
+
content=response_content,
|
|
60
|
+
media_type="application/json",
|
|
61
|
+
status_code=e.status_code,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_validation_handler(error_model: type[ApiErrorBody]):
|
|
66
|
+
async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
67
|
+
logger.warning(f"Validation error details: {exc.errors()}")
|
|
68
|
+
errors = []
|
|
69
|
+
for error in exc.errors():
|
|
70
|
+
location = ".".join(str(loc) for loc in error["loc"] if loc != "body")
|
|
71
|
+
message = error["msg"]
|
|
72
|
+
error_type = error.get("type", "unknown")
|
|
73
|
+
detailed_error = (
|
|
74
|
+
f"Field: {location if location else 'N/A'}, Error: {message}, Type: {error_type}, Received: "
|
|
75
|
+
f"{error.get('input', 'not provided')}"
|
|
76
|
+
)
|
|
77
|
+
errors.append(detailed_error)
|
|
78
|
+
logger.warning(detailed_error)
|
|
79
|
+
body = error_model(message="Request Validation Error", errors=errors)
|
|
80
|
+
return JSONResponse(
|
|
81
|
+
status_code=422,
|
|
82
|
+
content=body.model_dump(mode="json", exclude_none=True),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return handle_validation_error
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_error_responses(error_model: type[ApiErrorBody]) -> dict:
|
|
89
|
+
return {
|
|
90
|
+
401: {
|
|
91
|
+
"model": error_model,
|
|
92
|
+
"description": "Authentication data in the request is invalid",
|
|
93
|
+
},
|
|
94
|
+
403: {"model": error_model, "description": "Not authorized"},
|
|
95
|
+
404: {"model": error_model, "description": "Not found"},
|
|
96
|
+
409: {"model": error_model, "description": "Resource conflict detected"},
|
|
97
|
+
422: {
|
|
98
|
+
"model": error_model,
|
|
99
|
+
"description": "Understood the request, but can't and won't process it",
|
|
100
|
+
},
|
|
101
|
+
400: {"model": error_model, "description": "Bad request"},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ExceptionMiddleware(BaseHTTPMiddleware):
|
|
106
|
+
def __init__(self, app, error_model: type[ApiErrorBody], settings: ExceptionHandlerSettings | None = None):
|
|
107
|
+
super().__init__(app)
|
|
108
|
+
self._error_model = error_model
|
|
109
|
+
self._settings = settings or DefaultExceptionHandlerSettings()
|
|
110
|
+
|
|
111
|
+
async def dispatch(self, request: Request, call_next):
|
|
112
|
+
try:
|
|
113
|
+
return await call_next(request)
|
|
114
|
+
except AppError as e:
|
|
115
|
+
return _handle_service_error(request, e, self._error_model)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return _handle_unexpected_error(request, e, self._settings, self._error_model)
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from starlette import status
|
|
2
|
+
|
|
3
|
+
from voltwire.fastapi.exceptions import (
|
|
4
|
+
BadRequestError,
|
|
5
|
+
DownstreamServiceError,
|
|
6
|
+
EntityNotFoundError,
|
|
7
|
+
ForbiddenError,
|
|
8
|
+
ResourceConflictError,
|
|
9
|
+
AppError,
|
|
10
|
+
AppWarning,
|
|
11
|
+
UnauthorizedRequestError,
|
|
12
|
+
UnprocessableRequestError,
|
|
13
|
+
UnsupportedFeatureError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_subclass_status_codes_and_default_messages():
|
|
18
|
+
cases = [
|
|
19
|
+
(UnauthorizedRequestError(), status.HTTP_401_UNAUTHORIZED),
|
|
20
|
+
(ForbiddenError(), status.HTTP_403_FORBIDDEN),
|
|
21
|
+
(BadRequestError(), status.HTTP_400_BAD_REQUEST),
|
|
22
|
+
(EntityNotFoundError(), status.HTTP_404_NOT_FOUND),
|
|
23
|
+
(ResourceConflictError(), status.HTTP_409_CONFLICT),
|
|
24
|
+
(UnprocessableRequestError(), status.HTTP_422_UNPROCESSABLE_ENTITY),
|
|
25
|
+
(UnsupportedFeatureError(), status.HTTP_501_NOT_IMPLEMENTED),
|
|
26
|
+
(DownstreamServiceError(), status.HTTP_502_BAD_GATEWAY),
|
|
27
|
+
]
|
|
28
|
+
for err, code in cases:
|
|
29
|
+
assert err.status_code == code
|
|
30
|
+
assert isinstance(err.message, str) and err.message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_custom_message_and_str():
|
|
34
|
+
err = EntityNotFoundError("no user 5")
|
|
35
|
+
assert err.message == "no user 5"
|
|
36
|
+
assert str(err) == "no user 5"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_warnings_are_service_errors():
|
|
40
|
+
assert issubclass(AppWarning, AppError)
|
|
41
|
+
assert isinstance(ForbiddenError(), AppWarning)
|
|
42
|
+
assert isinstance(UnsupportedFeatureError(), AppWarning)
|
|
43
|
+
assert not isinstance(BadRequestError(), AppWarning)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from fastapi.exceptions import RequestValidationError
|
|
5
|
+
from starlette import status
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
|
|
8
|
+
from voltwire.fastapi.exceptions import (
|
|
9
|
+
DefaultExceptionHandlerSettings,
|
|
10
|
+
EntityNotFoundError,
|
|
11
|
+
build_error_responses,
|
|
12
|
+
build_validation_handler,
|
|
13
|
+
)
|
|
14
|
+
from voltwire.fastapi.exceptions.middleware import _handle_service_error, _handle_unexpected_error
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeApiMessage:
|
|
18
|
+
def __init__(self, *, message: str, errors: list[str]):
|
|
19
|
+
self.message = message
|
|
20
|
+
self.errors = errors
|
|
21
|
+
|
|
22
|
+
def model_dump(self, *, mode: str = "json", exclude_none: bool = True) -> dict:
|
|
23
|
+
return {"message": self.message, "errors": self.errors}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _request(path: str = "/x") -> Request:
|
|
27
|
+
return Request(
|
|
28
|
+
{
|
|
29
|
+
"type": "http",
|
|
30
|
+
"http_version": "1.1",
|
|
31
|
+
"method": "GET",
|
|
32
|
+
"scheme": "http",
|
|
33
|
+
"server": ("test", 80),
|
|
34
|
+
"path": path,
|
|
35
|
+
"query_string": b"",
|
|
36
|
+
"headers": [],
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _body(response) -> dict:
|
|
42
|
+
return json.loads(response.body)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_service_error_uses_caller_model():
|
|
46
|
+
response = _handle_service_error(_request(), EntityNotFoundError("nope"), FakeApiMessage)
|
|
47
|
+
assert response.status_code == 404
|
|
48
|
+
assert _body(response) == {"message": "service error", "errors": ["nope"]}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_unexpected_error_hides_detail_in_production():
|
|
52
|
+
response = _handle_unexpected_error(
|
|
53
|
+
_request(), ValueError("boom"), DefaultExceptionHandlerSettings(production=True), FakeApiMessage
|
|
54
|
+
)
|
|
55
|
+
assert response.status_code == 500
|
|
56
|
+
assert _body(response)["errors"] == []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_unexpected_error_includes_detail_outside_production():
|
|
60
|
+
response = _handle_unexpected_error(
|
|
61
|
+
_request(), ValueError("boom"), DefaultExceptionHandlerSettings(production=False), FakeApiMessage
|
|
62
|
+
)
|
|
63
|
+
assert response.status_code == 500
|
|
64
|
+
assert _body(response)["errors"] == ["boom"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_validation_handler_uses_caller_model():
|
|
68
|
+
handler = build_validation_handler(FakeApiMessage)
|
|
69
|
+
exc = RequestValidationError([{"loc": ("body", "name"), "msg": "field required", "type": "missing", "input": None}])
|
|
70
|
+
response = asyncio.run(handler(_request(), exc))
|
|
71
|
+
assert response.status_code == 422
|
|
72
|
+
body = _body(response)
|
|
73
|
+
assert body["message"] == "Request Validation Error"
|
|
74
|
+
assert len(body["errors"]) == 1
|
|
75
|
+
assert "field required" in body["errors"][0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_build_error_responses_maps_caller_model():
|
|
79
|
+
responses = build_error_responses(FakeApiMessage)
|
|
80
|
+
for code in (
|
|
81
|
+
status.HTTP_401_UNAUTHORIZED,
|
|
82
|
+
status.HTTP_403_FORBIDDEN,
|
|
83
|
+
status.HTTP_404_NOT_FOUND,
|
|
84
|
+
status.HTTP_409_CONFLICT,
|
|
85
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
86
|
+
status.HTTP_400_BAD_REQUEST,
|
|
87
|
+
):
|
|
88
|
+
assert responses[code]["model"] is FakeApiMessage
|
|
89
|
+
assert responses[code]["description"]
|