Fix FlaskApp exception handlers (#1788)

Fixes #1787 

In the `FlaskApp`, the error handlers were still registered on the
underlying Flask app instead of on the `ExceptionMiddleware`, which led
to them not following the documented behavior.

The documentation was also incorrect about ignoring error handlers
registered on the flask application. We are only ignoring the default
error handlers registered by Flask itself.

This is a breaking change, however, since this functionality was not
following the documented behavior, and 3.0.0 was only recently released,
I propose to release this as a patch version.
This commit is contained in:
Robbe Sneyders
2023-11-06 19:00:46 +01:00
committed by GitHub
parent 55bdfbaa70
commit 70084bcc4c
7 changed files with 147 additions and 64 deletions

View File

@@ -6,6 +6,8 @@ import pathlib
import typing as t import typing as t
import flask import flask
import starlette.exceptions
import werkzeug.exceptions
from a2wsgi import WSGIMiddleware from a2wsgi import WSGIMiddleware
from flask import Response as FlaskResponse from flask import Response as FlaskResponse
from starlette.types import Receive, Scope, Send from starlette.types import Receive, Scope, Send
@@ -213,7 +215,7 @@ class FlaskApp(AbstractApp):
:obj:`security.SECURITY_HANDLERS` :obj:`security.SECURITY_HANDLERS`
""" """
self._middleware_app = FlaskASGIApp(import_name, server_args or {}) self._middleware_app = FlaskASGIApp(import_name, server_args or {})
self.app = self._middleware_app.app
super().__init__( super().__init__(
import_name, import_name,
lifespan=lifespan, lifespan=lifespan,
@@ -233,6 +235,15 @@ class FlaskApp(AbstractApp):
security_map=security_map, security_map=security_map,
) )
self.app = self._middleware_app.app
self.app.register_error_handler(
werkzeug.exceptions.HTTPException, self._http_exception
)
def _http_exception(self, exc: werkzeug.exceptions.HTTPException):
"""Reraise werkzeug HTTPExceptions as starlette HTTPExceptions"""
raise starlette.exceptions.HTTPException(exc.code, detail=exc.description)
def add_url_rule( def add_url_rule(
self, rule, endpoint: str = None, view_func: t.Callable = None, **options self, rule, endpoint: str = None, view_func: t.Callable = None, **options
): ):
@@ -247,4 +258,4 @@ class FlaskApp(AbstractApp):
[ConnexionRequest, Exception], MaybeAwaitable[ConnexionResponse] [ConnexionRequest, Exception], MaybeAwaitable[ConnexionResponse]
], ],
) -> None: ) -> None:
self.app.register_error_handler(code_or_exception, function) self.middleware.add_error_handler(code_or_exception, function)

View File

@@ -5,3 +5,70 @@ This module contains definitions of the HTTP protocol.
FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data"] FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data"]
METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
HTTP_STATUS_CODES = {
100: "Continue",
101: "Switching Protocols",
102: "Processing",
103: "Early Hints", # see RFC 8297
200: "OK",
201: "Created",
202: "Accepted",
203: "Non Authoritative Information",
204: "No Content",
205: "Reset Content",
206: "Partial Content",
207: "Multi Status",
208: "Already Reported", # see RFC 5842
226: "IM Used", # see RFC 3229
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
306: "Switch Proxy", # unused
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required", # unused
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Request Entity Too Large",
414: "Request URI Too Long",
415: "Unsupported Media Type",
416: "Requested Range Not Satisfiable",
417: "Expectation Failed",
418: "I'm a teapot", # see RFC 2324
421: "Misdirected Request", # see RFC 7540
422: "Unprocessable Entity",
423: "Locked",
424: "Failed Dependency",
425: "Too Early", # see RFC 8470
426: "Upgrade Required",
428: "Precondition Required", # see RFC 6585
429: "Too Many Requests",
431: "Request Header Fields Too Large",
449: "Retry With", # proprietary MS extension
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates", # see RFC 2295
507: "Insufficient Storage",
508: "Loop Detected", # see RFC 5842
510: "Not Extended",
511: "Network Authentication Failed",
}

View File

@@ -260,5 +260,6 @@ class ConnexionResponse:
self.content_type = content_type self.content_type = content_type
self.body = body self.body = body
self.headers = headers or {} self.headers = headers or {}
if content_type:
self.headers.update({"Content-Type": content_type}) self.headers.update({"Content-Type": content_type})
self.is_streamed = is_streamed self.is_streamed = is_streamed

View File

