voltwire-fastapi-exceptions 0.0.1__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.
@@ -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,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,7 @@
1
+ voltwire/fastapi/exceptions/__init__.py,sha256=sF8lF3J9XVyySHAX69nUcSC0iidJTL_6TWxVSAk2P6Y,868
2
+ voltwire/fastapi/exceptions/exceptions.py,sha256=zgS4DAtqlKWXhID39neH3LAl4YCxY7PbE2zf7qlHFYc,2588
3
+ voltwire/fastapi/exceptions/middleware.py,sha256=W8Kq9foVAc5_qaNEQQznENhL2TNOv69K8FPfMrT6cIY,4445
4
+ voltwire/fastapi/exceptions/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ voltwire_fastapi_exceptions-0.0.1.dist-info/METADATA,sha256=vDhmjBpiugub0xFfmTtDJDWb_nKfvbBtdgBiMn8sbX0,5200
6
+ voltwire_fastapi_exceptions-0.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ voltwire_fastapi_exceptions-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any