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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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