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.
@@ -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)
@@ -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"]