Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docs/user/recipes/error-handling.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
.. _error_handling_recipe:

Customizing Error Handling
===========================

By default, Falcon handles any raised instance of :class:`~falcon.HTTPError`
(or :class:`~falcon.HTTPStatus`) by rendering it as a JSON response. Any
other unhandled exception results in a generic
``500 Internal Server Error`` response, also rendered as JSON.

This recipe demonstrates two complementary ways to customize this behavior:
using a custom error serializer to change *how* errors are rendered across
the board, and using :meth:`~falcon.App.add_error_handler` to customize
*what happens* when a specific exception type is raised.

(See also: :ref:`errors`.)

Custom Error Serializer
------------------------

By default, Falcon serializes errors as JSON regardless of what media type
the client prefers. We can override this using
:meth:`~falcon.App.set_error_serializer` to negotiate the response format
based on the ``Accept`` header:

.. tab-set::

.. tab-item:: WSGI
:sync: wsgi

.. literalinclude:: ../../../examples/recipes/error_handling_serializer_wsgi.py
:language: python

.. tab-item:: ASGI
:sync: asgi

.. literalinclude:: ../../../examples/recipes/error_handling_serializer_asgi.py
:language: python

With this serializer in place, requesting the above resource with an
unsatisfiable division (e.g., dividing by zero) will trigger Falcon's
default ``500`` response, but rendered as plain text if the client sent
``Accept: text/plain``, or JSON otherwise.

Custom Error Handler
----------------------

Sometimes it is more convenient to intercept a specific exception type and
decide how to handle it inline, rather than (or in addition to) customizing
the serializer. :meth:`~falcon.App.add_error_handler` lets us register a
handler for a given exception class. From within the handler we can either:

* Render a response directly (by setting ``resp.status``, ``resp.text``,
etc.), or
* Re-raise the exception as an instance of :class:`~falcon.HTTPError`, and
let Falcon render it as usual.

The following example does both, depending on the value of one of the
routed URI parameters:

.. tab-set::

.. tab-item:: WSGI
:sync: wsgi

.. literalinclude:: ../../../examples/recipes/error_handling_custom_wsgi.py
:language: python

.. tab-item:: ASGI
:sync: asgi

.. literalinclude:: ../../../examples/recipes/error_handling_custom_asgi.py
:language: python

Here, requesting a small enough exponentiation that still overflows
(``OverflowError``) results in a custom ``422 Unprocessable Entity`` plain
text response. However, once the requested power reaches ``1000`` or more,
the handler instead raises :class:`~falcon.HTTPBadRequest`, which Falcon
renders as a standard JSON error response.
3 changes: 2 additions & 1 deletion docs/user/recipes/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ Recipes
.. toctree::
:maxdepth: 2

error-handling
header-name-case
msgspec-integration
multipart-mixed
output-csv
plain-text-handler
pretty-json
raw-url-path
request-id
request-id
21 changes: 21 additions & 0 deletions examples/recipes/error_handling_custom_asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import falcon
import falcon.asgi


async def handle_overflow(req, resp, ex, params):
if params.get('power', 0) >= 1000:
raise falcon.HTTPBadRequest(title='Too Damn High!')

resp.content_type = falcon.MEDIA_TEXT
resp.text = 'That was too much to handle... Check your parameters.\n'
resp.status = falcon.HTTP_422


class Exponentiation:
async def on_get(self, req, resp, base, power):
resp.media = base**power


app = falcon.asgi.App()
app.add_error_handler(OverflowError, handle_overflow)
app.add_route('/exponentiation/{base:float}/{power:float}', Exponentiation())
23 changes: 23 additions & 0 deletions examples/recipes/error_handling_custom_wsgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import falcon


def handle_overflow(req, resp, ex, params):
if params.get('power', 0) >= 1000:
# NOTE: Re-raise as a Falcon HTTPError; Falcon takes care of
# rendering an appropriate response for us from here.
raise falcon.HTTPBadRequest(title='Too Damn High!')

# NOTE: Otherwise, render a custom error response directly.
resp.content_type = falcon.MEDIA_TEXT
resp.text = 'That was too much to handle... Check your parameters.\n'
resp.status = falcon.HTTP_422


class Exponentiation:
def on_get(self, req, resp, base, power):
resp.media = base**power


