Skip to content

Commit 70084bc

Browse files
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.
1 parent 55bdfba commit 70084bc

File tree

7 files changed

+147
-64
lines changed

7 files changed

+147
-64
lines changed

connexion/apps/flask.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import typing as t
77

88
import flask
9+
import starlette.exceptions
10+
import werkzeug.exceptions
911
from a2wsgi import WSGIMiddleware
1012
from flask import Response as FlaskResponse
1113
from starlette.types import Receive, Scope, Send
@@ -213,7 +215,7 @@ def __init__(
213215
:obj:`security.SECURITY_HANDLERS`
214216
"""
215217
self._middleware_app = FlaskASGIApp(import_name, server_args or {})
216-
self.app = self._middleware_app.app
218+
217219
super().__init__(
218220
import_name,
219221
lifespan=lifespan,
@@ -233,6 +235,15 @@ def __init__(
233235
security_map=security_map,
234236
)
235237

238+
self.app = self._middleware_app.app
239+
self.app.register_error_handler(
240+
werkzeug.exceptions.HTTPException, self._http_exception
241+
)
242+
243+
def _http_exception(self, exc: werkzeug.exceptions.HTTPException):
244+
"""Reraise werkzeug HTTPExceptions as starlette HTTPExceptions"""
245+
raise starlette.exceptions.HTTPException(exc.code, detail=exc.description)
246+
236247
def add_url_rule(
237248
self, rule, endpoint: str = None, view_func: t.Callable = None, **options
238249
):
@@ -247,4 +258,4 @@ def add_error_handler(
247258
[ConnexionRequest, Exception], MaybeAwaitable[ConnexionResponse]
248259
],
249260
) -> None:
250-
self.app.register_error_handler(code_or_exception, function)
261+
self.middleware.add_error_handler(code_or_exception, function)

connexion/http_facts.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,70 @@
55
FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data"]
66

77
METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
8+
9+
HTTP_STATUS_CODES = {
10+
100: "Continue",
11+
101: "Switching Protocols",
12+
102: "Processing",
13+
103: "Early Hints", # see RFC 8297
14+
200: "OK",
15+
201: "Created",
16+
202: "Accepted",
17+
203: "Non Authoritative Information",
18+
204: "No Content",
19+
205: "Reset Content",
20+
206: "Partial Content",
21+
207: "Multi Status",
22+
208: "Already Reported", # see RFC 5842
23+
226: "IM Used", # see RFC 3229
24+
300: "Multiple Choices",
25+
301: "Moved Permanently",
26+
302: "Found",
27+
303: "See Other",
28+
304: "Not Modified",
29+
305: "Use Proxy",
30+
306: "Switch Proxy", # unused
31+
307: "Temporary Redirect",
32+
308: "Permanent Redirect",
33+
400: "Bad Request",
34+
401: "Unauthorized",
35+
402: "Payment Required", # unused
36+
403: "Forbidden",
37+
404: "Not Found",
38+
405: "Method Not Allowed",
39+
406: "Not Acceptable",
40+
407: "Proxy Authentication Required",
41+
408: "Request Timeout",
42+
409: "Conflict",
43+
410: "Gone",
44+
411: "Length Required",
45+
412: "Precondition Failed",
46+
413: "Request Entity Too Large",
47+
414: "Request URI Too Long",
48+
415: "Unsupported Media Type",
49+
416: "Requested Range Not Satisfiable",
50+
417: "Expectation Failed",
51+
418: "I'm a teapot", # see RFC 2324
52+
421: "Misdirected Request", # see RFC 7540
53+
422: "Unprocessable Entity",
54+
423: "Locked",
55+
424: "Failed Dependency",
56+
425: "Too Early", # see RFC 8470
57+
426: "Upgrade Required",
58+
428: "Precondition Required", # see RFC 6585
59+
429: "Too Many Requests",
60+
431: "Request Header Fields Too Large",
61+
449: "Retry With", # proprietary MS extension
62+
451: "Unavailable For Legal Reasons",
63+
500: "Internal Server Error",
64+
501: "Not Implemented",
65+
502: "Bad Gateway",
66+
503: "Service Unavailable",
67+
504: "Gateway Timeout",
68+
505: "HTTP Version Not Supported",
69+
506: "Variant Also Negotiates", # see RFC 2295
70+
507: "Insufficient Storage",
71+
508: "Loop Detected", # see RFC 5842
72+
510: "Not Extended",
73+
511: "Network Authentication Failed",
74+
}

connexion/lifecycle.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,5 +260,6 @@ def __init__(
260260
self.content_type = content_type
261261
self.body = body
262262
self.headers = headers or {}
263-
self.headers.update({"Content-Type": content_type})
263+
if content_type:
264+
self.headers.update({"Content-Type": content_type})
264265
self.is_streamed = is_streamed

connexion/middleware/exceptions.py

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import asyncio
2+
import functools
23
import logging
34
import typing as t
45

5-
import werkzeug.exceptions
66
from starlette.concurrency import run_in_threadpool
77
from starlette.exceptions import HTTPException
88
from starlette.middleware.exceptions import (
@@ -12,6 +12,7 @@
1212
from starlette.responses import Response as StarletteResponse
1313
from starlette.types import ASGIApp, Receive, Scope, Send
1414

15+
from connexion import http_facts
1516
from connexion.exceptions import InternalServerError, ProblemException, problem
1617
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
1718
from connexion.types import MaybeAwaitable
@@ -28,6 +29,7 @@ def connexion_wrapper(
2829
them to the error handler, and translates the returned Connexion responses to
2930
Starlette responses."""
3031

32+
@functools.wraps(handler)
3133
async def wrapper(request: StarletteRequest, exc: Exception) -> StarletteResponse:
3234
request = ConnexionRequest.from_starlette_request(request)
3335

@@ -36,6 +38,9 @@ async def wrapper(request: StarletteRequest, exc: Exception) -> StarletteRespons
3638
else:
3739
response = await run_in_threadpool(handler, request, exc)
3840

41+
while asyncio.iscoroutine(response):
42+
response = await response
43+
3944
return StarletteResponse(
4045
content=response.body,
4146
status_code=response.status_code,
@@ -53,9 +58,6 @@ class ExceptionMiddleware(StarletteExceptionMiddleware):
5358
def __init__(self, next_app: ASGIApp):
5459
super().__init__(next_app)
5560
self.add_exception_handler(ProblemException, self.problem_handler) # type: ignore
56-
self.add_exception_handler(
57-
werkzeug.exceptions.HTTPException, self.flask_error_handler
58-
)
5961
self.add_exception_handler(Exception, self.common_error_handler)
6062

6163
def add_exception_handler(
@@ -81,7 +83,7 @@ def http_exception(
8183
"""Default handler for Starlette HTTPException"""
8284
logger.error("%r", exc)
8385
return problem(
84-
title=exc.detail,
86+
title=http_facts.HTTP_STATUS_CODES.get(exc.status_code),
8587
detail=exc.detail,
8688
status=exc.status_code,
8789
headers=exc.headers,
@@ -95,21 +97,5 @@ def common_error_handler(
9597
logger.error("%r", exc, exc_info=exc)
9698
return InternalServerError().to_problem()
9799

98-
def flask_error_handler(
99-
self, request: StarletteRequest, exc: werkzeug.exceptions.HTTPException
100-
) -> ConnexionResponse:
101-
"""Default handler for Flask / werkzeug HTTPException"""
102-
# If a handler is registered for the received status_code, call it instead.
103-
# This is only done automatically for Starlette HTTPExceptions
104-
if handler := self._status_handlers.get(exc.code):
105-
starlette_exception = HTTPException(exc.code, detail=exc.description)
106-
return handler(request, starlette_exception)
107-
108-
return problem(
109-
title=exc.name,
110-
detail=exc.description,
111-
status=exc.code,
112-
)
113-
114100
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
115101
await super().__call__(scope, receive, send)

docs/exceptions.rst

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,21 @@ Exception Handling
44
Connexion allows you to register custom error handlers to convert Python ``Exceptions`` into HTTP
55
problem responses.
66

7+
You can register error handlers on:
8+
9+
- The exception class to handle
10+
If this exception class is raised somewhere in your application or the middleware stack,
11+
it will be passed to your handler.
12+
- The HTTP status code to handle
13+
Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues
14+
with a request or response. You can intercept these exceptions with specific status codes
15+
if you want to return custom responses.
16+
717
.. tab-set::
818

919
.. tab-item:: AsyncApp
1020
:sync: AsyncApp
1121

12-
You can register error handlers on:
13-
14-
- The exception class to handle
15-
If this exception class is raised somewhere in your application or the middleware stack,
16-
it will be passed to your handler.
17-
- The HTTP status code to handle
18-
Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues
19-
with a request or response. You can intercept these exceptions with specific status codes
20-
if you want to return custom responses.
21-
2222
.. code-block:: python
2323
2424
from connexion import AsyncApp
@@ -40,17 +40,6 @@ problem responses.
4040
.. tab-item:: FlaskApp
4141
:sync: FlaskApp
4242

43-
You can register error handlers on:
44-
45-
- The exception class to handle
46-
If this exception class is raised somewhere in your application or the middleware stack,
47-
it will be passed to your handler.
48-
- The HTTP status code to handle
49-
Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues
50-
with a request or response. The underlying Flask application will raise
51-
``werkzeug.HTTPException`` errors. You can intercept both of these exceptions with
52-
specific status codes if you want to return custom responses.
53-
5443
.. code-block:: python
5544
5645
from connexion import FlaskApp
@@ -69,20 +58,34 @@ problem responses.
6958
.. automethod:: connexion.FlaskApp.add_error_handler
7059
:noindex:
7160

72-
.. tab-item:: ConnexionMiddleware
73-
:sync: ConnexionMiddleware
61+
.. note::
62+
63+
.. warning::
64+
65+
⚠️ **The following is not recommended as it complicates the exception handling logic,**
7466

75-
You can register error handlers on:
67+
You can also register error handlers on the underlying flask application directly.
7668

77-
- The exception class to handle
78-
If this exception class is raised somewhere in your application or the middleware stack,
79-
it will be passed to your handler.
80-
- The HTTP status code to handle
81-
Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues
82-
with a request or response. You can intercept these exceptions with specific status codes
83-
if you want to return custom responses.
84-
Note that this might not catch ``HTTPExceptions`` with the same status code raised by
85-
your wrapped ASGI/WSGI framework.
69+
.. code-block:: python
70+
71+
flask_app = app.app
72+
flask_app.register_error_handler(FileNotFoundError, not_found)
73+
flask_app.register_error_handler(404, not_found)
74+
75+
`Flask documentation`_
76+
77+
Error handlers registered this way:
78+
79+
- Will only intercept exceptions thrown in the application, not in the Connexion
80+
middleware.
81+
- Can intercept exceptions before they reach the error handlers registered on the
82+
connexion app.
83+
- When registered on status code, will intercept only
84+
``werkzeug.exceptions.HTTPException`` thrown by werkzeug / Flask not
85+
``starlette.exceptions.HTTPException``.
86+
87+
.. tab-item:: ConnexionMiddleware
88+
:sync: ConnexionMiddleware
8689

8790
.. code-block:: python
8891
@@ -105,10 +108,17 @@ problem responses.
105108
.. automethod:: connexion.ConnexionMiddleware.add_error_handler
106109
:noindex:
107110

111+
.. note::
112+
113+
This might not catch ``HTTPExceptions`` with the same status code raised by
114+
your wrapped ASGI/WSGI framework.
115+
108116
.. note::
109117

110118
Error handlers can be ``async`` coroutines as well.
111119

120+
.. _Flask documentation: https://flask.palletsprojects.com/en/latest/errorhandling/#error-handlers
121+
112122
Default Exception Handling
113123
--------------------------
114124
By default connexion exceptions are JSON serialized according to

docs/v3.rst

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

158159

159160
Non-breaking changes

tests/api/test_bootstrap.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from unittest import mock
23

34
import jinja2
@@ -7,6 +8,7 @@
78
from connexion.exceptions import InvalidSpecification
89
from connexion.http_facts import METHODS
910
from connexion.json_schema import ExtendedSafeLoader
11+
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
1012
from connexion.middleware.abstract import AbstractRoutingAPI
1113
from connexion.options import SwaggerUIOptions
1214

@@ -302,10 +304,15 @@ def test_add_error_handler(app_class, simple_api_spec_dir):
302304
app = app_class(__name__, specification_dir=simple_api_spec_dir)
303305
app.add_api("openapi.yaml")
304306

305-
def custom_error_handler(_request, _exception):
306-
pass
307+
def not_found(request: ConnexionRequest, exc: Exception) -> ConnexionResponse:
308+
return ConnexionResponse(
309+
status_code=404, body=json.dumps({"error": "NotFound"})
310+
)
307311

308-
app.add_error_handler(Exception, custom_error_handler)
309-
app.add_error_handler(500, custom_error_handler)
312+
app.add_error_handler(404, not_found)
310313

311-
app.middleware._build_middleware_stack()
314+
app_client = app.test_client()
315+
316+
response = app_client.get("/does_not_exist")
317+
assert response.status_code == 404
318+
assert response.json()["error"] == "NotFound"

0 commit comments

Comments
 (0)