Browse Source

Separate Pydantic's ValidationError handler and improve docs for error handling (#273)

*  Implement separated ValidationError handlers and custom exceptions

*  Add tutorial source examples and tests

* 📝 Add docs for custom exception handlers

* 📝 Update docs section titles
pull/275/head
Sebastián Ramírez 6 years ago
committed by GitHub
parent
commit
62af6e0eeb
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 27
      docs/src/handling_errors/tutorial003.py
  2. 23
      docs/src/handling_errors/tutorial004.py
  3. 28
      docs/src/handling_errors/tutorial005.py
  4. 134
      docs/tutorial/handling-errors.md
  5. 21
      fastapi/applications.py
  6. 23
      fastapi/exception_handlers.py
  7. 9
      fastapi/exceptions.py
  8. 13
      fastapi/routing.py
  9. 91
      tests/test_tutorial/test_handling_errors/test_tutorial003.py
  10. 100
      tests/test_tutorial/test_handling_errors/test_tutorial004.py
  11. 103
      tests/test_tutorial/test_handling_errors/test_tutorial005.py

27
docs/src/handling_errors/tutorial003.py

@ -1,15 +1,26 @@
from fastapi import FastAPI from fastapi import FastAPI
from starlette.exceptions import HTTPException from starlette.requests import Request
from starlette.responses import PlainTextResponse from starlette.responses import JSONResponse
class UnicornException(Exception):
def __init__(self, name: str):
self.name = name
app = FastAPI() app = FastAPI()
@app.exception_handler(HTTPException) @app.exception_handler(UnicornException)
async def http_exception(request, exc): async def unicorn_exception_handler(request: Request, exc: UnicornException):
return PlainTextResponse(str(exc.detail), status_code=exc.status_code) return JSONResponse(
status_code=418,
content={"message": f"Oops! {exc.name} did something. There goes a rainbow..."},
)
@app.get("/") @app.get("/unicorns/{name}")
async def root(): async def read_unicorn(name: str):
return {"message": "Hello World"} if name == "yolo":
raise UnicornException(name=name)
return {"unicorn_name": name}

23
docs/src/handling_errors/tutorial004.py

@ -0,0 +1,23 @@
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import PlainTextResponse
app = FastAPI()
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
return PlainTextResponse(str(exc.detail), status_code=exc.status_code)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
return PlainTextResponse(str(exc), status_code=400)
@app.get("/items/{item_id}")
async def read_item(item_id: int):
if item_id == 3:
raise HTTPException(status_code=418, detail="Nope! I don't like 3.")
return {"item_id": item_id}

28
docs/src/handling_errors/tutorial005.py

@ -0,0 +1,28 @@
from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import (
http_exception_handler,
request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
app = FastAPI()
@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request, exc):
print(f"OMG! An HTTP error!: {exc}")
return await http_exception_handler(request, exc)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
print(f"OMG! The client sent invalid data!: {exc}")
return await request_validation_exception_handler(request, exc)
@app.get("/items/{item_id}")
async def read_item(item_id: int):
if item_id == 3:
raise HTTPException(status_code=418, detail="Nope! I don't like 3.")
return {"item_id": item_id}

134
docs/tutorial/handling-errors.md