@@ -1,8 +1,8 @@
import asyncio import asyncio
import functools
import logging import logging
import typing as t import typing as t
import werkzeug.exceptions
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.middleware.exceptions import ( from starlette.middleware.exceptions import (
@@ -12,6 +12,7 @@ from starlette.requests import Request as StarletteRequest
from starlette.responses import Response as StarletteResponse from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
from connexion import http_facts
from connexion.exceptions import InternalServerError, ProblemException, problem from connexion.exceptions import InternalServerError, ProblemException, problem
from connexion.lifecycle import ConnexionRequest, ConnexionResponse from connexion.lifecycle import ConnexionRequest, ConnexionResponse
from connexion.types import MaybeAwaitable from connexion.types import MaybeAwaitable
@@ -28,6 +29,7 @@ def connexion_wrapper(
them to the error handler, and translates the returned Connexion responses to them to the error handler, and translates the returned Connexion responses to
Starlette responses.""" Starlette responses."""
@functools.wraps(handler)
async def wrapper(request: StarletteRequest, exc: Exception) -> StarletteResponse: async def wrapper(request: StarletteRequest, exc: Exception) -> StarletteResponse:
request = ConnexionRequest.from_starlette_request(request) request = ConnexionRequest.from_starlette_request(request)
@@ -36,6 +38,9 @@ def connexion_wrapper(
else: else:
response = await run_in_threadpool(handler, request, exc) response = await run_in_threadpool(handler, request, exc)
while asyncio.iscoroutine(response):
response = await response
return StarletteResponse( return StarletteResponse(
content=response.body, content=response.body,
status_code=response.status_code, status_code=response.status_code,
@@ -53,9 +58,6 @@ class ExceptionMiddleware(StarletteExceptionMiddleware):
def __init__(self, next_app: ASGIApp): def __init__(self, next_app: ASGIApp):
super().__init__(next_app) super().__init__(next_app)
self.add_exception_handler(ProblemException, self.problem_handler) # type: ignore self.add_exception_handler(ProblemException, self.problem_handler) # type: ignore
self.add_exception_handler(
werkzeug.exceptions.HTTPException, self.flask_error_handler
)
self.add_exception_handler(Exception, self.common_error_handler) self.add_exception_handler(Exception, self.common_error_handler)
def add_exception_handler( def add_exception_handler(
@@ -81,7 +83,7 @@ class ExceptionMiddleware(StarletteExceptionMiddleware):
"""Default handler for Starlette HTTPException""" """Default handler for Starlette HTTPException"""
logger.error("%r", exc) logger.error("%r", exc)
return problem( return problem(
title=exc.detail, title=http_facts.HTTP_STATUS_CODES.get(exc.status_code),
detail=exc.detail, detail=exc.detail,
status=exc.status_code, status=exc.status_code,
headers=exc.headers, headers=exc.headers,
@@ -95,21 +97,5 @@ class ExceptionMiddleware(StarletteExceptionMiddleware):
logger.error("%r", exc, exc_info=exc) logger.error("%r", exc, exc_info=exc)
return InternalServerError().to_problem() return InternalServerError().to_problem()
def flask_error_handler(
self, request: StarletteRequest, exc: werkzeug.exceptions.HTTPException
) -> ConnexionResponse:
"""Default handler for Flask / werkzeug HTTPException"""
# If a handler is registered for the received status_code, call it instead.
# This is only done automatically for Starlette HTTPExceptions
if handler := self._status_handlers.get(exc.code):
starlette_exception = HTTPException(exc.code, detail=exc.description)
return handler(request, starlette_exception)
return problem(
title=exc.name,
detail=exc.description,
status=exc.code,
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await super().__call__(scope, receive, send) await super().__call__(scope, receive, send)

View File

@@ -4,11 +4,6 @@ Exception Handling
Connexion allows you to register custom error handlers to convert Python ``Exceptions`` into HTTP Connexion allows you to register custom error handlers to convert Python ``Exceptions`` into HTTP
problem responses. problem responses.
.. tab-set::
.. tab-item:: AsyncApp
:sync: AsyncApp
You can register error handlers on: You can register error handlers on:
- The exception class to handle - The exception class to handle
@@ -19,6 +14,11 @@ problem responses.
with a request or response. You can intercept these exceptions with specific status codes with a request or response. You can intercept these exceptions with specific status codes
if you want to return custom responses. if you want to return custom responses.
.. tab-set::
.. tab-item:: AsyncApp
:sync: AsyncApp
.. code-block:: python .. code-block:: python
from connexion import AsyncApp from connexion import AsyncApp
@@ -40,17 +40,6 @@ problem responses.
.. tab-item:: FlaskApp .. tab-item:: FlaskApp
:sync: FlaskApp :sync: FlaskApp
You can register error handlers on:
- The exception class to handle
If this exception class is raised somewhere in your application or the middleware stack,
it will be passed to your handler.
- The HTTP status code to handle
Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues
with a request or response. The underlying Flask application will raise
``werkzeug.HTTPException`` errors. You can intercept both of these exceptions with
specific status codes if you want to return custom responses.
.. code-block:: python .. code-block:: python
from connexion import FlaskApp from connexion import FlaskApp
@@ -69,21 +58,35 @@ problem responses.
.. automethod:: connexion.FlaskApp.add_error_handler .. automethod:: connexion.FlaskApp.add_error_handler
:noindex: :noindex:
.. note::
.. warning::
⚠️ **The following is not recommended as it complicates the exception handling logic,**
You can also register error handlers on the underlying flask application directly.
.. code-block:: python
flask_app = app.app
flask_app.register_error_handler(FileNotFoundError, not_found)
flask_app.register_error_handler(404, not_found)
`Flask documentation`_
Error handlers registered this way:
- Will only intercept exceptions thrown in the application, not in the Connexion
middleware.
- Can intercept exceptions before they reach the error handlers registered on the
connexion app.
- When registered on status code, will intercept only
``werkzeug.exceptions.HTTPException`` thrown by werkzeug / Flask not
``starlette.exceptions.HTTPException``.
.. tab-item:: ConnexionMiddleware .. tab-item:: ConnexionMiddleware
:sync: ConnexionMiddleware :sync: ConnexionMiddleware
You can register error handlers on:
- The exception class to handle
If this exception class is raised somewhere in your application or the middleware stack,
it will be passed to your handler.
- The HTTP status code to handle
Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues
with a request or response. You can intercept these exceptions with specific status codes
if you want to return custom responses.
Note that this might not catch ``HTTPExceptions`` with the same status code raised by
your wrapped ASGI/WSGI framework.
.. code-block:: python .. code-block:: python
from asgi_framework import App from asgi_framework import App
@@ -105,10 +108,17 @@ problem responses.
.. automethod:: connexion.ConnexionMiddleware.add_error_handler .. automethod:: connexion.ConnexionMiddleware.add_error_handler
:noindex: :noindex:
.. note::
This might not catch ``HTTPExceptions`` with the same status code raised by
your wrapped ASGI/WSGI framework.
.. note:: .. note::
Error handlers can be ``async`` coroutines as well. Error handlers can be ``async`` coroutines as well.
.. _Flask documentation: https://flask.palletsprojects.com/en/latest/errorhandling/#error-handlers
Default Exception Handling Default Exception Handling
-------------------------- --------------------------
By default connexion exceptions are JSON serialized according to By default connexion exceptions are JSON serialized according to

View File

@@ -152,8 +152,9 @@ Smaller breaking changes
has been added to work with Flask's ``MethodView`` specifically. has been added to work with Flask's ``MethodView`` specifically.
* Built-in support for uWSGI has been removed. You can re-add this functionality using a custom middleware. * Built-in support for uWSGI has been removed. You can re-add this functionality using a custom middleware.
* The request body is now passed through for ``GET``, ``HEAD``, ``DELETE``, ``CONNECT`` and ``OPTIONS`` methods as well. * The request body is now passed through for ``GET``, ``HEAD``, ``DELETE``, ``CONNECT`` and ``OPTIONS`` methods as well.
* Error handlers registered on the on the underlying Flask app directly will be ignored. You * The signature of error handlers has changed and default Flask error handlers are now replaced
should register them on the Connexion app directly. with default Connexion error handlers which work the same for ``AsyncApp`` and
``ConnexionMiddleware``.
Non-breaking changes Non-breaking changes

View File

@@ -1,3 +1,4 @@
import json
from unittest import mock from unittest import mock
import jinja2 import jinja2
@@ -7,6 +8,7 @@ from connexion import App
from connexion.exceptions import InvalidSpecification from connexion.exceptions import InvalidSpecification
from connexion.http_facts import METHODS from connexion.http_facts import METHODS
from connexion.json_schema import ExtendedSafeLoader from connexion.json_schema import ExtendedSafeLoader
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
from connexion.middleware.abstract import AbstractRoutingAPI from connexion.middleware.abstract import AbstractRoutingAPI
from connexion.options import SwaggerUIOptions from connexion.options import SwaggerUIOptions
@@ -302,10 +304,15 @@ def test_add_error_handler(app_class, simple_api_spec_dir):
app = app_class(__name__, specification_dir=simple_api_spec_dir) app = app_class(__name__, specification_dir=simple_api_spec_dir)
app.add_api("openapi.yaml") app.add_api("openapi.yaml")
def custom_error_handler(_request, _exception): def not_found(request: ConnexionRequest, exc: Exception) -> ConnexionResponse:
pass return ConnexionResponse(
status_code=404, body=json.dumps({"error": "NotFound"})
)
app.add_error_handler(Exception, custom_error_handler) app.add_error_handler(404, not_found)
app.add_error_handler(500, custom_error_handler)
app.middleware._build_middleware_stack() app_client = app.test_client()
response = app_client.get("/does_not_exist")
assert response.status_code == 404
assert response.json()["error"] == "NotFound"