├── tests ├── __init__.py ├── test_util.py ├── test_error.py └── test_handler.py ├── src └── fastapi_problem │ ├── py.typed │ ├── __init__.py │ ├── cors.py │ ├── util.py │ ├── error.py │ └── handler.py ├── _typos.toml ├── docs ├── index.md ├── favicon.svg ├── hooks.md ├── usage.md ├── handlers.md └── error.md ├── .github ├── pull_request_template.md └── workflows │ ├── publish.yml │ ├── publish_docs.yml │ ├── style.yml │ └── tests.yml ├── tasks.py ├── .pre-commit-config.yaml ├── mkdocs.yml ├── examples ├── basic.py ├── auth.py ├── override.py ├── builtin.py └── custom.py ├── README.md ├── .gitignore ├── pyproject.toml ├── LICENSE └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/fastapi_problem/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/fastapi_problem/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_typos.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | extend-ignore-re = ['\[[0-9a-fA-F]{7}\]'] 3 | -------------------------------------------------------------------------------- /src/fastapi_problem/cors.py: -------------------------------------------------------------------------------- 1 | from starlette_problem.cors import CorsConfiguration 2 | 3 | __all__ = ["CorsConfiguration"] 4 | -------------------------------------------------------------------------------- /src/fastapi_problem/util.py: -------------------------------------------------------------------------------- 1 | from starlette_problem.util import convert_status_code 2 | 3 | __all__ = ["convert_status_code"] 4 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | `fastapi_problem` is a set of exception base classes and handlers for use in fastapi 4 | applications to support easy error management and responses 5 | 6 | Each exception easily marshals to JSON based on the 7 | [RFC9457](https://www.rfc-editor.org/rfc/rfc9457.html) spec for use in api 8 | errors. 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Set a conventional commit style topic 2 | 3 | ``` 4 | [(optional scope)][!]: 5 | ``` 6 | 7 | feat: what new feature was added 8 | feat!: what breaking change was made 9 | feat(scope): change to scope 10 | 11 | # Replace this description with additional information to be included in merge commit 12 | 13 | [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 14 | 15 | ``` 16 | [optional body] 17 | 18 | [optional footer(s)] 19 | ``` 20 | 21 | ``` 22 | More details bout the change. 23 | 24 | BREAKING CHANGE: 25 | Refs: #{issue} 26 | ``` 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Python 3.9 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.9.x" 18 | 19 | - name: Install uv 20 | uses: astral-sh/setup-uv@v2 21 | 22 | - name: Publish 23 | env: 24 | TWINE_USERNAME: __token__ 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PUBLISH_TOKEN }} 26 | run: | 27 | uv build 28 | uvx twine upload dist/* 29 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_problem import util 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("status_code", "title", "code"), 8 | [ 9 | (500, "Internal Server Error", "http-internal-server-error"), 10 | (400, "Bad Request", "http-bad-request"), 11 | (401, "Unauthorized", "http-unauthorized"), 12 | (404, "Not Found", "http-not-found"), 13 | (409, "Conflict", "http-conflict"), 14 | (422, "Unprocessable Entity", "http-unprocessable-entity"), 15 | ], 16 | ) 17 | def test_convert_status_code(status_code, title, code): 18 | assert util.convert_status_code(status_code) == (title, code) 19 | -------------------------------------------------------------------------------- /.github/workflows/publish_docs.yml: -------------------------------------------------------------------------------- 1 | name: publish_docs 2 | on: 3 | push: 4 | branches: [ "main" ] 5 | permissions: 6 | contents: write 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-python@v5 13 | with: 14 | python-version: 3.11.x 15 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 16 | - uses: actions/cache@v4 17 | with: 18 | key: mkdocs-material-${{ env.cache_id }} 19 | path: .cache 20 | restore-keys: | 21 | mkdocs-material- 22 | - run: pip install mkdocs-material 23 | - run: mkdocs gh-deploy --force 24 | -------------------------------------------------------------------------------- /src/fastapi_problem/error.py: -------------------------------------------------------------------------------- 1 | """Implement RFC9547 compatible exceptions. 2 | 3 | https://www.rfc-editor.org/rfc/rfc9457.html 4 | """ 5 | 6 | from rfc9457 import ( 7 | BadRequestProblem, 8 | ConflictProblem, 9 | ForbiddenProblem, 10 | NotFoundProblem, 11 | Problem, 12 | RedirectProblem, 13 | ServerProblem, 14 | StatusProblem, 15 | UnauthorisedProblem, 16 | UnprocessableProblem, 17 | ) 18 | 19 | __all__ = [ 20 | "Problem", 21 | "StatusProblem", 22 | "BadRequestProblem", 23 | "ConflictProblem", 24 | "ForbiddenProblem", 25 | "NotFoundProblem", 26 | "RedirectProblem", 27 | "ServerProblem", 28 | "UnauthorisedProblem", 29 | "UnprocessableProblem", 30 | ] 31 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: Style 2 | 3 | on: 4 | push: 5 | branches: 6 | # Push will only build on branches that match this name 7 | # Pull requests will override this, so pushes to pull requests will still build 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | check-style: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Python 3.9 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.9.x" 25 | 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v2 28 | 29 | - name: Install dependencies 30 | run: | 31 | uv sync --all-extras 32 | 33 | - name: Lint with ruff 34 | run: | 35 | uv run pre-commit run --all-files 36 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | """Collection of useful commands for code management. 2 | 3 | To view a list of available commands: 4 | 5 | $ invoke --list 6 | """ 7 | 8 | import invoke 9 | 10 | 11 | @invoke.task 12 | def install(context): 13 | """Install production requirements.""" 14 | context.run("uv sync") 15 | 16 | 17 | @invoke.task 18 | def install_dev(context): 19 | """Install development requirements.""" 20 | context.run("uv sync --all-extras") 21 | context.run("uv run pre-commit install") 22 | 23 | 24 | @invoke.task 25 | def check_style(context): 26 | """Run style checks.""" 27 | context.run("ruff check .") 28 | 29 | 30 | @invoke.task 31 | def tests(context): 32 | """Run pytest unit tests.""" 33 | context.run("pytest -x -s") 34 | 35 | 36 | @invoke.task 37 | def tests_coverage(context): 38 | """Run pytest unit tests with coverage.""" 39 | context.run("pytest --cov -x --cov-report=xml") 40 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: check-json 9 | - id: check-toml 10 | - id: fix-byte-order-marker 11 | - id: end-of-file-fixer 12 | 13 | - repo: https://github.com/crate-ci/typos 14 | rev: v1.24.5 15 | hooks: 16 | - id: typos 17 | args: [--force-exclude] 18 | 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: v0.6.4 21 | hooks: 22 | - id: ruff-format 23 | args: [--preview, -s] 24 | - id: ruff 25 | args: [--fix] 26 | 27 | - repo: https://github.com/Lucas-C/pre-commit-hooks 28 | rev: v1.3.1 29 | hooks: 30 | - id: remove-crlf 31 | exclude: docs/favicon.svg 32 | - id: remove-tabs 33 | exclude: docs/favicon.svg 34 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json 2 | site_name: FastAPI Problem 3 | site_url: https://nrwldev.github.io/fastapi-problem/ 4 | repo_name: fastapi-problem 5 | repo_url: https://github.com/NRWLDev/fastapi-problem 6 | nav: 7 | - index.md 8 | - usage.md 9 | - error.md 10 | - handlers.md 11 | - hooks.md 12 | theme: 13 | name: material 14 | favicon: favicon.svg 15 | icon: 16 | logo: fontawesome/solid/shield-halved 17 | repo: fontawesome/brands/git-alt 18 | features: 19 | - navigation.instant 20 | - navigation.instant.progress 21 | - navigation.sections 22 | - navigation.tracking 23 | - toc.follow 24 | markdown_extensions: 25 | - pymdownx.highlight: 26 | anchor_linenums: true 27 | line_spans: __span 28 | pygments_lang_class: true 29 | - pymdownx.inlinehilite 30 | - pymdownx.snippets 31 | - pymdownx.superfences 32 | extra: 33 | social: 34 | - icon: fontawesome/solid/envelope 35 | link: mailto:admin@nrwl.co 36 | -------------------------------------------------------------------------------- /tests/test_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_problem import error 4 | 5 | 6 | class NotFoundError(error.NotFoundProblem): 7 | title = "a 404 message" 8 | 9 | 10 | class InvalidAuthError(error.UnauthorisedProblem): 11 | title = "a 401 message" 12 | 13 | 14 | class BadRequestError(error.BadRequestProblem): 15 | title = "a 400 message" 16 | 17 | 18 | class ServerExceptionError(error.ServerProblem): 19 | title = "a 500 message" 20 | 21 | 22 | @pytest.mark.parametrize( 23 | ("exc", "type_"), 24 | [ 25 | (NotFoundError, "not-found"), 26 | (InvalidAuthError, "invalid-auth"), 27 | (BadRequestError, "bad-request"), 28 | (ServerExceptionError, "server-exception"), 29 | ], 30 | ) 31 | def test_marshal(exc, type_): 32 | e = exc("detail") 33 | 34 | assert e.marshal() == { 35 | "type": type_, 36 | "title": e.title, 37 | "detail": "detail", 38 | "status": e.status, 39 | } 40 | 41 | 42 | def test_subclass_chain(): 43 | assert isinstance(NotFoundError("detail"), error.Problem) 44 | assert isinstance(NotFoundError("detail"), error.StatusProblem) 45 | -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run this example: 3 | $ fastapi dev examples/basic.py 4 | 5 | To see a standard expected user error response. 6 | $ curl http://localhost:8000/user-error 7 | 8 | To see a standard expected server error response. 9 | $ curl http://localhost:8000/user-error 10 | """ 11 | 12 | import logging 13 | 14 | import fastapi 15 | 16 | from fastapi_problem.error import BadRequestProblem, ServerProblem 17 | from fastapi_problem.handler import add_exception_handler, new_exception_handler 18 | 19 | logging.getLogger("uvicorn.error").disabled = True 20 | 21 | 22 | class KnownProblem(BadRequestProblem): 23 | title = "Something we know about happened." 24 | 25 | 26 | class KnownServerProblem(ServerProblem): 27 | title = "Something you can't do anything about happened." 28 | 29 | 30 | app = fastapi.FastAPI() 31 | 32 | eh = new_exception_handler() 33 | add_exception_handler(app, eh) 34 | 35 | 36 | @app.get("/user-error") 37 | async def user_error() -> dict: 38 | raise KnownProblem("A known user error use case occurred.") 39 | 40 | 41 | @app.get("/server-error") 42 | async def server_error() -> dict: 43 | raise KnownServerProblem("A known server error use case occurred.") 44 | 45 | 46 | if __name__ == "__main__": 47 | app.run() 48 | -------------------------------------------------------------------------------- /examples/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run this example: 3 | $ fastapi dev examples/auth.py 4 | 5 | To see a 401 response. 6 | $ curl http://localhost:8000/authorized 7 | 8 | To see a 403 response. 9 | $ curl http://localhost:8000/authorized -H "Authorization: Bearer not-permitted" 10 | 11 | To see an authorized response. 12 | $ curl http://localhost:8000/authorized -H "Authorization: Bearer permitted" 13 | """ 14 | 15 | import fastapi 16 | from fastapi.security.http import HTTPAuthorizationCredentials, HTTPBearer 17 | from fastapi_problem.error import ( 18 | ForbiddenProblem, 19 | UnauthorisedProblem, 20 | ) 21 | from fastapi_problem.handler import add_exception_handler, new_exception_handler 22 | 23 | 24 | class AuthorizationRequiredError(UnauthorisedProblem): 25 | title = "Authorization token required." 26 | 27 | 28 | class PermissionRequiredError(ForbiddenProblem): 29 | title = "Permission required." 30 | 31 | 32 | async def check_auth( 33 | request: fastapi.Request, 34 | authorization: HTTPAuthorizationCredentials = fastapi.Depends(HTTPBearer(auto_error=False)), 35 | ) -> bool: 36 | if authorization is None: 37 | msg = "Missing Authorization header." 38 | raise AuthorizationRequiredError(msg) 39 | 40 | if authorization.credentials != "permitted": 41 | msg = "No active permissions." 42 | raise PermissionRequiredError(msg) 43 | 44 | return True 45 | 46 | 47 | app = fastapi.FastAPI() 48 | 49 | eh = new_exception_handler() 50 | add_exception_handler(app, eh) 51 | 52 | 53 | @app.get("/authorized") 54 | async def authorized( 55 | authorized=fastapi.Depends(check_auth), 56 | ) -> dict: 57 | return {"authorized": authorized} 58 | -------------------------------------------------------------------------------- /examples/override.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run this example: 3 | $ fastapi dev examples/override.py 4 | 5 | To see a custom starlette 405 error response. 6 | $ curl http://localhost:8000/not-allowed 7 | 8 | To see a custom starlette 404 error response. 9 | $ curl http://localhost:8000/not-found 10 | """ 11 | 12 | import logging 13 | 14 | import fastapi 15 | from starlette.exceptions import HTTPException 16 | 17 | from fastapi_problem.handler import ExceptionHandler, add_exception_handler, new_exception_handler 18 | from fastapi_problem.error import NotFoundProblem, Problem, ServerProblem, StatusProblem 19 | 20 | logging.getLogger("uvicorn.error").disabled = True 21 | 22 | 23 | class CustomNotFoundProblem(NotFoundProblem): 24 | type_ = "not-found" 25 | title = "Endpoint not available." 26 | 27 | 28 | class CustomNotAllowedProblem(StatusProblem): 29 | status = 405 30 | type_ = "method-not-allowed" 31 | title = "Method not allowed." 32 | 33 | 34 | status_mapping = { 35 | "404": (CustomNotFoundProblem, "The requested endpoint does not exist."), 36 | "405": (CustomNotAllowedProblem, "This method is not allowed."), 37 | } 38 | 39 | 40 | def http_exception_handler( 41 | _eh: ExceptionHandler, 42 | _request: fastapi.Request, 43 | exc: HTTPException, 44 | ) -> Problem: 45 | exc, detail = status_mapping.get(str(exc.status_code)) 46 | return exc(detail) 47 | 48 | 49 | app = fastapi.FastAPI() 50 | 51 | eh = new_exception_handler( 52 | http_exception_handler=http_exception_handler, 53 | ) 54 | add_exception_handler(app, eh) 55 | 56 | 57 | @app.post("/not-allowed") 58 | async def method_not_allowed() -> dict: 59 | return {} 60 | 61 | 62 | if __name__ == "__main__": 63 | app.run() 64 | -------------------------------------------------------------------------------- /docs/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 23 | 25 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /examples/builtin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run this example: 3 | $ fastapi dev examples/builtin.py 4 | 5 | To see a standard 422, fastapi RequestValidationError response. 6 | $ curl http://localhost:8000/validation-error 7 | 8 | To see a standard 422, fastapi RequestValidationError form validation response. 9 | $ curl http://localhost:8000/validation-error -X POST -H "Content-Type: application/json" --data '{"other": [{"inner_required": "provided"}, {}]}' 10 | 11 | To see a standard unhandled server error response. 12 | $ curl http://localhost:8000/unexpected-error 13 | 14 | To see a standard starlette 405 error response. 15 | $ curl http://localhost:8000/not-allowed 16 | 17 | To see a standard starlette 404 error response. 18 | $ curl http://localhost:8000/not-found 19 | """ 20 | 21 | import logging 22 | 23 | import fastapi 24 | import pydantic 25 | 26 | from fastapi_problem.handler import add_exception_handler, new_exception_handler 27 | 28 | logging.getLogger("uvicorn.error").disabled = True 29 | 30 | app = fastapi.FastAPI() 31 | 32 | eh = new_exception_handler() 33 | add_exception_handler(app, eh) 34 | 35 | 36 | @app.get("/validation-error") 37 | async def validation_error(required: str) -> dict: 38 | return {} 39 | 40 | 41 | class Other(pydantic.BaseModel): 42 | inner_required: str 43 | 44 | 45 | class NestedBody(pydantic.BaseModel): 46 | required: str 47 | other: list[Other] 48 | 49 | 50 | @app.post("/validation-error") 51 | async def validation_error( 52 | data: NestedBody, 53 | ) -> dict: 54 | return {} 55 | 56 | 57 | @app.post("/not-allowed") 58 | async def method_not_allowed() -> dict: 59 | return {} 60 | 61 | 62 | @app.get("/unexpected-error") 63 | async def unexpected_error() -> dict: 64 | return {"value": 1 / 0} 65 | 66 | 67 | if __name__ == "__main__": 68 | app.run() 69 | -------------------------------------------------------------------------------- /examples/custom.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run this example: 3 | $ fastapi dev examples/custom.py 4 | 5 | To see a custom 422, fastapi RequestValidationError response. 6 | $ curl http://localhost:8000/validation-error 7 | 8 | To see a custom unhandled server error response. 9 | $ curl http://localhost:8000/unexpected-error 10 | 11 | To see a custom starlette 405 error response. 12 | $ curl http://localhost:8000/not-allowed 13 | 14 | To see a custom starlette 404 error response. 15 | $ curl http://localhost:8000/not-found 16 | """ 17 | 18 | import logging 19 | 20 | import fastapi 21 | 22 | from fastapi_problem.handler import add_exception_handler, new_exception_handler 23 | from fastapi_problem.error import NotFoundProblem, ServerProblem, StatusProblem, UnprocessableProblem 24 | 25 | logging.getLogger("uvicorn.error").disabled = True 26 | 27 | 28 | class CustomNotFound(NotFoundProblem): 29 | title = "Endpoint not available." 30 | 31 | 32 | class CustomNotAllowed(StatusProblem): 33 | title = "Method not available." 34 | status = 405 35 | 36 | 37 | class CustomValidation(UnprocessableProblem): 38 | title = "Validation failed." 39 | 40 | 41 | class CustomServer(ServerProblem): 42 | title = "Server failed." 43 | 44 | 45 | app = fastapi.FastAPI() 46 | 47 | eh = new_exception_handler( 48 | unhandled_wrappers={ 49 | "404": CustomNotFound, 50 | "405": CustomNotAllowed, 51 | "422": CustomValidation, 52 | "default": CustomServer, 53 | }, 54 | ) 55 | add_exception_handler(app, eh) 56 | 57 | 58 | @app.get("/validation-error") 59 | async def validation_error(required: str) -> dict: 60 | return {} 61 | 62 | 63 | @app.post("/not-allowed") 64 | async def method_not_allowed() -> dict: 65 | return {} 66 | 67 | 68 | @app.get("/unexpected-error") 69 | async def unexpected_error() -> dict: 70 | return {"value": 1 / 0} 71 | 72 | 73 | if __name__ == "__main__": 74 | app.run() 75 | -------------------------------------------------------------------------------- /docs/hooks.md: -------------------------------------------------------------------------------- 1 | # Hooks 2 | 3 | Custom pre/post hook functions can be provided to the exception handler. 4 | 5 | ## Pre Hooks 6 | 7 | A pre hook will be provided with the current request, and exception. There 8 | should be no side effects in pre hooks and no return value, they can be used 9 | for informational purposes such as logging or debugging. 10 | 11 | ```python 12 | import logging 13 | 14 | import fastapi 15 | from fastapi_problem.handler import add_exception_handler, new_exception_handler 16 | from starlette.requests import Request 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def custom_hook(request: Request, exc: Exception) -> None: 22 | logger.debug(type(exc)) 23 | logger.debug(request.headers) 24 | 25 | 26 | app = fastapi.FastAPI() 27 | eh = new_exception_handler( 28 | pre_hooks=[custom_hook], 29 | ) 30 | add_exception_handler(app, eh) 31 | ``` 32 | 33 | ## Post Hooks 34 | 35 | A post hook will be provided with the raw content object, the incoming request, 36 | and the current response object. Post hooks can mutate the response object to 37 | provide additional headers etc. The CORS header implementation is done using a 38 | post hook. In the case the response format should be changed (if you have an 39 | xml api etc, the raw content can be reprocessed.). 40 | 41 | ```python 42 | import fastapi 43 | from fastapi_problem.handler import add_exception_handler, new_exception_handler 44 | from starlette.requests import Request 45 | from starlette.responses import Response 46 | 47 | logger = logging.getLogger(__name__) 48 | 49 | 50 | def custom_hook(content: dict, request: Request, response: Response) -> Response: 51 | if "x-custom-header" in request.headers: 52 | response.headers["x-custom-response"] = "set" 53 | 54 | return content, response 55 | 56 | 57 | app = fastapi.FastAPI() 58 | eh = new_exception_handler( 59 | post_hooks=[custom_hook], 60 | ) 61 | add_exception_handler(app, eh) 62 | ``` 63 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | # Push will only build on branches that match this name 7 | # Pull requests will override this, so pushes to pull requests will still build 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | 15 | test-coverage: 16 | 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 3.9 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.9.x" 25 | 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v2 28 | 29 | - name: Install dependencies 30 | run: uv sync --all-extras 31 | 32 | - name: Generate coverage report 33 | run: | 34 | uv run pytest --cov=src/fastapi_problem --cov-report=xml 35 | 36 | - name: Upload coverage to Codecov 37 | uses: codecov/codecov-action@v1 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | slug: NRWLDev/fastapi-problem 41 | file: ./coverage.xml 42 | flags: unittests 43 | name: codecov-umbrella 44 | yml: ./codecov.yml 45 | fail_ci_if_error: false 46 | 47 | # No legacy code currently 48 | # test-legacy: 49 | # 50 | # runs-on: ubuntu-latest 51 | # 52 | # steps: 53 | # - uses: actions/checkout@v3 54 | # - name: Set up Python 3.9 55 | # uses: actions/setup-python@v3 56 | # with: 57 | # python-version: "3.9.x" 58 | # - name: Install uv 59 | # uses: astral-sh/setup-uv@v2 60 | # - name: Install dependencies 61 | # run: uv sync --all-extras 62 | # - name: Generate coverage report 63 | # run: | 64 | # pytest -m "backwards_compat" 65 | 66 | test-python-versions: 67 | 68 | runs-on: ${{ matrix.os }} 69 | strategy: 70 | matrix: 71 | os: [ubuntu-latest, macos-latest, windows-latest] 72 | version: ["3.9.x", "3.10.x", "3.11.x", "3.12.x"] 73 | 74 | steps: 75 | - uses: actions/checkout@v4 76 | - name: Set up Python ${{ matrix.version }} 77 | uses: actions/setup-python@v5 78 | with: 79 | python-version: ${{ matrix.version }} 80 | 81 | - name: Install uv 82 | uses: astral-sh/setup-uv@v2 83 | 84 | - name: Install dependencies 85 | run: uv sync --all-extras 86 | 87 | - name: Test with pytest 88 | run: | 89 | uv run pytest 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Problems 2 | [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) 3 | [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 4 | [![image](https://img.shields.io/pypi/v/fastapi_problem.svg)](https://pypi.org/project/fastapi-problem/) 5 | [![image](https://img.shields.io/pypi/l/fastapi_problem.svg)](https://pypi.org/project/fastapi-problem/) 6 | [![image](https://img.shields.io/pypi/pyversions/fastapi_problem.svg)](https://pypi.org/project/fastapi-problem/) 7 | ![style](https://github.com/NRWLDev/fastapi-problem/actions/workflows/style.yml/badge.svg) 8 | ![tests](https://github.com/NRWLDev/fastapi-problem/actions/workflows/tests.yml/badge.svg) 9 | [![codecov](https://codecov.io/gh/NRWLDev/fastapi-problem/branch/main/graph/badge.svg)](https://codecov.io/gh/NRWLDev/fastapi-problem) 10 | 11 | `fastapi_problem` is a set of exceptions and handlers for use in fastapi 12 | applications to support easy error management and responses. 13 | 14 | Each exception easily marshals to JSON based on the 15 | [RFC9457](https://www.rfc-editor.org/rfc/rfc9457.html) spec for use in api 16 | errors. 17 | 18 | Check the [docs](https://nrwldev.github.io/fastapi-problem) for more details. 19 | 20 | ## Custom Errors 21 | 22 | Subclassing the convenience classes provide a simple way to consistently raise 23 | the same error with detail/extras changing based on the raised context. 24 | 25 | ```python 26 | from fastapi_problem.error import NotFoundProblem 27 | 28 | 29 | class UserNotFoundError(NotFoundProblem): 30 | title = "User not found." 31 | 32 | raise UserNotFoundError(detail="detail") 33 | ``` 34 | 35 | ```json 36 | { 37 | "type": "user-not-found", 38 | "title": "User not found", 39 | "detail": "detail", 40 | "status": 404, 41 | } 42 | ``` 43 | 44 | ## Usage 45 | 46 | ```python 47 | import fastapi 48 | from fastapi_problem.handler import add_exception_handler, new_exception_handler 49 | 50 | 51 | app = fastapi.FastAPI() 52 | eh = new_exception_handler() 53 | add_exception_handler(app, eh) 54 | 55 | @app.get("/user") 56 | async def get_user(): 57 | raise UserNotFoundError("No user found.") 58 | ``` 59 | 60 | ```bash 61 | $ curl localhost:8000/user 62 | { 63 | 64 | "type": "user-not-found", 65 | "title": "User not found", 66 | "detail": "No user found.", 67 | "status": 404, 68 | } 69 | ``` 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | .mutmut-cache 54 | *.bak 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | DEPRECATIONS.md 133 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastapi-problem" 3 | version = "0.11.6" 4 | description = "FastAPI support for RFC9457 problems." 5 | authors = [ 6 | {name = "Daniel Edgecombe", email = "daniel@nrwl.co"}, 7 | ] 8 | license = "Apache-2.0" 9 | readme = "README.md" 10 | requires-python = ">=3.9" 11 | keywords = ["exception", "handler", "webdev", "starlette"] 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Environment :: Web Environment", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Operating System :: OS Independent", 18 | "Topic :: Internet", 19 | "Topic :: Utilities", 20 | "Framework :: FastAPI", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3", 28 | ] 29 | 30 | dependencies = [ 31 | "rfc9457 >= 0.3.4", 32 | "starlette_problem >=0.12.0", 33 | ] 34 | 35 | [project.urls] 36 | homepage="https://github.com/NRWLDev/fastapi-problem/" 37 | documentation="https://nrwldev.github.io/fastapi-problem/" 38 | 39 | [project.optional-dependencies] 40 | dev = [ 41 | "fastapi", 42 | 43 | # test 44 | "pytest >= 7.4.3", 45 | "pytest-asyncio >= 0.21", 46 | "pytest-cov >= 4.1.0", 47 | "pytest-httpx >= 0.26.0", 48 | "pytest-random-order >= 1.0", 49 | 50 | # style 51 | "ruff >= 0.6.4", 52 | "pre-commit >= 3.7.1", 53 | 54 | # release 55 | "changelog-gen >= 0.12", 56 | ] 57 | 58 | [tool.changelog_gen] 59 | current_version = "0.11.6" 60 | reject_empty = true 61 | statistics = true 62 | allowed_branches = [ 63 | "main", 64 | ] 65 | date_format = "- %Y-%m-%d" 66 | 67 | [tool.changelog_gen.github] 68 | strip_pr_from_description = true 69 | extract_pr_from_description = true 70 | extract_common_footers = true 71 | 72 | [[tool.changelog_gen.extractors]] 73 | footer = ["closes", "fixes", "Refs"] 74 | pattern = '#(?P\d+)' 75 | 76 | [[tool.changelog_gen.link_generators]] 77 | source = "issue_ref" 78 | link = "https://github.com/NRWLDev/fastapi-problem/issues/{0}" 79 | 80 | [[tool.changelog_gen.link_generators]] 81 | source = "__change__" 82 | text = "{0.short_hash}" 83 | link = "https://github.com/NRWLDev/fastapi-problem/commit/{0.commit_hash}" 84 | 85 | [[tool.changelog_gen.files]] 86 | filename = "pyproject.toml" 87 | pattern = 'version = "{version}"' 88 | 89 | [tool.pytest.ini_options] 90 | asyncio_mode = "auto" 91 | testpaths = ["tests"] 92 | addopts = "--random-order" 93 | markers = [ 94 | "backwards_compat: marks tests as part of backwards compatibility checks.", 95 | ] 96 | 97 | [tool.coverage.report] 98 | sort = "cover" 99 | fail_under = 95 100 | show_missing = true 101 | skip_covered = true 102 | exclude_lines = [ 103 | "if t.TYPE_CHECKING:", 104 | ] 105 | 106 | [tool.coverage.run] 107 | branch = true 108 | source = ["src/fastapi_problem"] 109 | 110 | [tool.ruff] 111 | line-length = 120 112 | target-version = "py38" 113 | output-format = "concise" 114 | 115 | [tool.ruff.lint] 116 | select = ["ALL"] 117 | ignore = [ 118 | "ANN002", # ParamSpec not available in 3.9 119 | "ANN003", # ParamSpec not available in 3.9 120 | "E501", # Handled by ruff format 121 | "FIX", # allow TODO 122 | "D", 123 | ] 124 | 125 | [tool.ruff.lint.per-file-ignores] 126 | "tasks.py" = ["ANN", "E501", "INP001", "S"] 127 | "tests/*" = ["ANN", "D", "S101", "S105", "S106", "SLF001"] 128 | "examples/*" = ["ALL"] 129 | 130 | [tool.ruff.lint.isort] 131 | known-first-party = ["fastapi_problem"] 132 | 133 | [tool.ruff.lint.flake8-quotes] 134 | docstring-quotes = "double" 135 | 136 | [tool.ruff.lint.pydocstyle] 137 | convention = "google" 138 | 139 | [build-system] 140 | requires = ["hatchling"] 141 | build-backend = "hatchling.build" 142 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ```python 4 | import fastapi 5 | import fastapi_problem.handler 6 | 7 | 8 | app = fastapi.FastAPI() 9 | eh = fastapi_problem.handler.new_exception_handler() 10 | fastapi_problem.handler.add_exception_handler(app, eh) 11 | ``` 12 | 13 | A custom logger can be provided using: 14 | 15 | ```python 16 | new_exception_handler( 17 | logger=..., 18 | ) 19 | ``` 20 | 21 | If you require cors headers, you can pass a `fastapi_problem.cors.CorsConfiguration` 22 | instance to `new_exception_handler(cors=...)`. 23 | 24 | ```python 25 | new_exception_handler( 26 | cors=CorsConfiguration( 27 | allow_origins=["*"], 28 | allow_methods=["*"], 29 | allow_headers=["*"], 30 | allow_credentials=True, 31 | ) 32 | ) 33 | ``` 34 | 35 | To customise the way that errors, that are not a subclass of Problem, are 36 | handled provide `unhandled_wrappers`, a dict mapping an http status code to 37 | a `StatusProblem`, the system key `default` is also accepted as the root wrapper 38 | for all unhandled exceptions. 39 | 40 | ```python 41 | from fastapi_problem.error import StatusProblem 42 | from fastapi_problem.handler import add_exception_handler, new_exception_handler 43 | 44 | class NotFoundError(StatusProblem): 45 | status = 404 46 | message = "Endpoint not found." 47 | 48 | eh = new_exception_handler( 49 | unhandled_wrappers={ 50 | "404": NotFoundError, 51 | }, 52 | ) 53 | add_exception_handler(app, eh) 54 | ``` 55 | 56 | If you wish to hide debug messaging from external users, `StripExtrasPostHook` 57 | allows modifying the response content. `mandatory_fields` supports defining 58 | fields that should always be returned, default fields are `["type", "title", 59 | "status", "detail"]`. 60 | 61 | For more fine-grained control, `exclude_status_codes=[500, ...]` can be used to 62 | allow extras for specific status codes. Allowing expected fields to reach the 63 | user, while suppressing unexpected server errors etc. 64 | 65 | ```python 66 | from fastapi_problem.handler import StripExtrasPostHook, add_exception_handler, new_exception_handler 67 | 68 | eh = new_exception_handler( 69 | post_hooks=[ 70 | StripExtrasPostHook( 71 | mandatory_fields=["type", "title", "status", "detail", "custom-extra"], 72 | exclude_status_codes=[400], 73 | enabled=True, 74 | ) 75 | ], 76 | ) 77 | add_exception_handler(app, eh) 78 | ``` 79 | 80 | ## Swagger 81 | 82 | When the exception handlers are registered, the default `422` response type is 83 | updated to match the Problem format instead of the FastAPI default response. 84 | 85 | A generic `4XX` and `5XX` response is added to each path as well, these can be 86 | opted out of by passing `generic_swagger_defaults=False` when registering the 87 | exception handlers. 88 | 89 | ```python 90 | eh = new_exception_handler( 91 | generic_swagger_defaults=False, 92 | ) 93 | add_exception_handler(app, eh) 94 | ``` 95 | 96 | To specify specific error responses per endpoint, when registering the route 97 | the swagger responses for each possible error can be generated using the 98 | `generate_swagger_response` helper method. Multiple exceptions can be provided 99 | if the route can return different errors of the same status code. 100 | 101 | ``` 102 | from fastapi_problem.error import StatusProblem 103 | from fastapi_problem.handler import generate_swagger_response 104 | 105 | class NotFoundError(StatusProblem): 106 | status = 404 107 | title = "Endpoint not found." 108 | 109 | 110 | eh = new_exception_handler() 111 | add_exception_handler(app, eh) 112 | 113 | @app.post( 114 | "/path", 115 | responses={400: eh.generate_swagger_response(NotFoundError)}}, 116 | ) 117 | ... 118 | ``` 119 | ## Sentry 120 | 121 | `fastapi_problem` is designed to play nicely with [Sentry](https://sentry.io), 122 | there is no need to do anything special to integrate with sentry other than 123 | initializing the sdk. The Starlette and Fastapi integrations paired with the 124 | Logging integration will take care of everything. 125 | 126 | To prevent duplicated entries, ignoing the `uvicorn.error` logger in sentry can 127 | be handy. 128 | -------------------------------------------------------------------------------- /docs/handlers.md: -------------------------------------------------------------------------------- 1 | # Custom Handler 2 | 3 | In the event that you are using a third party library with a custom error 4 | class, a handler specifically a common base class can be provided. 5 | 6 | Providing a custom handler allows for conversion from the custom error class 7 | into a `Problem`, when the exception handler catches it, rather than converting 8 | each raised instance into a `Problem` at the time it is raised. 9 | 10 | ## Usage 11 | 12 | Given a `third_party` library with a `error.py` module. 13 | 14 | ```python 15 | class CustomBaseError(Exception): 16 | def __init__(reason: str, debug: str): 17 | self.reason = reason 18 | self.debug = debug 19 | ``` 20 | 21 | A custom handler can then be defined in your application. 22 | 23 | ```python 24 | import fastapi 25 | from rfc9457 import error_class_to_type 26 | from fastapi_problem.error import Problem 27 | from fastapi_problem.handler import ExceptionHandler, add_exception_handler, new_exception_handler 28 | from starlette.requests import Request 29 | 30 | from third_party.error import CustomBaseError 31 | 32 | def my_custom_handler(eh: ExceptionHandler, request: Request, exc: CustomBaseError) -> Problem: 33 | return Problem( 34 | title=exc.reason, 35 | detail=exc.debug, 36 | type_=error_class_to_type(exc), 37 | status=500, 38 | headers={"x-custom-header": "value"}, 39 | ) 40 | 41 | app = fastapi.FastAPI() 42 | eh = new_exception_handler( 43 | handlers={ 44 | CustomBaseError: my_custom_handler, 45 | }, 46 | ) 47 | add_exception_handler(app, eh) 48 | ``` 49 | 50 | Any instance of CustomBaseError, or any subclasses, that reach the exception 51 | handler will then be converted into a Problem response, as opposed to an 52 | unhandled error response. 53 | 54 | ## Builtin Handlers 55 | 56 | Starlette HTTPException and fastapi RequestValidationError instances are 57 | handled by default, to customise how these errors are processed, provide a 58 | handler for `starlette.exceptions.HTTPException` or 59 | `fastapi.exceptions.RequestValidationError`, similar to the custom handlers 60 | previously defined, but rather than passing it to handlers, use the 61 | `http_exception_handler` and `request_validation_handler` parameters respectively. 62 | 63 | ```python 64 | import fastapi 65 | from fastapi_problem.error import Problem 66 | from fastapi_problem.handler import ExceptionHandler, add_exception_handler, new_exception_handler 67 | from starlette.exceptions import HTTPException 68 | from starlette.requests import Request 69 | 70 | 71 | def my_custom_handler(eh: ExceptionHandler, request: Request, exc: HTTPException) -> Problem: 72 | return Problem(...) 73 | 74 | 75 | app = fastapi.FastAPI() 76 | eh = new_excep, new_exception_handler( 77 | http_exception_handler=my_custom_handler, 78 | ) 79 | add_exception_handler(app, eh) 80 | ``` 81 | 82 | ### Optional handling 83 | 84 | In some cases you may want to handle specific cases for a type of exception, 85 | but let others defer to another handler. In these scenarios, a custom handler 86 | can return None rather than a Problem. If a handler returns None the exception 87 | will be pass to the next defined handler. 88 | 89 | ```python 90 | import fastapi 91 | from rfc9457 import error_class_to_type 92 | from fastapi_problem.error import Problem 93 | from fastapi_problem.handler import ExceptionHandler, add_exception_handler, new_exception_handler 94 | from starlette.requests import Request 95 | 96 | def no_response_handler(eh: ExceptionHandler, request: Request, exc: RuntimeError) -> Problem | None: 97 | if str(exc) == "No response returned.": 98 | return Problem( 99 | title="No response returned.", 100 | detail="starlette bug", 101 | type_="no-response", 102 | status=409, 103 | ) 104 | return None 105 | 106 | def base_handler(eh: ExceptionHandler, request: Request, exc: Exception) -> Problem: 107 | return Problem( 108 | title=exc.reason, 109 | detail=exc.debug, 110 | type_=error_class_to_type(exc), 111 | status=500, 112 | ) 113 | 114 | app = fastapi.FastAPI() 115 | eh = new_exception_handler( 116 | handlers={ 117 | RuntimeError: no_response_handler, 118 | Exception: base_handler, 119 | }, 120 | ) 121 | add_exception_handler(app, eh) 122 | ``` 123 | 124 | At the time of writing there was (is?) a 125 | [bug](https://github.com/encode/starlette/issues/2516) in starlette that would 126 | cause middlewares to error. To prevent these from reaching Sentry, a deferred 127 | handler was implemented in the impacted project. 128 | -------------------------------------------------------------------------------- /docs/error.md: -------------------------------------------------------------------------------- 1 | # Errors 2 | 3 | The base `fastapi_problem.error.Problem` accepts a `title`, `detail`, `status` 4 | (default 500) and optional `**kwargs`. An additional `code` can be passed in, 5 | which will be used as the `type`, if not provided the `type` is derived from 6 | the class name. 7 | 8 | And will return a JSON response with `exc.status` as the status code and response body: 9 | 10 | ```json 11 | { 12 | "type": "an-exception", 13 | "title": "title", 14 | "detail": "detail", 15 | "status": 500, 16 | "extra-key": "extra-value", 17 | ... 18 | } 19 | ``` 20 | 21 | Derived types are generated using the class name after dropping `...Error` from 22 | the end, and converting to `kebab-case`. i.e. `PascalCaseError` will derive the 23 | type `pascal-case`. If the class name doesn't suit your purposes, an optional 24 | `code` attribute can be set with the desired value of there response `type` 25 | field. 26 | 27 | Some convenience Problems are provided with predefined `status` attributes. 28 | To create custom errors subclasss these and define the `title` attribute. 29 | 30 | * `fastapi_problem.error.ServerProblem` provides status 500 errors 31 | * `fastapi_problem.error.RedirectProblem` provides status 301 errors 32 | * `fastapi_problem.error.BadRequestProblem` provides status 400 errors 33 | * `fastapi_problem.error.UnauthorisedProblem` provides status 401 errors 34 | * `fastapi_problem.error.ForbiddenProblem` provides status 403 errors 35 | * `fastapi_problem.error.NotFoundProblem` provides status 404 errors 36 | * `fastapi_problem.error.ConflictProblem` provides status 409 errors 37 | * `fastapi_problem.error.UnprocessableProblem` provides status 422 errors 38 | 39 | ## Custom Errors 40 | 41 | Subclassing the convenience classes provide a simple way to consistently raise the same error 42 | with detail/extras changing based on the raised context. 43 | 44 | ```python 45 | from fastapi_problem.error import NotFoundProblem 46 | 47 | 48 | class UserNotFoundError(NotFoundProblem): 49 | title = "User not found." 50 | 51 | raise UserNotFoundError(detail="detail") 52 | ``` 53 | 54 | ```json 55 | { 56 | "type": "user-not-found", 57 | "title": "User not found", 58 | "detail": "detail", 59 | "status": 404, 60 | } 61 | ``` 62 | 63 | Whereas a defined `code` will be used in the output. 64 | 65 | ```python 66 | class UserNotFoundError(NotFoundProblem): 67 | title = "User not found." 68 | type_ = "cant-find-user" 69 | 70 | raise UserNotFoundError(detail="detail") 71 | ``` 72 | 73 | ```json 74 | { 75 | "type": "cant-find-user", 76 | "title": "User not found", 77 | "detail": "detail", 78 | "status": 404, 79 | } 80 | ``` 81 | 82 | If additional kwargs are provided when the error is raised, they will be 83 | included in the output (ensure the provided values are json seriablizable. 84 | 85 | 86 | ```python 87 | raise UserNotFoundError(detail="detail", user_id="1234", metadata={"hello": "world"}) 88 | ``` 89 | 90 | ```json 91 | { 92 | ... 93 | "detail": "detail", 94 | "user_id": "1234", 95 | "metadata": {"hello": "world"}, 96 | } 97 | ``` 98 | 99 | ### Headers 100 | 101 | Problem subclasses can define specific headers at definition, or provide 102 | instance specific headers at raise. These headers will be extracted and 103 | returned as part of the response. 104 | 105 | Headers provided when raising will overwrite any matching headers defined on the class. 106 | 107 | ```python 108 | class HeaderProblem(StatusProblem): 109 | status = 400 110 | headers = {"x-define-header": "value"} 111 | 112 | 113 | raise HeaderProblem(headers={"x-instance-header": "value2"}) 114 | 115 | response.headers == { 116 | "x-define-header": "value", 117 | "x-instance-header": "value2", 118 | } 119 | ``` 120 | 121 | ### Redirects 122 | 123 | An additional helper class `RedirectProblem` is provided for handling 3XX 124 | problems with a `Location` header. This subclass takes an additional required 125 | init argument `location`. 126 | 127 | ```python 128 | class PermanentRedirect(RedirectProblem): 129 | status = 308 130 | title = "Permanent redirect" 131 | 132 | 133 | raise PermanentRedirect("https://location", "detail of move") 134 | 135 | e.headers == { 136 | "Location": "https://location", 137 | } 138 | ``` 139 | 140 | ## Error Documentation 141 | 142 | The RFC-9457 spec defines that the `type` field should provide a URI that can 143 | link to documentation about the error type that has occurred. By default the 144 | Problem class provides a unique identifier for the type, rather than a full 145 | url. If your service/project provides documentation on error types, the 146 | documentation uri can be provided to the handler which will result in response 147 | `type` fields being converted to a full link. The uri `.format()` will be 148 | called with the type, title, status and any additional extras provided when the 149 | error is raised. 150 | 151 | ```python 152 | eh = new_exception_handler( 153 | documentation_uri_template="https://link-to/my/errors/{type}", 154 | ) 155 | add_exception_handler(app, eh) 156 | ``` 157 | 158 | ```json 159 | { 160 | "type": "https://link-to/my/errors/an-exception", 161 | ... 162 | } 163 | ``` 164 | 165 | Where a full resolvable documentation uri does not exist, the rfc allows for a 166 | [tag uri](https://en.wikipedia.org/wiki/Tag_URI_scheme#Format). 167 | 168 | ```python 169 | eh = new_exception_handler( 170 | documentation_uri_template="https://link-to/my/errors/{type}", 171 | ) 172 | add_exception_handler(app, eh) 173 | ``` 174 | 175 | ```json 176 | { 177 | "type": "tag:my-domain.com,2024-01-01:an-exception", 178 | ... 179 | } 180 | ``` 181 | 182 | ### Strict mode 183 | 184 | The RFC-9457 spec defines the type as requiring a URI format, when no reference 185 | is provided, it should default to `about:blank`. Initializing the handler in 186 | `strict_rfc9457` more requires the `documentation_uri_template` to be defined, and 187 | in cases where the Problem doesn't explicitly define a `type_` attribute, the 188 | type will default to `about:blank`. 189 | 190 | ```python 191 | eh = new_exception_handler( 192 | documentation_uri_template="https://link-to/my/errors/{type}", 193 | strict_rfc9457=True, 194 | ) 195 | add_exception_handler(app, eh) 196 | ``` 197 | 198 | ```json 199 | { 200 | "type": "about:blank", 201 | ... 202 | } 203 | ``` 204 | -------------------------------------------------------------------------------- /src/fastapi_problem/handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing as t 5 | from http.client import responses 6 | from warnings import warn 7 | 8 | import rfc9457 9 | from fastapi.exceptions import RequestValidationError 10 | from rfc9457.openapi import problem_component, problem_response 11 | from starlette.exceptions import HTTPException 12 | from starlette_problem.handler import ( 13 | CorsPostHook, 14 | Handler, 15 | PostHook, 16 | PreHook, 17 | StripExtrasPostHook, 18 | http_exception_handler_, 19 | ) 20 | from starlette_problem.handler import ExceptionHandler as BaseExceptionHandler 21 | 22 | from fastapi_problem.error import Problem, StatusProblem 23 | 24 | if t.TYPE_CHECKING: 25 | import logging 26 | 27 | from fastapi import FastAPI 28 | from starlette.requests import Request 29 | 30 | from fastapi_problem.cors import CorsConfiguration 31 | 32 | 33 | def _generate_swagger_response( 34 | *exceptions: type[Problem] | Problem, 35 | documentation_uri_template: str = "", 36 | strict: bool = False, 37 | ) -> dict: 38 | examples = [] 39 | for e in exceptions: 40 | exc = e("Additional error context.") if not isinstance(e, Problem) else e 41 | examples.append(exc.marshal(uri=documentation_uri_template, strict=strict)) 42 | return problem_response( 43 | responses[exceptions[0].status], 44 | examples=examples, 45 | ) 46 | 47 | 48 | def generate_swagger_response( 49 | *exceptions: type[Problem] | Problem, 50 | documentation_uri_template: str = "", 51 | strict: bool = False, 52 | ) -> dict: 53 | warn( 54 | "Direct calls to generate_swagger_response are being deprecated, use `eh.generate_swagger_response(...)` instead.", 55 | FutureWarning, 56 | stacklevel=2, 57 | ) 58 | return _generate_swagger_response( 59 | *exceptions, 60 | documentation_uri_template=documentation_uri_template, 61 | strict=strict, 62 | ) 63 | 64 | 65 | class ExceptionHandler(BaseExceptionHandler): 66 | def generate_swagger_response(self, *exceptions: type[Problem] | Problem) -> dict: 67 | return _generate_swagger_response( 68 | *exceptions, 69 | documentation_uri_template=self.documentation_uri_template, 70 | strict=self.strict, 71 | ) 72 | 73 | 74 | def customise_openapi( 75 | func: t.Callable[..., dict], 76 | *, 77 | documentation_uri_template: str = "", 78 | strict: bool = False, 79 | generic_defaults: bool = True, 80 | ) -> t.Callable[..., dict]: 81 | """Customize OpenAPI schema.""" 82 | 83 | def wrapper() -> dict: 84 | """Wrapper.""" 85 | res = func() 86 | 87 | if not res["paths"]: 88 | # If there are no paths, we don't need to add any responses 89 | return res 90 | 91 | if "components" not in res: 92 | res["components"] = {"schemas": {}} 93 | elif "schemas" not in res["components"]: 94 | res["components"]["schemas"] = {} 95 | 96 | validation_error = problem_component( 97 | "RequestValidationError", 98 | required=["errors"], 99 | errors={ 100 | "type": "array", 101 | "items": { 102 | "$ref": "#/components/schemas/ValidationError", 103 | }, 104 | }, 105 | ) 106 | problem = problem_component("Problem") 107 | 108 | res["components"]["schemas"]["HTTPValidationError"] = validation_error 109 | res["components"]["schemas"]["Problem"] = problem 110 | 111 | for methods in res["paths"].values(): 112 | for details in methods.values(): 113 | if ( 114 | "422" in details["responses"] 115 | and "application/problem+json" not in details["responses"]["422"]["content"] 116 | ): 117 | details["responses"]["422"]["content"]["application/problem+json"] = details["responses"]["422"][ 118 | "content" 119 | ].pop("application/json") 120 | if generic_defaults: 121 | user_error = Problem( 122 | "User facing error message.", 123 | type_="client-error-type", 124 | status=400, 125 | detail="Additional error context.", 126 | ) 127 | server_error = Problem( 128 | "User facing error message.", 129 | type_="server-error-type", 130 | status=500, 131 | detail="Additional error context.", 132 | ) 133 | details["responses"]["4XX"] = problem_response( 134 | description="Client Error", 135 | examples=[user_error.marshal(uri=documentation_uri_template, strict=strict)], 136 | ) 137 | details["responses"]["5XX"] = problem_response( 138 | description="Server Error", 139 | examples=[server_error.marshal(uri=documentation_uri_template, strict=strict)], 140 | ) 141 | 142 | return res 143 | 144 | return wrapper 145 | 146 | 147 | def request_validation_handler_( 148 | eh: ExceptionHandler, 149 | _request: Request, 150 | exc: RequestValidationError, 151 | ) -> Problem: 152 | wrapper = eh.unhandled_wrappers.get("422") 153 | errors = json.loads(json.dumps(exc.errors(), default=str)) 154 | kwargs = {"errors": errors} 155 | return ( 156 | wrapper(**kwargs) 157 | if wrapper 158 | else Problem( 159 | title="Request validation error.", 160 | type_="request-validation-failed", 161 | status=422, 162 | **kwargs, 163 | ) 164 | ) 165 | 166 | 167 | def new_exception_handler( # noqa: PLR0913 168 | logger: logging.Logger | None = None, 169 | cors: CorsConfiguration | None = None, 170 | unhandled_wrappers: dict[str, type[StatusProblem]] | None = None, 171 | handlers: dict[type[Exception], Handler] | None = None, 172 | pre_hooks: list[PreHook] | None = None, 173 | post_hooks: list[PostHook] | None = None, 174 | documentation_uri_template: str = "", 175 | http_exception_handler: Handler = http_exception_handler_, 176 | request_validation_handler: Handler = request_validation_handler_, 177 | *, 178 | strict_rfc9457: bool = False, 179 | ) -> ExceptionHandler: 180 | handlers = handlers or {} 181 | handlers.update( 182 | { 183 | HTTPException: http_exception_handler, 184 | RequestValidationError: request_validation_handler, 185 | }, 186 | ) 187 | pre_hooks = pre_hooks or [] 188 | post_hooks = post_hooks or [] 189 | 190 | if cors: 191 | # Ensure it runs first before any custom modifications 192 | post_hooks.insert(0, CorsPostHook(cors)) 193 | 194 | return ExceptionHandler( 195 | logger=logger, 196 | unhandled_wrappers=unhandled_wrappers, 197 | handlers=handlers, 198 | pre_hooks=pre_hooks, 199 | post_hooks=post_hooks, 200 | documentation_uri_template=documentation_uri_template, 201 | strict_rfc9457=strict_rfc9457, 202 | ) 203 | 204 | 205 | def add_exception_handler( # noqa: PLR0913 206 | app: FastAPI, 207 | eh: ExceptionHandler | None = None, 208 | *, 209 | logger: logging.Logger | None = None, 210 | cors: CorsConfiguration | None = None, 211 | unhandled_wrappers: dict[str, type[StatusProblem]] | None = None, 212 | handlers: dict[type[Exception], Handler] | None = None, 213 | pre_hooks: list[PreHook] | None = None, 214 | post_hooks: list[PostHook] | None = None, 215 | documentation_uri_template: str = "", 216 | http_exception_handler: Handler = http_exception_handler_, 217 | request_validation_handler: Handler = request_validation_handler_, 218 | generic_swagger_defaults: bool = True, 219 | strict_rfc9457: bool = False, 220 | ) -> ExceptionHandler: 221 | if eh is None: 222 | warn( 223 | "Generating exception handler while adding is being deprecated, use `new_exception_handler(...)`..", 224 | FutureWarning, 225 | stacklevel=2, 226 | ) 227 | 228 | eh = new_exception_handler( 229 | logger=logger, 230 | cors=cors, 231 | unhandled_wrappers=unhandled_wrappers, 232 | handlers=handlers, 233 | pre_hooks=pre_hooks, 234 | post_hooks=post_hooks, 235 | documentation_uri_template=documentation_uri_template, 236 | http_exception_handler=http_exception_handler, 237 | request_validation_handler=request_validation_handler, 238 | strict_rfc9457=strict_rfc9457, 239 | ) 240 | 241 | app.add_exception_handler(Exception, eh) 242 | app.add_exception_handler(rfc9457.Problem, eh) 243 | app.add_exception_handler(HTTPException, eh) 244 | app.add_exception_handler(RequestValidationError, eh) 245 | 246 | # Override default 422 with Problem schema 247 | app.openapi = customise_openapi( 248 | app.openapi, 249 | generic_defaults=generic_swagger_defaults, 250 | documentation_uri_template=eh.documentation_uri_template, 251 | strict=eh.strict, 252 | ) 253 | 254 | return eh 255 | 256 | 257 | __all__ = [ 258 | "CorsPostHook", 259 | "ExceptionHandler", 260 | "Handler", 261 | "PostHook", 262 | "PreHook", 263 | "StripExtrasPostHook", 264 | "new_exception_handler", 265 | "add_exception_handler", 266 | "http_exception_handler_", 267 | "request_validation_handler_", 268 | ] 269 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Qadre 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.11.6 - 2025-10-20 4 | 5 | ### Bug fixes 6 | 7 | - (`handler`) fix an invalid type hint [[a40de5a](https://github.com/NRWLDev/fastapi-problem/commit/a40de5a08c7b93dc163f66e7d6d80e50bfcc94fd)] 8 | 9 | ## v0.11.5 - 2025-08-26 10 | 11 | ### Bug fixes 12 | 13 | - OpenAPI Swagger docs generation in certain scenarios [[44](https://github.com/NRWLDev/fastapi-problem/issues/44)] [[18a18cf](https://github.com/NRWLDev/fastapi-problem/commit/18a18cfe54cef1b87abab3a4474f0906d49cda6a)] 14 | 15 | ## v0.11.4 - 2025-05-05 16 | 17 | ### Documentation 18 | 19 | - Add documentation url for pypi. [[f580c25](https://github.com/NRWLDev/fastapi-problem/commit/f580c2535e31207a11ba2975fb41cd635986ed9f)] 20 | 21 | ## v0.11.3 - 2025-05-05 22 | 23 | ### Bug fixes 24 | 25 | - Ensure urls appear in pypi. [[524c4ba](https://github.com/NRWLDev/fastapi-problem/commit/524c4ba791ee75a165bb25430ccff741db7732fa)] 26 | 27 | ## v0.11.2 - 2025-04-30 28 | 29 | ### Bug fixes 30 | 31 | - Support instantiated problems when generating swagger responses to allow for customising details and additional keys. [[85d4a7d](https://github.com/NRWLDev/fastapi-problem/commit/85d4a7d916054c15c474a4fc8d11c8daa9b52a3d)] 32 | - Ensure generic swagger problem definitions inherit documentation uri template and strict settings. [[f33e013](https://github.com/NRWLDev/fastapi-problem/commit/f33e01398d130815df487fa6578629fa8ae5278e)] 33 | - Correctly set 422 content type in swagger when generic error responses are excluded. [[997a2a6](https://github.com/NRWLDev/fastapi-problem/commit/997a2a6e327b6870baca81290e33d5589b922707)] 34 | 35 | ## v0.11.1 - 2025-04-29 36 | 37 | ### Features and Improvements 38 | 39 | - Add support for generating response examples for multiple exceptions under the same status code in swagger docs. [[37](https://github.com/NRWLDev/fastapi-problem/issues/37)] [[863178e](https://github.com/NRWLDev/fastapi-problem/commit/863178ea7efb8af77dad850ab685a18a83d14b03)] 40 | - Add generate_swagger_response method to ExceptionHandler to support generating responses with documentation_uris. [[38](https://github.com/NRWLDev/fastapi-problem/issues/38)] [[8bba952](https://github.com/NRWLDev/fastapi-problem/commit/8bba9521ba3c6adb652212b468a3c9ff59f5e2e1)] 41 | 42 | ## v0.11.0 - 2025-04-26 43 | 44 | ### Features and Improvements 45 | 46 | - **Breaking** Migrate http_exception_handler and request_validation_handler definition to first class parameters. [[bff1fa1](https://github.com/NRWLDev/fastapi-problem/commit/bff1fa18ff19a4742ef4c7b732dfaeca720701ce)] 47 | 48 | ## v0.10.10 - 2025-04-25 49 | 50 | ### Bug fixes 51 | 52 | - Support user defined http and request validation exception handlers. [[28dcb79](https://github.com/NRWLDev/fastapi-problem/commit/28dcb7968d3efc2717d546c94e5a6a88c161dcc1)] 53 | 54 | ## v0.10.9 - 2025-04-25 55 | 56 | ### Bug fixes 57 | 58 | - Update underlying starlette problem library with support for user defined HTTPException handler. [[7a9898e](https://github.com/NRWLDev/fastapi-problem/commit/7a9898e12b8da49f74124627c7d34b70f821fbf5)] 59 | 60 | ## v0.10.8 - 2025-04-25 61 | 62 | ### Bug fixes 63 | 64 | - Update content type to correctly match rfc9457 spec in swagger docs [[31](https://github.com/NRWLDev/fastapi-problem/issues/31)] [[97d9472](https://github.com/NRWLDev/fastapi-problem/commit/97d94726f3ac567c4b50f84e7943323911a1d694)] 65 | 66 | ## v0.10.7 - 2025-03-28 67 | 68 | ### Bug fixes 69 | 70 | - Prevent request validation errors from appearing as a duplicate of internal validation errors in swagger docs. [[33](https://github.com/NRWLDev/fastapi-problem/issues/33)] [[9082dbe](https://github.com/NRWLDev/fastapi-problem/commit/9082dbefbec6777be62ef2e87b8ca442e5a67a42)] 71 | 72 | ## v0.10.6 - 2025-02-17 73 | 74 | ### Features and Improvements 75 | 76 | - Add support for generating specific error responses for swagger. [[31](https://github.com/NRWLDev/fastapi-problem/issues/31)] [[bed7f10](https://github.com/NRWLDev/fastapi-problem/commit/bed7f100f0c1a8afab8220665e1dcb2e9b80866f)] 77 | 78 | ## v0.10.5 - 2025-02-07 79 | 80 | ### Bug fixes 81 | 82 | - Include examples for 4xx and 5xx responses. [[234b84b](https://github.com/NRWLDev/fastapi-problem/commit/234b84b6331892f1ecefaad24357fa9b92cd9825)] 83 | 84 | ## v0.10.4 - 2025-02-07 85 | 86 | ### Features and Improvements 87 | 88 | - Register generic 4xx and 5xx responses for paths in swagger docs. [[cc08e52](https://github.com/NRWLDev/fastapi-problem/commit/cc08e52f28b1b55cd8c2874faee610c5f81fd04e)] 89 | 90 | ## v0.10.3 - 2025-02-07 91 | 92 | ### Features and Improvements 93 | 94 | - Register generic 4xx and 5xx responses for paths in swagger docs. [[de698cc](https://github.com/NRWLDev/fastapi-problem/commit/de698ccc61ef10e3245c5201640ecf7d418fe01f)] 95 | 96 | ## v0.10.2 - 2025-02-07 97 | 98 | ### Features and Improvements 99 | 100 | - Replace default 422 response in swagger with an accurate Problem component. [[28](https://github.com/NRWLDev/fastapi-problem/issues/28)] [[e92514f](https://github.com/NRWLDev/fastapi-problem/commit/e92514f8b253db4b2df3a2d21135c7a75d33155c)] 101 | 102 | ## v0.10.1 - 2024-11-14 103 | 104 | ### Bug fixes 105 | 106 | - Drop support for generate_handler to simplify interface, [[d91a703](https://github.com/NRWLDev/fastapi-problem/commit/d91a703055dbb7b221b72958e4867aae5ba3be7c)] 107 | 108 | ## v0.10.0 - 2024-11-14 109 | 110 | ### Bug fixes 111 | 112 | - **Breaking** Remove deprecated strip_debug flags. [[908a80b](https://github.com/NRWLDev/fastapi-problem/commit/908a80b5fae7123f4a8cc5d353b0dbb8c9e4c368)] 113 | 114 | ## v0.9.6 - 2024-10-01 115 | 116 | ### Bug fixes 117 | 118 | - Stop deprecation warnings when flag is not explicitly set. [[aaa8781](https://github.com/NRWLDev/fastapi-problem/commit/aaa8781441ba685633385c6cc2813bc1fc7da423)] 119 | 120 | ## v0.9.5 - 2024-10-01 121 | 122 | ### Bug fixes 123 | 124 | - Update starlette-problem pin to include deprecation for strip_debug fields. [[26](https://github.com/NRWLDev/fastapi-problem/issues/26)] [[e1c5ae1](https://github.com/NRWLDev/fastapi-problem/commit/e1c5ae1aeeb284246f6cced9610460feaa870be5)] 125 | 126 | ## v0.9.4 - 2024-09-09 127 | 128 | ### Miscellaneous 129 | 130 | - Migrate from poetry to uv for dependency and build management [[477e369](https://github.com/NRWLDev/fastapi-problem/commit/477e3698ce48bbd79575877ac5339ffde5d01087)] 131 | 132 | ## v0.9.3 - 2024-09-03 133 | 134 | ### Miscellaneous 135 | 136 | - Update starlette-problem minimum supported version. [[c4db645](https://github.com/NRWLDev/fastapi-problem/commit/c4db64579358316942a134295a35a2e383473a36)] 137 | - Update changelog-gen and related configuration. [[bc5f88d](https://github.com/NRWLDev/fastapi-problem/commit/bc5f88daf10c98821a9e24178e1ec5a9345ee19b)] 138 | 139 | ## v0.9.2 - 2024-08-29 140 | 141 | ### Miscellaneous 142 | 143 | - Rename documentation_base_uri to documentation_uri_template [[cff46af](https://github.com/NRWLDev/fastapi-problem/commit/cff46afacffc5e44bcb0636f93fedb77352645ec)] 144 | 145 | ## v0.9.1 - 2024-08-29 146 | 147 | ### Bug fixes 148 | 149 | - Update rfc9457 library and document strict mode and new uri support. [[1369d62](https://github.com/NRWLDev/fastapi-problem/commit/1369d6225a119a1f9a7020ea3862f0e64f88c156)] 150 | 151 | ## v0.9.0 - 2024-07-23 152 | 153 | ### Miscellaneous 154 | 155 | - **Breaking:** Update rfc9457 with fix for correct response format per spec. [[8401bc7](https://github.com/NRWLDev/fastapi-problem/commit/8401bc738c7fb61bc57d738777d8b6f5c8290240)] 156 | 157 | ## v0.8.1 - 2024-06-28 158 | 159 | ### Features and Improvements 160 | 161 | - Use underlying starlette-problem library to reduce code duplication. [[9a4165e](https://github.com/NRWLDev/fastapi-problem/commit/9a4165e3ce1e06ae3731b4529643d57c80b2e706)] 162 | 163 | ### Miscellaneous 164 | 165 | ## v0.8.0 - 2024-06-14 166 | 167 | ### Features and Improvements 168 | 169 | - **Breaking:** Drop deprecated features from 0.7 release. [[79f1026](https://github.com/NRWLDev/fastapi-problem/commit/79f1026e4519dd8fc7ff9091060dbf595e42f3a4)] 170 | 171 | ## v0.7.20 - 2024-05-31 172 | 173 | ### Features and Improvements 174 | 175 | - Add support for fully qualified documentation links in type. [[0d7e353](https://github.com/NRWLDev/fastapi-problem/commit/0d7e35326d1269af980d81b28545cb35a5c4cf83)] 176 | 177 | ## v0.7.19 - 2024-05-31 178 | 179 | ### Bug fixes 180 | 181 | - Update rfc9457 and include redirect support. [[f9fe241](https://github.com/NRWLDev/fastapi-problem/commit/f9fe2411aa6d1fea0397794f318503b0cc33c005)] 182 | 183 | ### Documentation 184 | 185 | - Expand documentation to include headers and sentry information. [[555e657](https://github.com/NRWLDev/fastapi-problem/commit/555e6578dc16c5895226bb36839b74cc0e34f537)] 186 | 187 | ## v0.7.18 - 2024-05-28 188 | 189 | ### Features and Improvements 190 | 191 | - Allow handlers to return None to delegate handling of the exception to the next handler in the chain. [[dd3a252](https://github.com/NRWLDev/fastapi-problem/commit/dd3a25255d5d7b4a782f30e4dbb86937778227e6)] 192 | 193 | ## v0.7.17 - 2024-05-25 194 | 195 | ### Bug fixes 196 | 197 | - Add deprecation warnings to deprecated modules. [[#15](https://github.com/NRWLDev/fastapi-problem/issues/15)] [[a23747f](https://github.com/NRWLDev/fastapi-problem/commit/a23747f82fcdbcbf35a12effc977651c0c2be936)] 198 | 199 | ## v0.7.16 - 2024-05-23 200 | 201 | ### Bug fixes 202 | 203 | - Include Problem.headers in the JSONResponse. [[95bce0c](https://github.com/NRWLDev/fastapi-problem/commit/95bce0ca81b71eba6b7dd5dd18776f1ba8169f0f)] 204 | 205 | ## v0.7.15 - 2024-05-23 206 | 207 | ### Bug fixes 208 | 209 | - rfc9457 now supports headers and status_code, no need to reimplement base classes. [[089c65f](https://github.com/NRWLDev/fastapi-problem/commit/089c65fdeacd589a3db5ff4e6a095b3732054a08)] 210 | 211 | ## v0.7.14 - 2024-05-22 212 | 213 | ### Bug fixes 214 | 215 | - Remove HTTPException subclassing, and notify starlette of Problem in exception handlers to have them properly handled in sentry integration. [[b8324fa](https://github.com/NRWLDev/fastapi-problem/commit/b8324faf5c792692ad1971137a6a837b11a14010)] 216 | 217 | ## v0.7.13 - 2024-05-22 218 | 219 | ### Miscellaneous 220 | 221 | - Multiclass inheritance from starlette.HTTPException introduces unexpected side effects in testing and middleware. [[c8b0f3d](https://github.com/NRWLDev/fastapi-problem/commit/c8b0f3d291622fccf630284e57737b006ab2a7dd)] 222 | 223 | ## v0.7.12 - 2024-05-22 224 | 225 | ### Bug fixes 226 | 227 | - Remove __str__ implementation overrides, rely on rfc9457 implementation [[fc90194](https://github.com/NRWLDev/fastapi-problem/commit/fc901947bea38386240afea09731754bd1002191)] 228 | 229 | ## v0.7.11 - 2024-05-21 230 | 231 | ### Bug fixes 232 | 233 | - Pin rfc9457 [[50bb647](https://github.com/NRWLDev/fastapi-problem/commit/50bb647e6130abe8e118a9f4156d5537ae8ccdcc)] 234 | 235 | ## v0.7.10 - 2024-05-21 236 | 237 | ### Bug fixes 238 | 239 | - Make logger optional with no base logger, allow disabling logging on exceptions. [[9da8d50](https://github.com/NRWLDev/fastapi-problem/commit/9da8d50a51cc3c6d0e184ec4e374bef67936c808)] 240 | 241 | ## v0.7.9 - 2024-05-21 242 | 243 | ### Bug fixes 244 | 245 | - Subclass rfc9457 Problems with HTTPException to support sentry_sdk starlette integrations. [[#11](https://github.com/NRWLDev/fastapi-problem/issues/11)] [[8b43192](https://github.com/NRWLDev/fastapi-problem/commit/8b43192b8336b9d94164f0d1cfad9d396a6af08c)] 246 | - Add in py.typed file so mypy/pyright acknowledge type hints. [[#12](https://github.com/NRWLDev/fastapi-problem/issues/12)] [[010f6da](https://github.com/NRWLDev/fastapi-problem/commit/010f6da1b718d9397187fd8a71a225f8f155ad72)] 247 | 248 | ## v0.7.7 - 2024-05-16 249 | 250 | ### Features and Improvements 251 | 252 | - Use rfc9457 library for base Problem implementation. [[a9cf622](https://github.com/NRWLDev/fastapi-problem/commit/a9cf62209155da132a98dd8a88ac59f5a14b1028)] 253 | 254 | ## v0.7.6 - 2024-05-15 255 | 256 | ### Miscellaneous 257 | 258 | - Deprecate code in favour of type_ on base class implementations. [[fa828a2](https://github.com/NRWLDev/fastapi-problem/commit/fa828a2cf42b826018bb09769ff587bca92d146a)] 259 | 260 | ## v0.7.5 - 2024-05-13 261 | 262 | ### Bug fixes 263 | 264 | - Fix issue where custom handlers were ignored. [[34898e7](https://github.com/NRWLDev/fastapi-problem/commit/34898e79f436847145428114bd0ca7aebde83bab)] 265 | 266 | ## v0.7.4 - 2024-05-13 267 | 268 | ### Bug fixes 269 | 270 | - Fix typo in HttpException init. [[1c7fcf9](https://github.com/NRWLDev/fastapi-problem/commit/1c7fcf970cce7e208e0f74102e88a1574d2db2ad)] 271 | 272 | ## v0.7.3 - 2024-05-13 273 | 274 | ### Bug fixes 275 | 276 | - Add missed ForbiddenProblem 403 convenience class. [[1c756ee](https://github.com/NRWLDev/fastapi-problem/commit/1c756eec1c9203e219eed29a10f7359c5dbcbc35)] 277 | 278 | ## v0.7.2 - 2024-05-12 279 | 280 | ### Features and Improvements 281 | 282 | - Add support for pre/post hooks. [[#1](https://github.com/NRWLDev/fastapi-problem/issues/1)] [[a02b7cf](https://github.com/NRWLDev/fastapi-problem/commit/a02b7cf70b77feb7d300a979d27fcb9a6a0288d8)] 283 | - Support custom exception handler functions [[#2](https://github.com/NRWLDev/fastapi-problem/issues/2)] [[95e56d1](https://github.com/NRWLDev/fastapi-problem/commit/95e56d1ca78bf11aa95c29970ba155e8e418be18)] 284 | - Implement base error class Problem [[#3](https://github.com/NRWLDev/fastapi-problem/issues/3)] [[e35bfcf](https://github.com/NRWLDev/fastapi-problem/commit/e35bfcffccdf9b9564b4ec3dad6059c01e5680e5)] 285 | 286 | ## v0.7.1 - 2024-05-01 287 | 288 | ### Bug fixes 289 | 290 | - Remove unused legacy warning [[f4f51f0](https://github.com/NRWLDev/fastapi-problem/commit/f4f51f087e1ed1de30d7dfbcd2a3f80181883044)] 291 | 292 | ## v0.7.0 - 2024-05-01 293 | 294 | ### Features and Improvements 295 | 296 | - **Breaking:** Drop support for legacy modes from web_error [[89bff61](https://github.com/NRWLDev/fastapi-problem/commit/89bff61eddcb6d068c7e8a7a8cf4a231cb4bd7dc)] 297 | 298 | ## v0.6.10 - 2024-04-11 299 | 300 | ### Features and Improvements 301 | 302 | - Support stripping debug by status code, rather than flag. [#1](https://github.com/EdgyEdgemond/web-error/issues/1) [4d76e1e](https://github.com/EdgyEdgemond/web-error/commit/4d76e1eb65efa004d62812e64d40fcc8a224405a) 303 | 304 | ## v0.6.9 - 2024-03-12 305 | 306 | ### Bug fixes 307 | 308 | - Fix incorrect string method used in type generation [01f2c1b](https://github.com/EdgyEdgemond/web-error/commit/01f2c1b26ee296ef723d4c852dbe162e0218174f) 309 | 310 | ## v0.6.8 - 2024-03-12 311 | 312 | ### Bug fixes 313 | 314 | - Only replace last instance of Error in class name [eff6b14](https://github.com/EdgyEdgemond/web-error/commit/eff6b149f1d72a58fa4ec0340f0a9511a88d85e1) 315 | 316 | ## v0.6.7 - 2024-03-11 317 | 318 | ### Bug fixes 319 | 320 | - Cleanup legacy warning detection [[9c54796](https://github.com/EdgyEdgemond/web-error/commit/9c54796458082a1a9f7b265c33348335f65f5e44)] 321 | 322 | ## v0.6.6 - 2024-03-08 323 | 324 | ### Bug fixes 325 | 326 | - Clean up message deprecation detection [[c2a0e3f](https://github.com/EdgyEdgemond/web-error/commit/c2a0e3f552fad60e0ea73b449e269c89c3c2f43c)] 327 | 328 | ## v0.6.5 - 2024-03-08 329 | 330 | ### Bug fixes 331 | 332 | - Expose legacy attributes, accessing new attributes. [[30c3682](https://github.com/EdgyEdgemond/web-error/commit/30c3682ea9526b6c2a4b180cd928becd69396961)] 333 | 334 | ## v0.6.4 - 2024-03-08 335 | 336 | ### Bug fixes 337 | 338 | - Handle legacy exception definitions with message attribute [[c07215c](https://github.com/EdgyEdgemond/web-error/commit/c07215cc4ec9e10953abded8311a93717704a324)] 339 | 340 | ## v0.6.3 - 2024-03-08 341 | 342 | ### Bug fixes 343 | 344 | - Support legacy init [[e83a8f8](https://github.com/EdgyEdgemond/web-error/commit/e83a8f8b29b11694414b20e2e2ac1856b61dbb0c)] 345 | 346 | ## v0.6.2 - 2024-03-04 347 | 348 | ### Features and Improvements 349 | 350 | - Attach exception handlers to an active app, rather than providing to `__init__` [[#23](https://github.com/EdgyEdgemond/web-error/issues/23)] [[3d61d82](https://github.com/EdgyEdgemond/web-error/commit/3d61d82be86e12ee477cb5737e8085ff8982167f)] 351 | 352 | ## v0.6.1 - 2024-02-29 353 | 354 | ### Bug fixes 355 | 356 | - Derive title/type from unexpected error status_codes. [[#19](https://github.com/EdgyEdgemond/web-error/issues/19)] [[a62d99a](https://github.com/EdgyEdgemond/web-error/commit/a62d99a64b02d79a0e54ceaab7c3d7bc689b56e1)] 357 | 358 | ## v0.6.0 359 | 360 | ### Features and Improvements 361 | 362 | - **Breaking:** Drop support for aiohttp, flask and pyramid. Refactor fastapi/starlette interface. [[#16](https://github.com/EdgyEdgemond/web-error/issues/16)] 363 | - Default to RFC9457 response formats, optional legacy generator kwarg can be provided to maintain old response formats. [[#15](https://github.com/EdgyEdgemond/web-error/issues/15)] 364 | 365 | ## v0.5.6 366 | 367 | ## v0.5.5 368 | 369 | ## v0.5.4 370 | 371 | ## v0.5.3 372 | 373 | ## v0.5.2 374 | 375 | ## v0.5.1 376 | 377 | ## v0.5.0 378 | 379 | ### Features and Improvements 380 | 381 | - Introduce ruff, black, pre-commit. Drop support for py 3.7 and earlier. [[#13](https://github.com/EdgyEdgemond/web-error/issues/13)] 382 | 383 | ## v0.4.2 384 | 385 | ### Bug fixes 386 | 387 | - Handle starlette core exceptions [[#11](https://github.com/EdgyEdgemond/web-error/issues/11)] 388 | 389 | ## v0.4.1 390 | 391 | ## v0.4.0 392 | 393 | ### Features and Improvements 394 | 395 | - Migrate fastapi handler to starlette handler, extend starlette handler to support fastapi. [[#9](https://github.com/EdgyEdgemond/web-error/issues/9)] 396 | 397 | ## v0.3.1 398 | 399 | ### Bug fixes 400 | 401 | - Generate fastapi handler with cors support. [[#7](https://github.com/EdgyEdgemond/web-error/issues/7)] 402 | 403 | ## v0.3.0 404 | 405 | ### Features and Improvements 406 | 407 | - Add support for reraising from an error response from another api. [[#5](https://github.com/EdgyEdgemond/web-error/issues/5)] 408 | 409 | ## v0.2.2 410 | 411 | ### Bug fixes 412 | 413 | - Support RequestValidationError exceptions in fastapi handler. [[#4](https://github.com/EdgyEdgemond/web-error/issues/4)] 414 | 415 | ## v0.2.1 416 | 417 | ### Bug fixes 418 | 419 | - Bugfix for fastapi exception logging [[#2](https://github.com/EdgyEdgemond/web-error/issues/2)] 420 | 421 | ## v0.2.0 422 | 423 | ### Features and Improvements 424 | 425 | - Add handler to support fastapi [[#2](https://github.com/EdgyEdgemond/web-error/issues/2)] 426 | 427 | ## v0.1.1 428 | 429 | ## v0.1.0 430 | 431 | ### Features and Improvements 432 | 433 | - Add in pyramid exception handlers [[#1](https://github.com/EdgyEdgemond/web-error/issues/1)] 434 | 435 | ## v0.0.2 436 | 437 | ### Bug fixes 438 | 439 | - Initial code release 440 | -------------------------------------------------------------------------------- /tests/test_handler.py: -------------------------------------------------------------------------------- 1 | import http 2 | import json 3 | from unittest import mock 4 | 5 | import httpx 6 | import pytest 7 | from fastapi import Depends, FastAPI 8 | from fastapi.exceptions import RequestValidationError 9 | from fastapi.security import HTTPBearer 10 | from starlette.exceptions import HTTPException 11 | 12 | from fastapi_problem import error, handler 13 | from fastapi_problem.cors import CorsConfiguration 14 | 15 | 16 | class SomethingWrongError(error.ServerProblem): 17 | title = "This is an error." 18 | 19 | 20 | class CustomUnhandledException(error.ServerProblem): 21 | title = "Unhandled exception occurred." 22 | 23 | 24 | class CustomValidationError(error.StatusProblem): 25 | status = 422 26 | title = "Request validation error." 27 | 28 | 29 | @pytest.fixture 30 | def cors(): 31 | return CorsConfiguration( 32 | allow_origins=["*"], 33 | allow_methods=["*"], 34 | allow_headers=["*"], 35 | allow_credentials=True, 36 | ) 37 | 38 | 39 | class TestExceptionHandler: 40 | @pytest.mark.parametrize("default_key", ["default", "500"]) 41 | def test_unexpected_error_replaces_code(self, default_key): 42 | logger = mock.Mock() 43 | 44 | request = mock.Mock() 45 | exc = Exception("Something went bad") 46 | 47 | eh = handler.ExceptionHandler( 48 | logger=logger, 49 | unhandled_wrappers={ 50 | default_key: CustomUnhandledException, 51 | }, 52 | ) 53 | response = eh(request, exc) 54 | 55 | assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR 56 | assert json.loads(response.body) == { 57 | "title": "Unhandled exception occurred.", 58 | "detail": "Something went bad", 59 | "type": "custom-unhandled-exception", 60 | "status": 500, 61 | } 62 | assert logger.exception.call_args == mock.call( 63 | "Unhandled exception occurred.", 64 | exc_info=(type(exc), exc, None), 65 | ) 66 | 67 | def test_documentation_uri_template(self): 68 | request = mock.Mock() 69 | exc = Exception("Something went bad") 70 | 71 | eh = handler.ExceptionHandler( 72 | unhandled_wrappers={ 73 | "default": CustomUnhandledException, 74 | }, 75 | documentation_uri_template="https://docs/errors/{type}", 76 | ) 77 | response = eh(request, exc) 78 | 79 | assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR 80 | assert json.loads(response.body) == { 81 | "title": "Unhandled exception occurred.", 82 | "type": "https://docs/errors/custom-unhandled-exception", 83 | "status": 500, 84 | "detail": "Something went bad", 85 | } 86 | 87 | def test_strict(self): 88 | request = mock.Mock() 89 | exc = Exception("Something went bad") 90 | 91 | eh = handler.ExceptionHandler( 92 | unhandled_wrappers={ 93 | "default": CustomUnhandledException, 94 | }, 95 | documentation_uri_template="https://docs/errors/{type}", 96 | strict_rfc9457=True, 97 | ) 98 | response = eh(request, exc) 99 | 100 | assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR 101 | assert json.loads(response.body) == { 102 | "title": "Unhandled exception occurred.", 103 | "type": "about:blank", 104 | "status": 500, 105 | "detail": "Something went bad", 106 | } 107 | 108 | def test_unexpected_error(self): 109 | logger = mock.Mock() 110 | 111 | request = mock.Mock() 112 | exc = Exception("Something went bad") 113 | 114 | eh = handler.ExceptionHandler(logger=logger) 115 | response = eh(request, exc) 116 | 117 | assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR 118 | assert json.loads(response.body) == { 119 | "title": "Unhandled exception occurred.", 120 | "detail": "Something went bad", 121 | "type": "unhandled-exception", 122 | "status": 500, 123 | } 124 | assert logger.exception.call_args == mock.call( 125 | "Unhandled exception occurred.", 126 | exc_info=(type(exc), exc, None), 127 | ) 128 | 129 | def test_error_handler_can_pass(self): 130 | def pass_handler(_eh, _request, _exc): 131 | return None 132 | 133 | def handler_(_eh, _request, exc): 134 | return error.Problem( 135 | title="Handled", 136 | type_="handled-error", 137 | detail=str(exc), 138 | status=500, 139 | headers=None, 140 | ) 141 | 142 | request = mock.Mock() 143 | exc = RuntimeError("Something went bad") 144 | 145 | eh = handler.ExceptionHandler(handlers={RuntimeError: pass_handler, Exception: handler_}) 146 | response = eh(request, exc) 147 | 148 | assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR 149 | assert json.loads(response.body) == { 150 | "title": "Handled", 151 | "detail": "Something went bad", 152 | "type": "handled-error", 153 | "status": 500, 154 | } 155 | 156 | def test_error_handler_breaks_at_first_bite(self): 157 | def handler_(_eh, _request, exc): 158 | return error.Problem( 159 | title="Handled", 160 | type_="handled-error", 161 | detail=str(exc), 162 | status=400, 163 | headers=None, 164 | ) 165 | 166 | def unused_handler(_eh, _request, _exc): 167 | return error.Problem( 168 | title="Handled", 169 | type_="handled-error", 170 | detail=str(exc), 171 | status=500, 172 | headers=None, 173 | ) 174 | 175 | request = mock.Mock() 176 | exc = RuntimeError("Something went bad") 177 | 178 | eh = handler.ExceptionHandler(handlers={RuntimeError: handler_, Exception: unused_handler}) 179 | response = eh(request, exc) 180 | 181 | assert response.status_code == http.HTTPStatus.BAD_REQUEST 182 | assert json.loads(response.body) == { 183 | "title": "Handled", 184 | "detail": "Something went bad", 185 | "type": "handled-error", 186 | "status": 400, 187 | } 188 | 189 | def test_error_handler_pass(self): 190 | logger = mock.Mock() 191 | 192 | request = mock.Mock() 193 | exc = Exception("Something went bad") 194 | 195 | eh = handler.ExceptionHandler(logger=logger) 196 | response = eh(request, exc) 197 | 198 | assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR 199 | assert response.headers["content-type"] == "application/problem+json" 200 | assert json.loads(response.body) == { 201 | "title": "Unhandled exception occurred.", 202 | "detail": "Something went bad", 203 | "type": "unhandled-exception", 204 | "status": 500, 205 | } 206 | assert logger.exception.call_args == mock.call( 207 | "Unhandled exception occurred.", 208 | exc_info=(type(exc), exc, None), 209 | ) 210 | 211 | def test_starlette_error(self): 212 | request = mock.Mock() 213 | exc = HTTPException(404) 214 | 215 | eh = handler.ExceptionHandler(handlers={HTTPException: handler.http_exception_handler_}) 216 | response = eh(request, exc) 217 | 218 | assert response.status_code == http.HTTPStatus.NOT_FOUND 219 | assert json.loads(response.body) == { 220 | "title": "Not Found", 221 | "detail": "Not Found", 222 | "type": "http-not-found", 223 | "status": 404, 224 | } 225 | 226 | def test_starlette_error_custom_wrapper(self): 227 | request = mock.Mock() 228 | exc = HTTPException(404) 229 | 230 | eh = handler.ExceptionHandler( 231 | handlers={HTTPException: handler.http_exception_handler_}, 232 | unhandled_wrappers={ 233 | "404": SomethingWrongError, 234 | }, 235 | ) 236 | response = eh(request, exc) 237 | 238 | assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR 239 | assert json.loads(response.body) == { 240 | "title": "This is an error.", 241 | "detail": "Not Found", 242 | "type": "something-wrong", 243 | "status": 500, 244 | } 245 | 246 | def test_known_error(self): 247 | request = mock.Mock() 248 | exc = SomethingWrongError("something bad") 249 | 250 | eh = handler.ExceptionHandler() 251 | response = eh(request, exc) 252 | 253 | assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR 254 | assert json.loads(response.body) == { 255 | "title": "This is an error.", 256 | "detail": "something bad", 257 | "type": "something-wrong", 258 | "status": 500, 259 | } 260 | 261 | def test_error_with_no_origin(self, cors): 262 | app = FastAPI() 263 | request = mock.Mock(headers={}) 264 | exc = SomethingWrongError("something bad") 265 | 266 | eh = handler.add_exception_handler( 267 | app=app, 268 | cors=cors, 269 | ) 270 | response = eh(request, exc) 271 | 272 | assert "access-control-allow-origin" not in response.headers 273 | 274 | def test_error_with_origin(self, cors): 275 | app = FastAPI() 276 | request = mock.Mock(headers={"origin": "localhost"}) 277 | exc = SomethingWrongError("something bad") 278 | 279 | eh = handler.add_exception_handler( 280 | app=app, 281 | cors=cors, 282 | ) 283 | response = eh(request, exc) 284 | 285 | assert "access-control-allow-origin" in response.headers 286 | assert response.headers["access-control-allow-origin"] == "*" 287 | 288 | def test_error_with_origin_and_cookie(self, cors): 289 | app = FastAPI() 290 | request = mock.Mock(headers={"origin": "localhost", "cookie": "something"}) 291 | exc = SomethingWrongError("something bad") 292 | 293 | eh = handler.add_exception_handler( 294 | app=app, 295 | cors=cors, 296 | ) 297 | response = eh(request, exc) 298 | 299 | assert "access-control-allow-origin" in response.headers 300 | assert response.headers["access-control-allow-origin"] == "localhost" 301 | 302 | def test_missing_token_with_origin_limited_origins(self, cors): 303 | app = FastAPI() 304 | request = mock.Mock(headers={"origin": "localhost", "cookie": "something"}) 305 | exc = SomethingWrongError("something bad") 306 | 307 | cors.allow_origins = ["localhost"] 308 | 309 | eh = handler.add_exception_handler( 310 | app=app, 311 | cors=cors, 312 | ) 313 | response = eh(request, exc) 314 | 315 | assert "access-control-allow-origin" in response.headers 316 | assert response.headers["vary"] == "Origin" 317 | assert response.headers["access-control-allow-origin"] == "localhost" 318 | 319 | def test_missing_token_with_origin_limited_origins_no_match(self, cors): 320 | app = FastAPI() 321 | request = mock.Mock(headers={"origin": "localhost2", "cookie": "something"}) 322 | exc = SomethingWrongError("something bad") 323 | 324 | cors.allow_origins = ["localhost"] 325 | 326 | eh = handler.add_exception_handler( 327 | app=app, 328 | cors=cors, 329 | ) 330 | response = eh(request, exc) 331 | 332 | assert "access-control-allow-origin" not in response.headers 333 | 334 | def test_fastapi_error(self): 335 | app = FastAPI() 336 | request = mock.Mock() 337 | exc = RequestValidationError([]) 338 | 339 | eh = handler.add_exception_handler( 340 | app=app, 341 | ) 342 | response = eh(request, exc) 343 | 344 | assert response.status_code == http.HTTPStatus.UNPROCESSABLE_ENTITY 345 | assert json.loads(response.body) == { 346 | "title": "Request validation error.", 347 | "errors": [], 348 | "type": "request-validation-failed", 349 | "status": 422, 350 | } 351 | 352 | def test_fastapi_error_custom_wrapper(self): 353 | app = FastAPI() 354 | request = mock.Mock() 355 | exc = RequestValidationError([]) 356 | 357 | eh = handler.add_exception_handler( 358 | app=app, 359 | unhandled_wrappers={ 360 | "422": CustomValidationError, 361 | }, 362 | ) 363 | response = eh(request, exc) 364 | 365 | assert response.status_code == http.HTTPStatus.UNPROCESSABLE_ENTITY 366 | assert json.loads(response.body) == { 367 | "title": "Request validation error.", 368 | "errors": [], 369 | "type": "custom-validation", 370 | "status": 422, 371 | } 372 | 373 | def test_pre_hook(self): 374 | app = FastAPI() 375 | logger = mock.Mock() 376 | 377 | request = mock.Mock() 378 | exc = ValueError("Something went bad") 379 | 380 | def hook(_request, exc) -> None: 381 | logger.debug(str(type(exc))) 382 | 383 | eh = handler.add_exception_handler( 384 | app=app, 385 | logger=logger, 386 | pre_hooks=[hook], 387 | ) 388 | eh(request, exc) 389 | 390 | assert logger.debug.call_args == mock.call("") 391 | 392 | 393 | async def test_exception_handler_in_app(): 394 | m = mock.Mock() 395 | 396 | def pre_hook(_req, _exc): 397 | m.call("pre-hook") 398 | 399 | app = FastAPI() 400 | eh = handler.new_exception_handler( 401 | pre_hooks=[pre_hook], 402 | unhandled_wrappers={ 403 | "422": CustomValidationError, 404 | "default": CustomUnhandledException, 405 | }, 406 | ) 407 | handler.add_exception_handler( 408 | app=app, 409 | eh=eh, 410 | ) 411 | 412 | transport = httpx.ASGITransport(app=app, raise_app_exceptions=False, client=("1.2.3.4", 123)) 413 | client = httpx.AsyncClient(transport=transport, base_url="https://test") 414 | 415 | r = await client.get("/endpoint") 416 | assert r.json() == { 417 | "type": "http-not-found", 418 | "title": "Not Found", 419 | "detail": "Not Found", 420 | "status": 404, 421 | } 422 | assert m.call.call_args == mock.call("pre-hook") 423 | 424 | 425 | async def test_exception_handler_in_app_post_register(): 426 | app = FastAPI() 427 | 428 | handler.add_exception_handler( 429 | app, 430 | unhandled_wrappers={ 431 | "422": CustomValidationError, 432 | "default": CustomUnhandledException, 433 | }, 434 | ) 435 | 436 | transport = httpx.ASGITransport(app=app, raise_app_exceptions=False, client=("1.2.3.4", 123)) 437 | client = httpx.AsyncClient(transport=transport, base_url="https://test") 438 | 439 | r = await client.get("/endpoint") 440 | assert r.json() == { 441 | "type": "http-not-found", 442 | "title": "Not Found", 443 | "detail": "Not Found", 444 | "status": 404, 445 | } 446 | 447 | 448 | def test_generate_swagger_response_status_problem_deprecated(): 449 | assert handler.generate_swagger_response(error.BadRequestProblem) == { 450 | "content": { 451 | "application/problem+json": { 452 | "schema": { 453 | "$ref": "#/components/schemas/Problem", 454 | }, 455 | "example": { 456 | "title": "Base http exception.", 457 | "detail": "Additional error context.", 458 | "type": "bad-request-problem", 459 | "status": 400, 460 | }, 461 | }, 462 | }, 463 | "description": "Bad Request", 464 | } 465 | 466 | 467 | def test_generate_swagger_response_instantiated_problem(): 468 | eh = handler.new_exception_handler() 469 | assert eh.generate_swagger_response(error.BadRequestProblem("Custom detail", additional="key")) == { 470 | "content": { 471 | "application/problem+json": { 472 | "schema": { 473 | "$ref": "#/components/schemas/Problem", 474 | }, 475 | "example": { 476 | "title": "Base http exception.", 477 | "detail": "Custom detail", 478 | "type": "bad-request-problem", 479 | "status": 400, 480 | "additional": "key", 481 | }, 482 | }, 483 | }, 484 | "description": "Bad Request", 485 | } 486 | 487 | 488 | def test_generate_swagger_response_status_problem(): 489 | eh = handler.new_exception_handler() 490 | assert eh.generate_swagger_response(error.BadRequestProblem) == { 491 | "content": { 492 | "application/problem+json": { 493 | "schema": { 494 | "$ref": "#/components/schemas/Problem", 495 | }, 496 | "example": { 497 | "title": "Base http exception.", 498 | "detail": "Additional error context.", 499 | "type": "bad-request-problem", 500 | "status": 400, 501 | }, 502 | }, 503 | }, 504 | "description": "Bad Request", 505 | } 506 | 507 | 508 | def test_generate_swagger_response_custom_problem(): 509 | eh = handler.new_exception_handler() 510 | assert eh.generate_swagger_response(CustomUnhandledException) == { 511 | "content": { 512 | "application/problem+json": { 513 | "schema": { 514 | "$ref": "#/components/schemas/Problem", 515 | }, 516 | "example": { 517 | "title": "Unhandled exception occurred.", 518 | "detail": "Additional error context.", 519 | "type": "custom-unhandled-exception", 520 | "status": 500, 521 | }, 522 | }, 523 | }, 524 | "description": "Internal Server Error", 525 | } 526 | 527 | 528 | def test_generate_swagger_response_multiple_problems(): 529 | eh = handler.new_exception_handler() 530 | assert eh.generate_swagger_response(CustomUnhandledException, error.ServerProblem) == { 531 | "content": { 532 | "application/problem+json": { 533 | "schema": { 534 | "$ref": "#/components/schemas/Problem", 535 | }, 536 | "examples": { 537 | "Base http exception.": { 538 | "value": { 539 | "title": "Base http exception.", 540 | "detail": "Additional error context.", 541 | "type": "server-problem", 542 | "status": 500, 543 | }, 544 | }, 545 | "Unhandled exception occurred.": { 546 | "value": { 547 | "title": "Unhandled exception occurred.", 548 | "detail": "Additional error context.", 549 | "type": "custom-unhandled-exception", 550 | "status": 500, 551 | }, 552 | }, 553 | }, 554 | }, 555 | }, 556 | "description": "Internal Server Error", 557 | } 558 | 559 | 560 | def test_generate_swagger_response_status_problem_with_uri_template(): 561 | eh = handler.new_exception_handler( 562 | documentation_uri_template="https://docs/errors/{type}", 563 | ) 564 | assert eh.generate_swagger_response(error.BadRequestProblem) == { 565 | "content": { 566 | "application/problem+json": { 567 | "schema": { 568 | "$ref": "#/components/schemas/Problem", 569 | }, 570 | "example": { 571 | "title": "Base http exception.", 572 | "detail": "Additional error context.", 573 | "type": "https://docs/errors/bad-request-problem", 574 | "status": 400, 575 | }, 576 | }, 577 | }, 578 | "description": "Bad Request", 579 | } 580 | 581 | 582 | def test_generate_swagger_response_status_problem_strict(): 583 | eh = handler.new_exception_handler( 584 | documentation_uri_template="https://docs/errors/{type}", 585 | strict_rfc9457=True, 586 | ) 587 | assert eh.generate_swagger_response(error.BadRequestProblem) == { 588 | "content": { 589 | "application/problem+json": { 590 | "schema": { 591 | "$ref": "#/components/schemas/Problem", 592 | }, 593 | "example": { 594 | "title": "Base http exception.", 595 | "detail": "Additional error context.", 596 | "type": "about:blank", 597 | "status": 400, 598 | }, 599 | }, 600 | }, 601 | "description": "Bad Request", 602 | } 603 | 604 | 605 | async def test_customise_openapi(): 606 | app = FastAPI() 607 | 608 | app.openapi = handler.customise_openapi(app.openapi) 609 | 610 | @app.get("/status") 611 | async def status(_a: str) -> dict: 612 | return {} 613 | 614 | app.openapi() 615 | res = app.openapi() # ensure openapi can be called repeatedly 616 | assert res["components"]["schemas"]["HTTPValidationError"] == { 617 | "properties": { 618 | "title": { 619 | "type": "string", 620 | "title": "Problem title", 621 | }, 622 | "type": { 623 | "type": "string", 624 | "title": "Problem type", 625 | }, 626 | "status": { 627 | "type": "integer", 628 | "title": "Status code", 629 | }, 630 | "errors": { 631 | "type": "array", 632 | "items": { 633 | "$ref": "#/components/schemas/ValidationError", 634 | }, 635 | }, 636 | }, 637 | "type": "object", 638 | "required": [ 639 | "type", 640 | "title", 641 | "status", 642 | "errors", 643 | ], 644 | "title": "RequestValidationError", 645 | } 646 | assert "Problem" in res["components"]["schemas"] 647 | assert "ValidationError" in res["components"]["schemas"] 648 | 649 | assert res["paths"]["/status"]["get"]["responses"] == { 650 | "200": { 651 | "content": { 652 | "application/json": { 653 | "schema": { 654 | "title": "Response Status Status Get", 655 | "type": "object", 656 | }, 657 | }, 658 | }, 659 | "description": "Successful Response", 660 | }, 661 | "422": { 662 | "content": { 663 | "application/problem+json": { 664 | "schema": { 665 | "$ref": "#/components/schemas/HTTPValidationError", 666 | }, 667 | }, 668 | }, 669 | "description": "Validation Error", 670 | }, 671 | "4XX": { 672 | "content": { 673 | "application/problem+json": { 674 | "schema": { 675 | "$ref": "#/components/schemas/Problem", 676 | }, 677 | "example": { 678 | "title": "User facing error message.", 679 | "detail": "Additional error context.", 680 | "type": "client-error-type", 681 | "status": 400, 682 | }, 683 | }, 684 | }, 685 | "description": "Client Error", 686 | }, 687 | "5XX": { 688 | "content": { 689 | "application/problem+json": { 690 | "schema": { 691 | "$ref": "#/components/schemas/Problem", 692 | }, 693 | "example": { 694 | "title": "User facing error message.", 695 | "detail": "Additional error context.", 696 | "type": "server-error-type", 697 | "status": 500, 698 | }, 699 | }, 700 | }, 701 | "description": "Server Error", 702 | }, 703 | } 704 | 705 | 706 | async def test_customise_openapi_handles_no_components_no_paths(): 707 | app = FastAPI() 708 | 709 | app.openapi = handler.customise_openapi(app.openapi) 710 | 711 | res = app.openapi() 712 | assert res["paths"] == {} 713 | assert "components" not in res 714 | 715 | 716 | async def test_customise_openapi_handles_no_components_no_422(): 717 | app = FastAPI() 718 | 719 | @app.get("/status") 720 | async def status() -> dict: 721 | return {} 722 | 723 | app.openapi = handler.customise_openapi(app.openapi) 724 | 725 | res = app.openapi() 726 | 727 | assert res["components"]["schemas"]["HTTPValidationError"] == { 728 | "properties": { 729 | "title": { 730 | "type": "string", 731 | "title": "Problem title", 732 | }, 733 | "type": { 734 | "type": "string", 735 | "title": "Problem type", 736 | }, 737 | "status": { 738 | "type": "integer", 739 | "title": "Status code", 740 | }, 741 | "errors": { 742 | "type": "array", 743 | "items": { 744 | "$ref": "#/components/schemas/ValidationError", 745 | }, 746 | }, 747 | }, 748 | "type": "object", 749 | "required": [ 750 | "type", 751 | "title", 752 | "status", 753 | "errors", 754 | ], 755 | "title": "RequestValidationError", 756 | } 757 | assert "Problem" in res["components"]["schemas"] 758 | 759 | assert res["paths"]["/status"]["get"]["responses"] == { 760 | "200": { 761 | "content": { 762 | "application/json": { 763 | "schema": { 764 | "title": "Response Status Status Get", 765 | "type": "object", 766 | }, 767 | }, 768 | }, 769 | "description": "Successful Response", 770 | }, 771 | "4XX": { 772 | "content": { 773 | "application/problem+json": { 774 | "schema": { 775 | "$ref": "#/components/schemas/Problem", 776 | }, 777 | "example": { 778 | "title": "User facing error message.", 779 | "detail": "Additional error context.", 780 | "type": "client-error-type", 781 | "status": 400, 782 | }, 783 | }, 784 | }, 785 | "description": "Client Error", 786 | }, 787 | "5XX": { 788 | "content": { 789 | "application/problem+json": { 790 | "schema": { 791 | "$ref": "#/components/schemas/Problem", 792 | }, 793 | "example": { 794 | "title": "User facing error message.", 795 | "detail": "Additional error context.", 796 | "type": "server-error-type", 797 | "status": 500, 798 | }, 799 | }, 800 | }, 801 | "description": "Server Error", 802 | }, 803 | } 804 | 805 | 806 | async def test_customise_openapi_handles_security_components_no_422(): 807 | bearer_scheme = HTTPBearer(bearerFormat="JWT") 808 | app = FastAPI() 809 | 810 | @app.get("/status") 811 | async def status(bearer: str = Depends(bearer_scheme)) -> dict: # noqa: ARG001 812 | return {} 813 | 814 | app.openapi = handler.customise_openapi(app.openapi) 815 | 816 | res = app.openapi() 817 | 818 | assert res["components"]["schemas"]["HTTPValidationError"] == { 819 | "properties": { 820 | "title": { 821 | "type": "string", 822 | "title": "Problem title", 823 | }, 824 | "type": { 825 | "type": "string", 826 | "title": "Problem type", 827 | }, 828 | "status": { 829 | "type": "integer", 830 | "title": "Status code", 831 | }, 832 | "errors": { 833 | "type": "array", 834 | "items": { 835 | "$ref": "#/components/schemas/ValidationError", 836 | }, 837 | }, 838 | }, 839 | "type": "object", 840 | "required": [ 841 | "type", 842 | "title", 843 | "status", 844 | "errors", 845 | ], 846 | "title": "RequestValidationError", 847 | } 848 | assert "Problem" in res["components"]["schemas"] 849 | assert "securitySchemes" in res["components"] 850 | 851 | assert res["paths"]["/status"]["get"]["responses"] == { 852 | "200": { 853 | "content": { 854 | "application/json": { 855 | "schema": { 856 | "title": "Response Status Status Get", 857 | "type": "object", 858 | }, 859 | }, 860 | }, 861 | "description": "Successful Response", 862 | }, 863 | "4XX": { 864 | "content": { 865 | "application/problem+json": { 866 | "schema": { 867 | "$ref": "#/components/schemas/Problem", 868 | }, 869 | "example": { 870 | "title": "User facing error message.", 871 | "detail": "Additional error context.", 872 | "type": "client-error-type", 873 | "status": 400, 874 | }, 875 | }, 876 | }, 877 | "description": "Client Error", 878 | }, 879 | "5XX": { 880 | "content": { 881 | "application/problem+json": { 882 | "schema": { 883 | "$ref": "#/components/schemas/Problem", 884 | }, 885 | "example": { 886 | "title": "User facing error message.", 887 | "detail": "Additional error context.", 888 | "type": "server-error-type", 889 | "status": 500, 890 | }, 891 | }, 892 | }, 893 | "description": "Server Error", 894 | }, 895 | } 896 | 897 | 898 | async def test_customise_openapi_generic_opt_out(): 899 | app = FastAPI() 900 | 901 | app.openapi = handler.customise_openapi(app.openapi, generic_defaults=False) 902 | 903 | @app.get("/status") 904 | async def status(_a: str) -> dict: 905 | return {} 906 | 907 | res = app.openapi() 908 | assert res["components"]["schemas"]["HTTPValidationError"] == { 909 | "properties": { 910 | "title": { 911 | "type": "string", 912 | "title": "Problem title", 913 | }, 914 | "type": { 915 | "type": "string", 916 | "title": "Problem type", 917 | }, 918 | "status": { 919 | "type": "integer", 920 | "title": "Status code", 921 | }, 922 | "errors": { 923 | "type": "array", 924 | "items": { 925 | "$ref": "#/components/schemas/ValidationError", 926 | }, 927 | }, 928 | }, 929 | "type": "object", 930 | "required": [ 931 | "type", 932 | "title", 933 | "status", 934 | "errors", 935 | ], 936 | "title": "RequestValidationError", 937 | } 938 | assert "Problem" in res["components"]["schemas"] 939 | assert "ValidationError" in res["components"]["schemas"] 940 | 941 | assert res["paths"]["/status"]["get"]["responses"] == { 942 | "200": { 943 | "content": { 944 | "application/json": { 945 | "schema": { 946 | "title": "Response Status Status Get", 947 | "type": "object", 948 | }, 949 | }, 950 | }, 951 | "description": "Successful Response", 952 | }, 953 | "422": { 954 | "content": { 955 | "application/problem+json": { 956 | "schema": { 957 | "$ref": "#/components/schemas/HTTPValidationError", 958 | }, 959 | }, 960 | }, 961 | "description": "Validation Error", 962 | }, 963 | } 964 | 965 | 966 | async def test_custom_http_exception_handler_in_app(): 967 | def custom_handler(_eh, _request, _exc) -> error.Problem: 968 | return error.Problem("a problem") 969 | 970 | app = FastAPI() 971 | 972 | handler.add_exception_handler( 973 | app=app, 974 | http_exception_handler=custom_handler, 975 | ) 976 | 977 | transport = httpx.ASGITransport(app=app, raise_app_exceptions=False, client=("1.2.3.4", 123)) 978 | client = httpx.AsyncClient(transport=transport, base_url="https://test") 979 | 980 | r = await client.get("/endpoint") 981 | assert r.json() == { 982 | "type": "problem", 983 | "title": "a problem", 984 | "status": 500, 985 | } 986 | --------------------------------------------------------------------------------