app = falcon.App()
app.add_error_handler(OverflowError, handle_overflow)
app.add_route('/exponentiation/{base:float}/{power:float}', Exponentiation())
28 changes: 28 additions & 0 deletions examples/recipes/error_handling_serializer_asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import falcon.asgi


def serialize_error(req, resp, exception):
preferred = req.client_prefers((falcon.MEDIA_JSON, falcon.MEDIA_TEXT))

if preferred == falcon.MEDIA_TEXT:
report = ['[Custom error serializer]\n']
for key, value in sorted(exception.to_dict().items()):
report.append(f'{key}: {value}\n')

resp.content_type = falcon.MEDIA_TEXT
resp.text = ''.join(report)
else:
resp.content_type = falcon.MEDIA_JSON
resp.data = exception.to_json()

resp.append_header('Vary', 'Accept')


class Division:
async def on_get(self, req, resp, dividend, divisor):
resp.media = dividend / divisor


app = falcon.asgi.App()
app.set_error_serializer(serialize_error)
app.add_route('/division/{dividend:int}/{divisor:int}', Division())
30 changes: 30 additions & 0 deletions examples/recipes/error_handling_serializer_wsgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import falcon


def serialize_error(req, resp, exception):
preferred = req.client_prefers((falcon.MEDIA_JSON, falcon.MEDIA_TEXT))

if preferred == falcon.MEDIA_TEXT:
report = ['[Custom error serializer]\n']
for key, value in sorted(exception.to_dict().items()):
report.append(f'{key}: {value}\n')

resp.content_type = falcon.MEDIA_TEXT
resp.text = ''.join(report)
else:
resp.content_type = falcon.MEDIA_JSON
resp.data = exception.to_json()

resp.append_header('Vary', 'Accept')


class Division:
def on_get(self, req, resp, dividend, divisor):
# NOTE: Dividing by zero triggers Falcon's default 500 response,
# which is now rendered through our custom serializer above.
resp.media = dividend / divisor


app = falcon.App()
app.set_error_serializer(serialize_error)
app.add_route('/division/{dividend:int}/{divisor:int}', Division())
50 changes: 50 additions & 0 deletions tests/test_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,56 @@ def test_csv_output(self, asgi, app_kind, util, recipe, expected_head):
assert result.status_code == 200
assert result.text.startswith(expected_head)

class TestErrorHandling:
@pytest.mark.parametrize(
'accept,expected_content_type',
[
(falcon.MEDIA_TEXT, falcon.MEDIA_TEXT),
(falcon.MEDIA_JSON, falcon.MEDIA_JSON),
],
)
def test_serializer(self, asgi, app_kind, util, accept, expected_content_type):
module = util.load_module(
'error_handling_serializer', parent_dir='examples/recipes', suffix=app_kind
)
app = util.create_app(asgi)
app.set_error_serializer(module.serialize_error)
app.add_route('/division/{dividend:int}/{divisor:int}', module.Division())

result = falcon.testing.simulate_get(
app, '/division/1/0', headers={'Accept': accept}
)
assert result.status_code == 500
assert result.headers['Content-Type'].startswith(expected_content_type)

def test_custom_handler_renders_response(self, asgi, app_kind, util):
module = util.load_module(
'error_handling_custom', parent_dir='examples/recipes', suffix=app_kind
)
app = util.create_app(asgi)
app.add_error_handler(OverflowError, module.handle_overflow)
app.add_route(
'/exponentiation/{base:float}/{power:float}', module.Exponentiation()
)

result = falcon.testing.simulate_get(app, '/exponentiation/20/900')
assert result.status_code == 422
assert result.text.startswith('That was too much to handle')

def test_custom_handler_reraises_httperror(self, asgi, app_kind, util):
module = util.load_module(
'error_handling_custom', parent_dir='examples/recipes', suffix=app_kind
)
app = util.create_app(asgi)
app.add_error_handler(OverflowError, module.handle_overflow)
app.add_route(
'/exponentiation/{base:float}/{power:float}', module.Exponentiation()
)

result = falcon.testing.simulate_get(app, '/exponentiation/20/1000')
assert result.status_code == 400
assert result.json == {'title': 'Too Damn High!'}


class TestPrettyJSON:
class QuoteResource:
Expand Down
Loading