Name | fastapi-problem-details JSON |
Version |
0.1.4
JSON |
| download |
home_page | None |
Summary | Structure your FastAPI APIs error responses with consistent and machine readable format using the RFC 9457 "Problem Details for HTTP APIs" standard |
upload_time | 2024-11-08 10:58:15 |
maintainer | None |
docs_url | None |
author | None |
requires_python | >=3.10 |
license | MIT |
keywords |
fastapi
problem
plugin
|
VCS |
|
bugtrack_url |
|
requirements |
No requirements were recorded.
|
Travis-CI |
No Travis.
|
coveralls test coverage |
No coveralls.
|
# fastapi-problem-details <!-- omit in toc -->
This FastAPI plugin allow you to automatically format any errors as Problem details described in [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html). This allow rich error responses and consistent errors formatting within a single or multiple APIs.
- [Getting Started](#getting-started)
- [Validation errors handling](#validation-errors-handling)
- [Changing default validation error status code and/or detail](#changing-default-validation-error-status-code-andor-detail)
- [HTTP errors handling](#http-errors-handling)
- [Unexisting routes error handling](#unexisting-routes-error-handling)
- [Unexpected errors handling](#unexpected-errors-handling)
- [Including exceptions type and stack traces](#including-exceptions-type-and-stack-traces)
- [Custom errors handling](#custom-errors-handling)
- [Returning HTTP errors as Problem Details](#returning-http-errors-as-problem-details)
- [Keeping the code DRY](#keeping-the-code-dry)
- [1. Inheritance](#1-inheritance)
- [2. Custom error handlers](#2-custom-error-handlers)
- [Wrapping up](#wrapping-up)
- [Documenting your custom problems details](#documenting-your-custom-problems-details)
- [Troubleshooting](#troubleshooting)
- [Problem "default" openapi response is not added into additional FastAPI routers routes](#problem-default-openapi-response-is-not-added-into-additional-fastapi-routers-routes)
## Getting Started
Install the plugin
```bash
pip install fastapi-problem-details
```
Register the plugin against your FastAPI app
```python
from fastapi import FastAPI
import fastapi_problem_details as problem
app = FastAPI()
problem.init_app(app)
```
And you're done! (mostly)
At this point any unhandled errors `Exception`, validation errors `fastapi.exceptions.RequestValidationError` and HTTP errors `starlette.exceptions.HTTPException` will be automatically handled and returned as Problem Details objects, for example, an unhandled error will be catched and returned as following JSON:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Server got itself in trouble"
}
```
The plugin actaully add custom error handlers for all mentionned kind of errors in order to return proper Problem Details responses. Note however that you can override any of those "default" handlers after initializing the plugin.
Now, let's dig a bit more on what the plugin is actually doing.
## Validation errors handling
Plugin will automatically handle any FastAPI `RequestValidationError` and returns a Problem Details response.
```python
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel
import fastapi_problem_details as problem
app = FastAPI()
problem.init_app(app)
class User(BaseModel):
id: str
name: str
@app.post("/users/")
def create_user(_user: User) -> Any: # noqa: ANN401
pass
```
Trying to create an user using invalid payload will result in a validation error formatted as a Problem Details response. In particular, it will put the validation errors into an `errors` property in returned object.
```bash
curl -X POST http://localhost:8000/users/ -d '{}' -H "Content-Type: application/json"
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Request validation failed",
"errors": [
{
"type": "missing",
"loc": [
"body",
"id"
],
"msg": "Field required",
"input": {}
},
{
"type": "missing",
"loc": [
"body",
"name"
],
"msg": "Field required",
"input": {}
}
]
}
```
### Changing default validation error status code and/or detail
By default, validation errors will returns a 422 status code (FastAPI default) with a `"Request validation failed"` detail message.
However, you can override both of those if you want.
```python
from fastapi import FastAPI, status
import fastapi_problem_details as problem
app = FastAPI()
problem.init_app(app, validation_error_code=status.HTTP_400_BAD_REQUEST, validation_error_detail="Invalid payload!")
```
## HTTP errors handling
Any FastAPI or starlette `HTTPException` raised during a request will be automatically catched and formatted as a Problem details response.
```python
from typing import Any
from fastapi import FastAPI, HTTPException, status
import fastapi_problem_details as problem
app = FastAPI()
problem.init_app(app)
@app.get("/")
def raise_error() -> Any: # noqa: ANN401
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
```
Requesting this endpoint will get you the following response
```bash
curl http://localhost:8000/
{
"type":"about:blank",
"title":"Unauthorized",
"status":401,
"detail":"No permission -- see authorization schemes",
}
```
The `title` property is the official phrase corresponding to the HTTP status code.
The `detail` property is filled with the one passed to the raised `HTTPException` and defaults to the description of the HTTP status code `http.HTTPStatus(status).description` if not provided.
> Note that `headers` passed to the `HTTPException` will be returned as well.
### Unexisting routes error handling
Requests against non existing routes of your API also raise 404 `HTTPException`. The key difference is in the `detail` property message. This is pretty handy for clients to distinguish between a resource not found (e.g: trying to get an user which does not exist) and a route not existing
```bash
curl -X POST http://localhost:8000/not-exist
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Nothing matches the given URI",
}
```
## Unexpected errors handling
Any unexpected errors raised during processing of a request will be automatically handled by the plugin which will returns an internal server error formatted as a Problem Details.
> Also note that the exception will be logged as well using logger named `fastapi_problem_details.error_handlers`
```python
from typing import Any
from fastapi import FastAPI
import fastapi_problem_details as problem
app = FastAPI()
problem.init_app(app)
class CustomError(Exception):
pass
@app.get("/")
def raise_error() -> Any: # noqa: ANN401
raise CustomError("Something went wrong...")
```
```bash
$ curl http://localhost:8000
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Server got itself in trouble",
}
```
### Including exceptions type and stack traces
During development, it can sometimes be useful to include in your HTTP responses the type and stack trace of unhandled errors for easier debugging.
```python
problem.init_app(app, include_exc_info_in_response=True)
```
Doing so will enrich Problem Details response with exception type `exc_type` (`str`) and stack trace `exc_stack` (`list[str]`)
```bash
$ curl http://localhost:8000
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Server got itself in trouble",
"exc_type": "snippet.CustomError",
"exc_stack": [
"Traceback (most recent call last):\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/middleware/exceptions.py\", line 65, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\", line 756, in __call__\n await self.middleware_stack(scope, receive, send)\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\", line 776, in app\n await route.handle(scope, receive, send)\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/fastapi/routing.py\", line 278, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/concurrency.py\", line 42, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/anyio/to_thread.py\", line 56, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/anyio/_backends/_asyncio.py\", line 2177, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/anyio/_backends/_asyncio.py\", line 859, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/snippet.py\", line 22, in raise_error\n return raise_some_error()\n ^^^^^^^^^^^^^^^^^^\n",
" File \"/Users/gody/Development/OpenSource/fastapi-problem-details/snippet.py\", line 17, in raise_some_error\n raise CustomError\n",
"snippet.CustomError\n"
]
}
```
> :warning: This feature is expected to be used only for development purposes. You should not enable this on production because it can leak sensitive internal information. Use it at your own risk.
## Custom errors handling
To handle specific errors in your API you can simply register custom error handlers (see [FastAPI documentation](https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers)) and returns `ProblemResponse` object.
```python
from typing import Any
from fastapi import FastAPI, Request, status
import fastapi_problem_details as problem
from fastapi_problem_details import ProblemResponse
app = FastAPI()
problem.init_app(app)
class UserNotFoundError(Exception):
def __init__(self, user_id: str) -> None:
super().__init__(f"There is no user with id {user_id!r}")
self.user_id = user_id
@app.exception_handler(UserNotFoundError)
async def handle_user_not_found_error(
_: Request, exc: UserNotFoundError
) -> ProblemResponse:
return ProblemResponse(
status=status.HTTP_404_NOT_FOUND,
type="/problems/user-not-found",
title="User Not Found",
detail=str(exc),
user_id=exc.user_id,
)
@app.get("/users/{user_id}")
def get_user(user_id: str) -> Any: # noqa: ANN401
raise UserNotFoundError(user_id)
```
Requesting an user will get you following problem details
```bash
$ curl http://localhost:8000/users/1234
{
"type":"/problems/user-not-found",
"title":"User Not Found",
"status":404,
"detail":"There is no user with id '1234'",
"user_id":"1234"
}
```
Note that in this example I've provided a custom `type` property but this might not be necessary for this use case. Basically, you should only use specific type and title when your error goes beyond the original meaning of the HTTP status code. See the [RFC](https://datatracker.ietf.org/doc/html/rfc9457#name-defining-new-problem-types) for more details.
> Likewise, truly generic problems -- i.e., conditions that might apply to any resource on the Web -- are usually better expressed as plain status codes. For example, a "write access disallowed" problem is probably unnecessary, since a 403 Forbidden status code in response to a PUT request is self-explanatory.
Also note that you can include additional properties to the `ProblemResponse` object like `headers` or `instance`. Any extra properties will be added as-is in the returned Problem Details object (like the `user_id` in this example).
Last but not least, any `null` values are stripped from returned Problem Details object.
## Returning HTTP errors as Problem Details
As shown in previous sections, any `HTTPException` raised during a request cause the API to respond with a well formatted Problem Details object. However, what if we want to raise a `HTTPException` but with extra properties or Problem Details specific properties?
In that case you can instead raise a `ProblemException` exception.
```python
from typing import Any
from fastapi import FastAPI, status
import fastapi_problem_details as problem
from fastapi_problem_details import ProblemException
app = FastAPI()
problem.init_app(app)
@app.get("/")
def raise_error() -> Any: # noqa: ANN401
raise ProblemException(
status=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="One or several internal services are not working properly",
service_1="down",
service_2="up",
headers={"Retry-After": "30"},
)
```
```bash
curl http://localhost:8000 -v
* Trying [::1]:8000...
* connect to ::1 port 8000 failed: Connection refused
* Trying 127.0.0.1:8000...
* Connected to localhost (127.0.0.1) port 8000
> GET / HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/1.1 503 Service Unavailable
< date: Tue, 30 Jul 2024 14:10:02 GMT
< server: uvicorn
< retry-after: 30
< content-length: 186
< content-type: application/problem+json
<
* Connection #0 to host localhost left intact
{
"type":"about:blank",
"title":"Service Unavailable",
"status":503,
"detail":"One or several internal services are not working properly",
"service_1":"down",
"service_2":"up"
}
```
The `ProblemException` exception takes almost same arguments as a `ProblemResponse`.
### Keeping the code DRY
If you start having to raise almost the same `ProblemException` in several places of your code (for example when you validate a requester permissions) you have two ways to avoid copy-pasting the same object in many places of your code
#### 1. Inheritance
Simply create your own subclass of `ProblemException`
```python
from fastapi import status
from fastapi_problem_details.models import ProblemException
class UserPermissionError(ProblemException):
def __init__(
self,
user_id: str,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(
status=status.HTTP_403_FORBIDDEN,
detail=f"User {user_id} is not allowed to perform this operation",
headers=headers,
user_id=user_id,
)
def do_something_meaningful(user_id: str):
raise UserPermissionError(user_id)
```
The advantage of this solution is that its rather simple and straightforward. You do not have anything else to do to properly returns Problem Details responses.
The main issue of this is that it can cause your code to cross boundaries. If you start to use `ProblemException` into your domain logic, you couple your core code with your HTTP API. If you decide to build a CLI and/or and event based application using the same core logic, you'll end up with uncomfortable problem exception and status code which has no meaning here.
#### 2. Custom error handlers
The other approach is to only use custom exceptions in your code and add custom error handlers in your FastAPI app to properly map your core errors with Problem Details.
```python
from typing import Any
from fastapi import FastAPI, Request, status
import fastapi_problem_details as problem
from fastapi_problem_details import ProblemResponse
app = FastAPI()
problem.init_app(app)
class UserNotFoundError(Exception):
def __init__(self, user_id: str) -> None:
super().__init__(f"There is no user with id {user_id!r}")
self.user_id = user_id
@app.exception_handler(UserNotFoundError)
async def handle_user_not_found_error(
_: Request, exc: UserNotFoundError
) -> ProblemResponse:
return ProblemResponse(
status=status.HTTP_404_NOT_FOUND,
type="/problems/user-not-found",
title="User Not Found",
detail=str(exc),
user_id=exc.user_id,
)
@app.get("/users/{user_id}")
def get_user(user_id: str) -> Any: # noqa: ANN401
raise get_user_by_id(user_id)
# somewhere else, in a repository.py file for example or dao.py
db: dict[str, dict[str, Any]] = {}
def get_user_by_id(user_id: str):
if user_id not in db:
raise UserNotFoundError(user_id)
```
The biggest advantage of this solution is that you decouple your core code from your FastAPI app. You can define regular Python exceptions whatever you want and just do the conversion for your API in your custom error handler(s).
The disadvantage obviously is that it requires you to write more code. Its a question of balance.
#### Wrapping up
Considering the two previous mechanisms, the way which worked best for me is to do the following:
- When I raise errors in my core (domain code, business logic) I use dedicated exceptions, unrelated to HTTP nor APIs, and I add a custom error handler to my FastAPI app to handle and returns a ProblemResponse`.
- When I want to raise an error directly in one of my API controller (i.e: a FastAPI route) I simply raise a `ProblemException`. If I'm raising same problem exception in several places I create a subclass of problem exception and put in my defaults and raise that error instead.
## Documenting your custom problems details
When registering problem details against your FastAPI app, it adds a `default` openapi response to all routes with the Problem Details schema. This might be enough in most cases but if you want to explicit additional problem details responses for specific status code or document additional properties you can register your Problem Details.
```python
from typing import Any, Literal
from fastapi import FastAPI, Request, status
import fastapi_problem_details as problem
from fastapi_problem_details import ProblemResponse
app = FastAPI()
problem.init_app(app)
class UserNotFoundProblem(problem.Problem):
status: Literal[404]
user_id: str
class UserNotFoundError(Exception):
def __init__(self, user_id: str) -> None:
super().__init__(f"There is no user with id {user_id!r}")
self.user_id = user_id
@app.exception_handler(UserNotFoundError)
async def handle_user_not_found_error(
_: Request, exc: UserNotFoundError
) -> ProblemResponse:
return ProblemResponse.from_exception(
exc,
status=status.HTTP_404_NOT_FOUND,
detail=f"User {exc.user_id} not found",
user_id=exc.user_id,
)
@app.get("/users/{user_id}", responses={404: {"model": UserNotFoundProblem}})
def get_user(user_id: str) -> Any: # noqa: ANN401
raise UserNotFoundError(user_id)
```
Note that this has limitation. Indeed, the `UserNotFoundProblem` class just act as a model schema for openapi documentation. You actually not instantiate this class and no validation is performed when returning the problem response. It means that the error handler can returns something which does not match a `UserNotFoundProblem`.
This is because of the way FastAPI manages errors. At the moment, there is no way to register error handler and its response schema in the same place and there is no mechanism to ensure both are synced.
## Troubleshooting
### Problem "default" openapi response is not added into additional FastAPI routers routes
If you use `APIRouter` from FastAPI to bind your routes and then include routers in your main API you must initializes the problem details error handlers BEFORE including the routers if you want your routes to have their OpenAPI responses documented with the `default` problem details response.
```python
from fastapi import APIRouter, FastAPI
import fastapi_problem_details as problem
app = FastAPI()
v1 = APIRouter(prefix="/v1")
# THIS DOES NOT WORK
app.include_router(v1)
problem.init_app(app)
# Instead, init problem errors handlers first
problem.init_app(app)
app.include_router(v1)
```
Raw data
{
"_id": null,
"home_page": null,
"name": "fastapi-problem-details",
"maintainer": null,
"docs_url": null,
"requires_python": ">=3.10",
"maintainer_email": null,
"keywords": "fastapi, problem, plugin",
"author": null,
"author_email": "g0di <benoit.godard.p@gmail.com>",
"download_url": "https://files.pythonhosted.org/packages/ac/e4/d0a9e2b97c4f0ea31aa858f29898b8dc8af4028ffe88f9a34a1983494736/fastapi_problem_details-0.1.4.tar.gz",
"platform": null,
"description": "# fastapi-problem-details <!-- omit in toc -->\n\nThis FastAPI plugin allow you to automatically format any errors as Problem details described in [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html). This allow rich error responses and consistent errors formatting within a single or multiple APIs.\n\n- [Getting Started](#getting-started)\n- [Validation errors handling](#validation-errors-handling)\n - [Changing default validation error status code and/or detail](#changing-default-validation-error-status-code-andor-detail)\n- [HTTP errors handling](#http-errors-handling)\n - [Unexisting routes error handling](#unexisting-routes-error-handling)\n- [Unexpected errors handling](#unexpected-errors-handling)\n - [Including exceptions type and stack traces](#including-exceptions-type-and-stack-traces)\n- [Custom errors handling](#custom-errors-handling)\n- [Returning HTTP errors as Problem Details](#returning-http-errors-as-problem-details)\n - [Keeping the code DRY](#keeping-the-code-dry)\n - [1. Inheritance](#1-inheritance)\n - [2. Custom error handlers](#2-custom-error-handlers)\n - [Wrapping up](#wrapping-up)\n- [Documenting your custom problems details](#documenting-your-custom-problems-details)\n- [Troubleshooting](#troubleshooting)\n - [Problem \"default\" openapi response is not added into additional FastAPI routers routes](#problem-default-openapi-response-is-not-added-into-additional-fastapi-routers-routes)\n\n## Getting Started\n\nInstall the plugin\n\n```bash\npip install fastapi-problem-details\n```\n\nRegister the plugin against your FastAPI app\n\n```python\nfrom fastapi import FastAPI\nimport fastapi_problem_details as problem\n\n\napp = FastAPI()\n\nproblem.init_app(app)\n```\n\nAnd you're done! (mostly)\n\nAt this point any unhandled errors `Exception`, validation errors `fastapi.exceptions.RequestValidationError` and HTTP errors `starlette.exceptions.HTTPException` will be automatically handled and returned as Problem Details objects, for example, an unhandled error will be catched and returned as following JSON:\n\n```json\n{\n \"type\": \"about:blank\",\n \"title\": \"Internal Server Error\",\n \"status\": 500,\n \"detail\": \"Server got itself in trouble\"\n}\n```\n\nThe plugin actaully add custom error handlers for all mentionned kind of errors in order to return proper Problem Details responses. Note however that you can override any of those \"default\" handlers after initializing the plugin.\n\nNow, let's dig a bit more on what the plugin is actually doing.\n\n## Validation errors handling\n\nPlugin will automatically handle any FastAPI `RequestValidationError` and returns a Problem Details response.\n\n```python\nfrom typing import Any\n\nfrom fastapi import FastAPI\nfrom pydantic import BaseModel\n\nimport fastapi_problem_details as problem\n\napp = FastAPI()\n\nproblem.init_app(app)\n\n\nclass User(BaseModel):\n id: str\n name: str\n\n\n@app.post(\"/users/\")\ndef create_user(_user: User) -> Any: # noqa: ANN401\n pass\n```\n\nTrying to create an user using invalid payload will result in a validation error formatted as a Problem Details response. In particular, it will put the validation errors into an `errors` property in returned object.\n\n```bash\ncurl -X POST http://localhost:8000/users/ -d '{}' -H \"Content-Type: application/json\"\n{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Request validation failed\",\n \"errors\": [\n {\n \"type\": \"missing\",\n \"loc\": [\n \"body\",\n \"id\"\n ],\n \"msg\": \"Field required\",\n \"input\": {}\n },\n {\n \"type\": \"missing\",\n \"loc\": [\n \"body\",\n \"name\"\n ],\n \"msg\": \"Field required\",\n \"input\": {}\n }\n ]\n}\n```\n\n### Changing default validation error status code and/or detail\n\nBy default, validation errors will returns a 422 status code (FastAPI default) with a `\"Request validation failed\"` detail message.\nHowever, you can override both of those if you want.\n\n```python\nfrom fastapi import FastAPI, status\nimport fastapi_problem_details as problem\n\n\napp = FastAPI()\n\nproblem.init_app(app, validation_error_code=status.HTTP_400_BAD_REQUEST, validation_error_detail=\"Invalid payload!\")\n```\n\n## HTTP errors handling\n\nAny FastAPI or starlette `HTTPException` raised during a request will be automatically catched and formatted as a Problem details response.\n\n```python\nfrom typing import Any\n\nfrom fastapi import FastAPI, HTTPException, status\n\nimport fastapi_problem_details as problem\n\napp = FastAPI()\n\nproblem.init_app(app)\n\n\n@app.get(\"/\")\ndef raise_error() -> Any: # noqa: ANN401\n raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)\n```\n\nRequesting this endpoint will get you the following response\n\n```bash\ncurl http://localhost:8000/\n{\n \"type\":\"about:blank\",\n \"title\":\"Unauthorized\",\n \"status\":401,\n \"detail\":\"No permission -- see authorization schemes\",\n}\n```\n\nThe `title` property is the official phrase corresponding to the HTTP status code.\nThe `detail` property is filled with the one passed to the raised `HTTPException` and defaults to the description of the HTTP status code `http.HTTPStatus(status).description` if not provided.\n\n> Note that `headers` passed to the `HTTPException` will be returned as well.\n\n### Unexisting routes error handling\n\nRequests against non existing routes of your API also raise 404 `HTTPException`. The key difference is in the `detail` property message. This is pretty handy for clients to distinguish between a resource not found (e.g: trying to get an user which does not exist) and a route not existing\n\n```bash\ncurl -X POST http://localhost:8000/not-exist\n{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Nothing matches the given URI\",\n}\n```\n\n## Unexpected errors handling\n\nAny unexpected errors raised during processing of a request will be automatically handled by the plugin which will returns an internal server error formatted as a Problem Details.\n\n> Also note that the exception will be logged as well using logger named `fastapi_problem_details.error_handlers`\n\n```python\nfrom typing import Any\n\nfrom fastapi import FastAPI\n\nimport fastapi_problem_details as problem\n\napp = FastAPI()\n\nproblem.init_app(app)\n\n\nclass CustomError(Exception):\n pass\n\n\n@app.get(\"/\")\ndef raise_error() -> Any: # noqa: ANN401\n raise CustomError(\"Something went wrong...\")\n```\n\n```bash\n$ curl http://localhost:8000\n{\n \"type\": \"about:blank\",\n \"title\": \"Internal Server Error\",\n \"status\": 500,\n \"detail\": \"Server got itself in trouble\",\n}\n```\n\n### Including exceptions type and stack traces\n\nDuring development, it can sometimes be useful to include in your HTTP responses the type and stack trace of unhandled errors for easier debugging.\n\n```python\nproblem.init_app(app, include_exc_info_in_response=True)\n```\n\nDoing so will enrich Problem Details response with exception type `exc_type` (`str`) and stack trace `exc_stack` (`list[str]`)\n\n```bash\n$ curl http://localhost:8000\n{\n \"type\": \"about:blank\",\n \"title\": \"Internal Server Error\",\n \"status\": 500,\n \"detail\": \"Server got itself in trouble\",\n \"exc_type\": \"snippet.CustomError\",\n \"exc_stack\": [\n \"Traceback (most recent call last):\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/middleware/errors.py\\\", line 164, in __call__\\n await self.app(scope, receive, _send)\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/middleware/exceptions.py\\\", line 65, in __call__\\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/_exception_handler.py\\\", line 64, in wrapped_app\\n raise exc\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/_exception_handler.py\\\", line 53, in wrapped_app\\n await app(scope, receive, sender)\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\\\", line 756, in __call__\\n await self.middleware_stack(scope, receive, send)\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\\\", line 776, in app\\n await route.handle(scope, receive, send)\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\\\", line 297, in handle\\n await self.app(scope, receive, send)\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\\\", line 77, in app\\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/_exception_handler.py\\\", line 64, in wrapped_app\\n raise exc\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/_exception_handler.py\\\", line 53, in wrapped_app\\n await app(scope, receive, sender)\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/routing.py\\\", line 72, in app\\n response = await func(request)\\n ^^^^^^^^^^^^^^^^^^^\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/fastapi/routing.py\\\", line 278, in app\\n raw_response = await run_endpoint_function(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/fastapi/routing.py\\\", line 193, in run_endpoint_function\\n return await run_in_threadpool(dependant.call, **values)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/starlette/concurrency.py\\\", line 42, in run_in_threadpool\\n return await anyio.to_thread.run_sync(func, *args)\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/anyio/to_thread.py\\\", line 56, in run_sync\\n return await get_async_backend().run_sync_in_worker_thread(\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/anyio/_backends/_asyncio.py\\\", line 2177, in run_sync_in_worker_thread\\n return await future\\n ^^^^^^^^^^^^\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/.venv/lib/python3.11/site-packages/anyio/_backends/_asyncio.py\\\", line 859, in run\\n result = context.run(func, *args)\\n ^^^^^^^^^^^^^^^^^^^^^^^^\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/snippet.py\\\", line 22, in raise_error\\n return raise_some_error()\\n ^^^^^^^^^^^^^^^^^^\\n\",\n \" File \\\"/Users/gody/Development/OpenSource/fastapi-problem-details/snippet.py\\\", line 17, in raise_some_error\\n raise CustomError\\n\",\n \"snippet.CustomError\\n\"\n ]\n}\n```\n\n> :warning: This feature is expected to be used only for development purposes. You should not enable this on production because it can leak sensitive internal information. Use it at your own risk.\n\n## Custom errors handling\n\nTo handle specific errors in your API you can simply register custom error handlers (see [FastAPI documentation](https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers)) and returns `ProblemResponse` object.\n\n```python\nfrom typing import Any\n\nfrom fastapi import FastAPI, Request, status\n\nimport fastapi_problem_details as problem\nfrom fastapi_problem_details import ProblemResponse\n\napp = FastAPI()\nproblem.init_app(app)\n\n\nclass UserNotFoundError(Exception):\n def __init__(self, user_id: str) -> None:\n super().__init__(f\"There is no user with id {user_id!r}\")\n self.user_id = user_id\n\n\n@app.exception_handler(UserNotFoundError)\nasync def handle_user_not_found_error(\n _: Request, exc: UserNotFoundError\n) -> ProblemResponse:\n return ProblemResponse(\n status=status.HTTP_404_NOT_FOUND,\n type=\"/problems/user-not-found\",\n title=\"User Not Found\",\n detail=str(exc),\n user_id=exc.user_id,\n )\n\n\n@app.get(\"/users/{user_id}\")\ndef get_user(user_id: str) -> Any: # noqa: ANN401\n raise UserNotFoundError(user_id)\n\n```\n\nRequesting an user will get you following problem details\n\n```bash\n$ curl http://localhost:8000/users/1234\n{\n \"type\":\"/problems/user-not-found\",\n \"title\":\"User Not Found\",\n \"status\":404,\n \"detail\":\"There is no user with id '1234'\",\n \"user_id\":\"1234\"\n}\n```\n\nNote that in this example I've provided a custom `type` property but this might not be necessary for this use case. Basically, you should only use specific type and title when your error goes beyond the original meaning of the HTTP status code. See the [RFC](https://datatracker.ietf.org/doc/html/rfc9457#name-defining-new-problem-types) for more details.\n\n> Likewise, truly generic problems -- i.e., conditions that might apply to any resource on the Web -- are usually better expressed as plain status codes. For example, a \"write access disallowed\" problem is probably unnecessary, since a 403 Forbidden status code in response to a PUT request is self-explanatory.\n\nAlso note that you can include additional properties to the `ProblemResponse` object like `headers` or `instance`. Any extra properties will be added as-is in the returned Problem Details object (like the `user_id` in this example).\n\nLast but not least, any `null` values are stripped from returned Problem Details object.\n\n## Returning HTTP errors as Problem Details\n\nAs shown in previous sections, any `HTTPException` raised during a request cause the API to respond with a well formatted Problem Details object. However, what if we want to raise a `HTTPException` but with extra properties or Problem Details specific properties?\n\nIn that case you can instead raise a `ProblemException` exception.\n\n```python\nfrom typing import Any\n\nfrom fastapi import FastAPI, status\n\nimport fastapi_problem_details as problem\nfrom fastapi_problem_details import ProblemException\n\napp = FastAPI()\n\nproblem.init_app(app)\n\n\n@app.get(\"/\")\ndef raise_error() -> Any: # noqa: ANN401\n raise ProblemException(\n status=status.HTTP_503_SERVICE_UNAVAILABLE,\n detail=\"One or several internal services are not working properly\",\n service_1=\"down\",\n service_2=\"up\",\n headers={\"Retry-After\": \"30\"},\n )\n```\n\n```bash\ncurl http://localhost:8000 -v\n* Trying [::1]:8000...\n* connect to ::1 port 8000 failed: Connection refused\n* Trying 127.0.0.1:8000...\n* Connected to localhost (127.0.0.1) port 8000\n> GET / HTTP/1.1\n> Host: localhost:8000\n> User-Agent: curl/8.4.0\n> Accept: */*\n>\n< HTTP/1.1 503 Service Unavailable\n< date: Tue, 30 Jul 2024 14:10:02 GMT\n< server: uvicorn\n< retry-after: 30\n< content-length: 186\n< content-type: application/problem+json\n<\n* Connection #0 to host localhost left intact\n{\n \"type\":\"about:blank\",\n \"title\":\"Service Unavailable\",\n \"status\":503,\n \"detail\":\"One or several internal services are not working properly\",\n \"service_1\":\"down\",\n \"service_2\":\"up\"\n}\n```\n\nThe `ProblemException` exception takes almost same arguments as a `ProblemResponse`.\n\n### Keeping the code DRY\n\nIf you start having to raise almost the same `ProblemException` in several places of your code (for example when you validate a requester permissions) you have two ways to avoid copy-pasting the same object in many places of your code\n\n#### 1. Inheritance\n\nSimply create your own subclass of `ProblemException`\n\n```python\nfrom fastapi import status\n\nfrom fastapi_problem_details.models import ProblemException\n\n\nclass UserPermissionError(ProblemException):\n def __init__(\n self,\n user_id: str,\n headers: dict[str, str] | None = None,\n ) -> None:\n super().__init__(\n status=status.HTTP_403_FORBIDDEN,\n detail=f\"User {user_id} is not allowed to perform this operation\",\n headers=headers,\n user_id=user_id,\n )\n\n\ndef do_something_meaningful(user_id: str):\n raise UserPermissionError(user_id)\n```\n\nThe advantage of this solution is that its rather simple and straightforward. You do not have anything else to do to properly returns Problem Details responses.\n\nThe main issue of this is that it can cause your code to cross boundaries. If you start to use `ProblemException` into your domain logic, you couple your core code with your HTTP API. If you decide to build a CLI and/or and event based application using the same core logic, you'll end up with uncomfortable problem exception and status code which has no meaning here.\n\n#### 2. Custom error handlers\n\nThe other approach is to only use custom exceptions in your code and add custom error handlers in your FastAPI app to properly map your core errors with Problem Details.\n\n```python\nfrom typing import Any\n\nfrom fastapi import FastAPI, Request, status\n\nimport fastapi_problem_details as problem\nfrom fastapi_problem_details import ProblemResponse\n\napp = FastAPI()\nproblem.init_app(app)\n\n\nclass UserNotFoundError(Exception):\n def __init__(self, user_id: str) -> None:\n super().__init__(f\"There is no user with id {user_id!r}\")\n self.user_id = user_id\n\n\n@app.exception_handler(UserNotFoundError)\nasync def handle_user_not_found_error(\n _: Request, exc: UserNotFoundError\n) -> ProblemResponse:\n return ProblemResponse(\n status=status.HTTP_404_NOT_FOUND,\n type=\"/problems/user-not-found\",\n title=\"User Not Found\",\n detail=str(exc),\n user_id=exc.user_id,\n )\n\n\n@app.get(\"/users/{user_id}\")\ndef get_user(user_id: str) -> Any: # noqa: ANN401\n raise get_user_by_id(user_id)\n\n\n# somewhere else, in a repository.py file for example or dao.py\ndb: dict[str, dict[str, Any]] = {}\n\ndef get_user_by_id(user_id: str):\n if user_id not in db:\n raise UserNotFoundError(user_id)\n```\n\nThe biggest advantage of this solution is that you decouple your core code from your FastAPI app. You can define regular Python exceptions whatever you want and just do the conversion for your API in your custom error handler(s).\n\nThe disadvantage obviously is that it requires you to write more code. Its a question of balance.\n\n#### Wrapping up\n\nConsidering the two previous mechanisms, the way which worked best for me is to do the following:\n\n- When I raise errors in my core (domain code, business logic) I use dedicated exceptions, unrelated to HTTP nor APIs, and I add a custom error handler to my FastAPI app to handle and returns a ProblemResponse`.\n- When I want to raise an error directly in one of my API controller (i.e: a FastAPI route) I simply raise a `ProblemException`. If I'm raising same problem exception in several places I create a subclass of problem exception and put in my defaults and raise that error instead.\n\n## Documenting your custom problems details\n\nWhen registering problem details against your FastAPI app, it adds a `default` openapi response to all routes with the Problem Details schema. This might be enough in most cases but if you want to explicit additional problem details responses for specific status code or document additional properties you can register your Problem Details.\n\n```python\nfrom typing import Any, Literal\n\nfrom fastapi import FastAPI, Request, status\n\nimport fastapi_problem_details as problem\nfrom fastapi_problem_details import ProblemResponse\n\napp = FastAPI()\nproblem.init_app(app)\n\n\nclass UserNotFoundProblem(problem.Problem):\n status: Literal[404]\n user_id: str\n\n\nclass UserNotFoundError(Exception):\n def __init__(self, user_id: str) -> None:\n super().__init__(f\"There is no user with id {user_id!r}\")\n self.user_id = user_id\n\n\n@app.exception_handler(UserNotFoundError)\nasync def handle_user_not_found_error(\n _: Request, exc: UserNotFoundError\n) -> ProblemResponse:\n return ProblemResponse.from_exception(\n exc,\n status=status.HTTP_404_NOT_FOUND,\n detail=f\"User {exc.user_id} not found\",\n user_id=exc.user_id,\n )\n\n\n@app.get(\"/users/{user_id}\", responses={404: {\"model\": UserNotFoundProblem}})\ndef get_user(user_id: str) -> Any: # noqa: ANN401\n raise UserNotFoundError(user_id)\n```\n\nNote that this has limitation. Indeed, the `UserNotFoundProblem` class just act as a model schema for openapi documentation. You actually not instantiate this class and no validation is performed when returning the problem response. It means that the error handler can returns something which does not match a `UserNotFoundProblem`.\n\nThis is because of the way FastAPI manages errors. At the moment, there is no way to register error handler and its response schema in the same place and there is no mechanism to ensure both are synced.\n\n## Troubleshooting\n\n### Problem \"default\" openapi response is not added into additional FastAPI routers routes\n\nIf you use `APIRouter` from FastAPI to bind your routes and then include routers in your main API you must initializes the problem details error handlers BEFORE including the routers if you want your routes to have their OpenAPI responses documented with the `default` problem details response.\n\n```python\nfrom fastapi import APIRouter, FastAPI\n\nimport fastapi_problem_details as problem\n\napp = FastAPI()\nv1 = APIRouter(prefix=\"/v1\")\n\n# THIS DOES NOT WORK\napp.include_router(v1)\nproblem.init_app(app)\n\n# Instead, init problem errors handlers first\nproblem.init_app(app)\napp.include_router(v1)\n```\n",
"bugtrack_url": null,
"license": "MIT",
"summary": "Structure your FastAPI APIs error responses with consistent and machine readable format using the RFC 9457 \"Problem Details for HTTP APIs\" standard",
"version": "0.1.4",
"project_urls": {
"Changelog": "https://github.com/g0di/fastapi-problem-details/blob/main/README.md",
"Documentation": "https://github.com/g0di/fastapi-problem-details",
"Homepage": "https://github.com/g0di/fastapi-problem-details",
"Source": "https://github.com/g0di/fastapi-problem-details"
},
"split_keywords": [
"fastapi",
" problem",
" plugin"
],
"urls": [
{
"comment_text": "",
"digests": {
"blake2b_256": "a0ced74b1b3c8314b47a53de9c368b555a6f794a9ac8f3ce68f96f8d1975924c",
"md5": "b33df166530d01de05645a28bc68776e",
"sha256": "2d920bd49dc0bd0f72c1887b75807888f3601b21686f683df277638e4fd28486"
},
"downloads": -1,
"filename": "fastapi_problem_details-0.1.4-py3-none-any.whl",
"has_sig": false,
"md5_digest": "b33df166530d01de05645a28bc68776e",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": ">=3.10",
"size": 12285,
"upload_time": "2024-11-08T10:58:13",
"upload_time_iso_8601": "2024-11-08T10:58:13.094928Z",
"url": "https://files.pythonhosted.org/packages/a0/ce/d74b1b3c8314b47a53de9c368b555a6f794a9ac8f3ce68f96f8d1975924c/fastapi_problem_details-0.1.4-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": "",
"digests": {
"blake2b_256": "ace4d0a9e2b97c4f0ea31aa858f29898b8dc8af4028ffe88f9a34a1983494736",
"md5": "e9bc9346720b0161023b6efcae267c39",
"sha256": "6cfdaefb393eb28b8007a27b35b1ad6e89e7b1309be98e918db95636accfa2a7"
},
"downloads": -1,
"filename": "fastapi_problem_details-0.1.4.tar.gz",
"has_sig": false,
"md5_digest": "e9bc9346720b0161023b6efcae267c39",
"packagetype": "sdist",
"python_version": "source",
"requires_python": ">=3.10",
"size": 18753,
"upload_time": "2024-11-08T10:58:15",
"upload_time_iso_8601": "2024-11-08T10:58:15.007711Z",
"url": "https://files.pythonhosted.org/packages/ac/e4/d0a9e2b97c4f0ea31aa858f29898b8dc8af4028ffe88f9a34a1983494736/fastapi_problem_details-0.1.4.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2024-11-08 10:58:15",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "g0di",
"github_project": "fastapi-problem-details",
"travis_ci": false,
"coveralls": false,
"github_actions": true,
"tox": true,
"lcname": "fastapi-problem-details"
}