@ -68,7 +68,7 @@ But if the client requests `http://example.com/items/bar` (a non-existent `item_
They are handled automatically by **FastAPI** and converted to JSON. They are handled automatically by **FastAPI** and converted to JSON.
### Adding custom headers ## Add custom headers
There are some situations in where it's useful to be able to add custom headers to the HTTP error. For example, for some types of security. There are some situations in where it's useful to be able to add custom headers to the HTTP error. For example, for some types of security.
@ -76,24 +76,138 @@ You probably won't need to use it directly in your code.
But in case you needed it for an advanced scenario, you can add custom headers: But in case you needed it for an advanced scenario, you can add custom headers:
```Python hl_lines="14" ```Python hl_lines="14"
{!./src/handling_errors/tutorial002.py!} {!./src/handling_errors/tutorial002.py!}
``` ```
### Installing custom handlers ## Install custom exception handlers
You can add custom exception handlers with <a href="https://www.starlette.io/exceptions/" target="_blank">the same exception utilities from Starlette</a>.
If you need to add other custom exception handlers, or override the default one (that sends the errors as JSON), you can use <a href="https://www.starlette.io/exceptions/" target="_blank">the same exception utilities from Starlette</a>. Let's say you have a custom exception `UnicornException` that you (or a library you use) might `raise`.
For example, you could override the default exception handler with: And you want to handle this exception globally with FastAPI.
```Python hl_lines="2 3 8 9 10" You could add a custom exception handler with `@app.exception_handler()`:
```Python hl_lines="6 7 8 14 15 16 17 18 24"
{!./src/handling_errors/tutorial003.py!} {!./src/handling_errors/tutorial003.py!}
``` ```
...this would make it return "plain text" responses with the errors, instead of JSON responses. Here, if you request `/unicorns/yolo`, the *path operation* will `raise` a `UnicornException`.
But it will be handled by the `unicorn_exception_handler`.
So, you will receive a clean error, with an HTTP status code of `418` and a JSON content of:
```JSON
{"message": "Oops! yolo did something. There goes a rainbow..."}
```
## Override the default exception handlers
**FastAPI** has some default exception handlers.
These handlers are in charge or returning the default JSON responses when you `raise` an `HTTPException` and when the request has invalid data.
You can override these exception handlers with your own.
### Override request validation exceptions
When a request contains invalid data, **FastAPI** internally raises a `RequestValidationError`.
And it also includes a default exception handler for it.
To override it, import the `RequestValidationError` and use it with `@app.exception_handler(RequestValidationError)` to decorate the exception handler.
The exception handler will receive a `Request` and the exception.
```Python hl_lines="2 14 15 16"
{!./src/handling_errors/tutorial004.py!}
```
Now, if you go to `/items/foo`, instead of getting the default JSON error with:
```JSON
{
"detail": [
{
"loc": [
"path",
"item_id"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
}
]
}
```
you will get a text version, with:
```
1 validation error
path -> item_id
value is not a valid integer (type=type_error.integer)
```
#### `RequestValidationError` vs `ValidationError`
!!! warning
These are technical details that you might skip if it's not important for you now.
`RequestValidationError` is a sub-class of Pydantic's <a href="https://pydantic-docs.helpmanual.io/#error-handling" target="_blank">`ValidationError`</a>.
**FastAPI** uses it so that, if you use a Pydantic model in `response_model`, and your data has an error, you will see the error in your log.
But the client/user will not see it. Instead, the client will receive an "Internal Server Error" with a HTTP status code `500`.
It should be this way because if you have a Pydantic `ValidationError` in your *response* or anywhere in your code (not in the client's *request*), it's actually a bug in your code.
And while you fix it, your clients/users shouldn't have access to internal information about the error, as that could expose a security vulnerability.
### Override the `HTTPException` error handler
The same way, you can override the `HTTPException` handler.
For example, you could want to return a plain text response instead of JSON for these errors:
```Python hl_lines="1 3 9 10 11 22"
{!./src/handling_errors/tutorial004.py!}
```
#### FastAPI's `HTTPException` vs Starlette's `HTTPException`
**FastAPI** has its own `HTTPException`.
And **FastAPI**'s `HTTPException` error class inherits from Starlette's `HTTPException` error class.
The only difference, is that **FastAPI**'s `HTTPException` allows you to add headers to be included in the response.
This is needed/used internally for OAuth 2.0 and some security utilities.
So, you can keep raising **FastAPI**'s `HTTPException` as normally in your code.
But when you register an exception handler, you should register it for Starlette's `HTTPException`.
This way, if any part of Starlette's internal code, or a Starlette extension or plug-in, raises an `HTTPException`, your handler will be able to catch handle it.
In this example, to be able to have both `HTTPException`s in the same code, Starlette's exceptions is renamed to `StarletteHTTPException`:
```Python
from starlette.exceptions import HTTPException as StarletteHTTPException
```
### Re-use **FastAPI**'s exception handlers
You could also just want to use the exception somehow, but then use the same default exception handlers from **FastAPI**.
You can import and re-use the default exception handlers from `fastapi.exception_handlers`:
```Python hl_lines="2 3 4 5 15 21"
{!./src/handling_errors/tutorial005.py!}
```
!!! info In this example, you are just `print`ing the error with a very expressive notification.
Note that in this example we set the exception handler with Starlette's `HTTPException` instead of FastAPI's `HTTPException`.
This would ensure that if you use a plug-in or any other third-party tool that raises Starlette's `HTTPException` directly, it will be caught by your exception handler. But you get the idea, you can use the exception and then just re-use the default exception handlers.

21
fastapi/applications.py

@ -1,6 +1,11 @@
from typing import Any, Callable, Dict, List, Optional, Set, Type, Union from typing import Any, Callable, Dict, List, Optional, Set, Type, Union
from fastapi import routing from fastapi import routing
from fastapi.exception_handlers import (
http_exception_handler,
request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from fastapi.openapi.docs import ( from fastapi.openapi.docs import (
get_redoc_html, get_redoc_html,
get_swagger_ui_html, get_swagger_ui_html,
@ -8,7 +13,6 @@ from fastapi.openapi.docs import (
) )
from fastapi.openapi.utils import get_openapi from fastapi.openapi.utils import get_openapi
from fastapi.params import Depends from fastapi.params import Depends
from pydantic import BaseModel
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.exceptions import ExceptionMiddleware, HTTPException from starlette.exceptions import ExceptionMiddleware, HTTPException
from starlette.middleware.errors import ServerErrorMiddleware from starlette.middleware.errors import ServerErrorMiddleware
@ -17,16 +21,6 @@ from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import BaseRoute from starlette.routing import BaseRoute
async def http_exception(request: Request, exc: HTTPException) -> JSONResponse:
headers = getattr(exc, "headers", None)
if headers:
return JSONResponse(
{"detail": exc.detail}, status_code=exc.status_code, headers=headers
)
else:
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
class FastAPI(Starlette): class FastAPI(Starlette):
def __init__( def __init__(
self, self,
@ -120,7 +114,10 @@ class FastAPI(Starlette):
) )
self.add_route(self.redoc_url, redoc_html, include_in_schema=False) self.add_route(self.redoc_url, redoc_html, include_in_schema=False)
self.add_exception_handler(HTTPException, http_exception) self.add_exception_handler(HTTPException, http_exception_handler)
self.add_exception_handler(
RequestValidationError, request_validation_exception_handler
)
def add_api_route( def add_api_route(
self, self,

23
fastapi/exception_handlers.py

@ -0,0 +1,23 @@
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
headers = getattr(exc, "headers", None)
if headers:
return JSONResponse(
{"detail": exc.detail}, status_code=exc.status_code, headers=headers
)
else:
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
async def request_validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
return JSONResponse(
status_code=HTTP_422_UNPROCESSABLE_ENTITY, content={"detail": exc.errors()}
)

9
fastapi/exceptions.py

@ -1,3 +1,4 @@
from pydantic import ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
@ -7,3 +8,11 @@ class HTTPException(StarletteHTTPException):
) -> None: ) -> None:
super().__init__(status_code=status_code, detail=detail) super().__init__(status_code=status_code, detail=detail)
self.headers = headers self.headers = headers
class RequestValidationError(ValidationError):
pass
class WebSocketRequestValidationError(ValidationError):
pass

13
fastapi/routing.py

@ -13,6 +13,7 @@ from fastapi.dependencies.utils import (
solve_dependencies, solve_dependencies,
) )
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
from pydantic import BaseConfig, BaseModel, Schema from pydantic import BaseConfig, BaseModel, Schema
from pydantic.error_wrappers import ErrorWrapper, ValidationError from pydantic.error_wrappers import ErrorWrapper, ValidationError
from pydantic.fields import Field from pydantic.fields import Field
@ -28,7 +29,7 @@ from starlette.routing import (
request_response, request_response,
websocket_session, websocket_session,
) )
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, WS_1008_POLICY_VIOLATION from starlette.status import WS_1008_POLICY_VIOLATION
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
@ -103,10 +104,7 @@ def get_app(
request=request, dependant=dependant, body=body request=request, dependant=dependant, body=body
) )
if errors: if errors:
errors_out = ValidationError(errors) raise RequestValidationError(errors)
raise HTTPException(
status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=errors_out.errors()
)
else: else:
assert dependant.call is not None, "dependant.call must be a function" assert dependant.call is not None, "dependant.call must be a function"
if is_coroutine: if is_coroutine:
@ -141,10 +139,7 @@ def get_websocket_app(dependant: Dependant) -> Callable:
) )
if errors: if errors:
await websocket.close(code=WS_1008_POLICY_VIOLATION) await websocket.close(code=WS_1008_POLICY_VIOLATION)
errors_out = ValidationError(errors) raise WebSocketRequestValidationError(errors)
raise HTTPException(
status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=errors_out.errors()
)
assert dependant.call is not None, "dependant.call must me a function" assert dependant.call is not None, "dependant.call must me a function"
await dependant.call(**values) await dependant.call(**values)

91
tests/test_tutorial/test_handling_errors/test_tutorial003.py

@ -0,0 +1,91 @@
from starlette.testclient import TestClient
from handling_errors.tutorial003 import app
client = TestClient(app)
openapi_schema = {
"openapi": "3.0.2",
"info": {"title": "Fast API", "version": "0.1.0"},
"paths": {
"/unicorns/{name}": {
"get": {
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
"summary": "Read Unicorn",
"operationId": "read_unicorn_unicorns__name__get",
"parameters": [
{
"required": True,
"schema": {"title": "Name", "type": "string"},
"name": "name",
"in": "path",
}
],
}
}
},
"components": {
"schemas": {
"ValidationError": {
"title": "ValidationError",
"required": ["loc", "msg", "type"],
"type": "object",
"properties": {
"loc": {
"title": "Location",
"type": "array",
"items": {"type": "string"},
},
"msg": {"title": "Message", "type": "string"},
"type": {"title": "Error Type", "type": "string"},
},
},
"HTTPValidationError": {
"title": "HTTPValidationError",
"type": "object",
"properties": {
"detail": {
"title": "Detail",
"type": "array",
"items": {"$ref": "#/components/schemas/ValidationError"},
}
},
},
}
},
}
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
assert response.json() == openapi_schema
def test_get():
response = client.get("/unicorns/shinny")
assert response.status_code == 200
assert response.json() == {"unicorn_name": "shinny"}
def test_get_exception():
response = client.get("/unicorns/yolo")
assert response.status_code == 418
assert response.json() == {
"message": "Oops! yolo did something. There goes a rainbow..."
}

100
tests/test_tutorial/test_handling_errors/test_tutorial004.py

@ -0,0 +1,100 @@
from starlette.testclient import TestClient
from handling_errors.tutorial004 import app
client = TestClient(app)
openapi_schema = {
"openapi": "3.0.2",
"info": {"title": "Fast API", "version": "0.1.0"},
"paths": {
"/items/{item_id}": {
"get": {
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
"summary": "Read Item",
"operationId": "read_item_items__item_id__get",
"parameters": [
{
"required": True,
"schema": {"title": "Item_Id", "type": "integer"},
"name": "item_id",
"in": "path",
}
],
}
}
},
"components": {
"schemas": {
"ValidationError": {
"title": "ValidationError",
"required": ["loc", "msg", "type"],
"type": "object",
"properties": {
"loc": {
"title": "Location",
"type": "array",
"items": {"type": "string"},
},
"msg": {"title": "Message", "type": "string"},
"type": {"title": "Error Type", "type": "string"},
},
},
"HTTPValidationError": {
"title": "HTTPValidationError",
"type": "object",
"properties": {
"detail": {
"title": "Detail",
"type": "array",
"items": {"$ref": "#/components/schemas/ValidationError"},
}
},
},
}
},
}
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
assert response.json() == openapi_schema
def test_get_validation_error():
response = client.get("/items/foo")
assert response.status_code == 400
validation_error_str_lines = [
b"1 validation error",
b"path -> item_id",
b" value is not a valid integer (type=type_error.integer)",
]
assert response.content == b"\n".join(validation_error_str_lines)
def test_get_http_error():
response = client.get("/items/3")
assert response.status_code == 418
assert response.content == b"Nope! I don't like 3."
def test_get():
response = client.get("/items/2")
assert response.status_code == 200
assert response.json() == {"item_id": 2}

103
tests/test_tutorial/test_handling_errors/test_tutorial005.py

@ -0,0 +1,103 @@
from starlette.testclient import TestClient
from handling_errors.tutorial005 import app
client = TestClient(app)
openapi_schema = {
"openapi": "3.0.2",
"info": {"title": "Fast API", "version": "0.1.0"},
"paths": {
"/items/{item_id}": {
"get": {
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
"summary": "Read Item",
"operationId": "read_item_items__item_id__get",
"parameters": [
{
"required": True,
"schema": {"title": "Item_Id", "type": "integer"},
"name": "item_id",
"in": "path",
}
],
}
}
},
"components": {
"schemas": {
"ValidationError": {
"title": "ValidationError",
"required": ["loc", "msg", "type"],
"type": "object",
"properties": {
"loc": {
"title": "Location",
"type": "array",
"items": {"type": "string"},
},
"msg": {"title": "Message", "type": "string"},
"type": {"title": "Error Type", "type": "string"},
},
},
"HTTPValidationError": {
"title": "HTTPValidationError",
"type": "object",
"properties": {
"detail": {
"title": "Detail",
"type": "array",
"items": {"$ref": "#/components/schemas/ValidationError"},
}
},
},
}
},
}
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
assert response.json() == openapi_schema
def test_get_validation_error():
response = client.get("/items/foo")
assert response.status_code == 422
assert response.json() == {
"detail": [
{
"loc": ["path", "item_id"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}
]
}
def test_get_http_error():
response = client.get("/items/3")
assert response.status_code == 418
assert response.json() == {"detail": "Nope! I don't like 3."}
def test_get():
response = client.get("/items/2")
assert response.status_code == 200
assert response.json() == {"item_id": 2}
Loading…
Cancel
Save