Repository: huge-success/sanic
Pull request #1937 (2020-09-29 08:59:18)

Add text and json fallback error handlers
Replaces #1905 

PR #1768 added HTML default exception pages. This PR adds two alternative fallback exception pages: text and JSON.

*This is **NOT** meant to replace custom error handlers, but is meant to be for **fallback***.

This is configurable with:

```python
app.config.FALLBACK_ERROR_FORMAT = "text"
# or
app.config.FALLBACK_ERROR_FORMAT = "json"
```

The **default** will still be **HTML**. And, the HTML page is untouched (except for some fixes to make valid HTML.

Just like in HTML, there are two handlers: one for debuggable exception responses, the other for minimal responses.

### Text

```
⚠️ 500 — Internal Server Error
==============================
foobar

Exception: foobar while handling path /hello
Traceback of __BASE__ (most recent call last):

  Exception: foobar
    File /path/to/sanic/app.py, line 938, in handle_request
    response = await response

    File server.py, line 12, in foo
    raise Exception("foobar")
```

```
⚠️ 500 — Internal Server Error
==============================
The server encountered an internal error and cannot complete your request.

```

### JSON

```json
{
  "description": "Internal Server Error",
  "status": 500,
  "message": "foobar",
  "path": "/hello",
  "args": {
    "name": [
      "world"
    ]
  },
  "exceptions": [
    {
      "type": "Exception",
      "exception": "foobar",
      "frames": [
        {
          "file": "/path/to/sanic/app.py",
          "line": 938,
          "name": "handle_request",
          "src": "response = await response"
        },
        {
          "file": "server.py",
          "line": 12,
          "name": "foo",
          "src": "raise Exception(\"foobar\")"
        }
      ]
    }
  ]
}
```

```json
{
  "description": "Internal Server Error",
  "status": 500,
  "message": "The server encountered an internal error and cannot complete your request."
}

```


ahopkins: I am still working on the tests, but I wanted to get this out there for some feedback ASAP so we can try and include it in upcoming release. It is **not** a breaking change, and a relatively minor change.
ahopkins: > Looks great!
> 
> 
> 
> FWIW, the old HTML responses _are_ actually valid HTML5.
> 
> The `<!DOCTYPE html>` hint tag puts the document in HTML5-mode. In HTML5 the `<html>` document wrapper tag is not needed (though most people put it in anyway for html4 backward compatibility). Also the `<head>` tag is _technically_ optional, you can just put your meta, script, title, and style tags before your content. Along the same lines, it doesn't need a `<body>` tag if there's no head before it.
> 
> `<p>` tags never need to be closed (some linters even generate an error for a closing `</p>` tag) and finally `</body>` and `</html>` closing tags don't need to be present at the end of the file (they are assumed present if the HTML just ends).

I put it in a linter and it yelled at me 🤷‍♂️
vltr: long time no see, but I really like this PR. I was just wondering: what if we read the `Content-Type` header from the request (in case it exists) and provide the error properly? let's say, `text/plain` would return the text error, `application/json` would return the JSON error and anything else not declared would return the default fallback? it's just an initial thought, it's open to ideas :sweat_smile: 
ahopkins: > long time no see, but I really like this PR. I was just wondering: what if we read the `Content-Type` header from the request (in case it exists) and provide the error properly? let's say, `text/plain` would return the text error, `application/json` would return the JSON error and anything else not declared would return the default fallback? it's just an initial thought, it's open to ideas :sweat_smile: 

I like this idea. Maybe this can be the behavior for

```
app.config.FALLBACK_ERROR_FORMAT = "auto"
```

I would suggest that would be a sensible default. But maybe since *that* would be a breaking change, maybe we don't make that default until after LTS.
vltr: > ```
> app.config.FALLBACK_ERROR_FORMAT = "auto"
> ```

hey, I like that as well! :sunglasses: I don't know if this will add more complexity to the code (after all, "_gotta go fast_"), but this is just in case of an error, which is not what any developer is looking on their app (except during development, sure), so ... but, **even so**, I'm pretty sure that could be passed to a constructor / `partial` to eliminate any unnecessary `if`s and / or `else`s from the code (not that I'm concerned, but I had to bring this up before this could become a concern).


ahopkins: @vltr, I think the main usage of this is really for development purposes. They **should** be safe enough to not leak in production, but this is not intended to be a production-ready replacement for error handling. Primarily because (as mentioned in the other thread), there are too many considerations for us to be able to account for. With that said, I will keep this in mind.
vltr: @ahopkins yeap, being just for development I think it's fine ... I just mentioned about performance because, well, never mind, that should not be the case :+1: 
ahopkins: > Looks great!
> 
> FWIW, the old HTML responses _are_ actually valid HTML5.
> The `<!DOCTYPE html>` hint tag puts the document in HTML5-mode. In HTML5 the `<html>` document wrapper tag is not needed (though most people put it in anyway for html4 backward compatibility). Also the `<head>` tag is _technically_ optional, you can just put your meta, script, title, and style tags before your content. Along the same lines, it doesn't need a `<body>` tag if there's no head before it.
> `<p>` tags never need to be closed (some linters even generate an error for a closing `</p>` tag) and finally `</body>` and `</html>` closing tags don't need to be present at the end of the file (they are assumed present if the HTML just ends).

I discovered the issue. Needed a single closing `</div>`.
vltr: > I discovered the issue. Needed a single closing `</div>`.

"[The missing `</div>`](https://thecodinglove.com/when-i-find-the-missing-div)". Sorry, but I had to ... :rofl: 
ashleysommer: Ok, I think we can still push this out in this release, with default format as "html", to keep compatibility. But we'll change it to default to "auto" in v21.03.
ashleysommer: There is still one type-hinting mypy error
```
error: "BaseRenderer" not callable
```
codecov[bot]: # [Codecov](https://codecov.io/gh/huge-success/sanic/pull/1937?src=pr&el=h1) Report
> Merging [#1937](https://codecov.io/gh/huge-success/sanic/pull/1937?src=pr&el=desc) into [master](https://codecov.io/gh/huge-success/sanic/commit/0c4a9b1dce3d1af828e595dcdbd75199ef4d9eb2?el=desc) will **increase** coverage by `0.28%`.
> The diff coverage is `99.24%`.

[![Impacted file tree graph](https://codecov.io/gh/huge-success/sanic/pull/1937/graphs/tree.svg?width=650&height=150&src=pr&token=NqLbJHUqoR)](https://codecov.io/gh/huge-success/sanic/pull/1937?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1937      +/-   ##
==========================================
+ Coverage   92.45%   92.73%   +0.28%     
==========================================
  Files          27       27              
  Lines        3100     3192      +92     
  Branches      555      563       +8     
==========================================
+ Hits         2866     2960      +94     
+ Misses        157      155       -2     
  Partials       77       77              
```


| [Impacted Files](https://codecov.io/gh/huge-success/sanic/pull/1937?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [sanic/config.py](https://codecov.io/gh/huge-success/sanic/pull/1937/diff?src=pr&el=tree#diff-c2FuaWMvY29uZmlnLnB5) | `100.00% <ø> (ø)` | |
| [sanic/errorpages.py](https://codecov.io/gh/huge-success/sanic/pull/1937/diff?src=pr&el=tree#diff-c2FuaWMvZXJyb3JwYWdlcy5weQ==) | `99.25% <99.24%> (-0.75%)` | :arrow_down: |
| [sanic/server.py](https://codecov.io/gh/huge-success/sanic/pull/1937/diff?src=pr&el=tree#diff-c2FuaWMvc2VydmVyLnB5) | `81.61% <0.00%> (+0.56%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/huge-success/sanic/pull/1937?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/huge-success/sanic/pull/1937?src=pr&el=footer). Last update [0c4a9b1...fdfc129](https://codecov.io/gh/huge-success/sanic/pull/1937?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/sanic/config.py b/sanic/config.py
index 3fbc157f..7b7e170d 100644
--- a/sanic/config.py
+++ b/sanic/config.py
@@ -32,6 +32,7 @@ DEFAULT_CONFIG = {
     "REAL_IP_HEADER": None,
     "PROXIES_COUNT": None,
     "FORWARDED_FOR_HEADER": "X-Forwarded-For",
+    "FALLBACK_ERROR_FORMAT": "html",
 }
 
 
diff --git a/sanic/errorpages.py b/sanic/errorpages.py
index 2d17cbde..a4d6b494 100644
--- a/sanic/errorpages.py
+++ b/sanic/errorpages.py
@@ -1,117 +1,330 @@
 import sys
+import typing as t
 
+from functools import partial
 from traceback import extract_tb
 
-from sanic.exceptions import SanicException
+from sanic.exceptions import InvalidUsage, SanicException
 from sanic.helpers import STATUS_CODES
-from sanic.response import html
+from sanic.request import Request
+from sanic.response import HTTPResponse, html, json, text
 
 
-# Here, There Be Dragons (custom HTML formatting to follow)
+try:
+    from ujson import dumps
 
+    dumps = partial(dumps, escape_forward_slashes=False)
+except ImportError:  # noqa
+    from json import dumps  # type: ignore
 
-def escape(text):
-    """Minimal HTML escaping, not for attribute values (unlike html.escape)."""
-    return f"{text}".replace("&", "&amp;").replace("<", "&lt;")
+
+FALLBACK_TEXT = (
+    "The server encountered an internal error and "
+    "cannot complete your request."
+)
+FALLBACK_STATUS = 500
 
 
-def exception_response(request, exception, debug):
-    status = 500
-    text = (
-        "The server encountered an internal error "
-        "and cannot complete your request."
-    )
+class BaseRenderer:
+    def __init__(self, request, exception, debug):
+        self.request = request
+        self.exception = exception
+        self.debug = debug
 
-    headers = {}
-    if isinstance(exception, SanicException):
-        text = f"{exception}"
-        status = getattr(exception, "status_code", status)
-        headers = getattr(exception, "headers", headers)
-    elif debug:
-        text = f"{exception}"
+    @property
+    def headers(self):
+        if isinstance(self.exception, SanicException):
+            return getattr(self.exception, "headers", {})
+        return {}
 
-    status_text = STATUS_CODES.get(status, b"Error Occurred").decode()
-    title = escape(f"{status} — {status_text}")
-    text = escape(text)
+    @property
+    def status(self):
+        if isinstance(self.exception, SanicException):
+            return getattr(self.exception, "status_code", FALLBACK_STATUS)
+        return FALLBACK_STATUS
 
-    if debug and not getattr(exception, "quiet", False):
-        return html(
-            f"<!DOCTYPE html><meta charset=UTF-8><title>{title}</title>"
-            f"<style>{TRACEBACK_STYLE}</style>\n"
-            f"<h1>⚠️ {title}</h1><p>{text}\n"
-            f"{_render_traceback_html(request, exception)}",
-            status=status,
+    @property
+    def text(self):
+        if self.debug or isinstance(self.exception, SanicException):
+            return str(self.exception)
+        return FALLBACK_TEXT
+
+    @property
+    def title(self):
+        status_text = STATUS_CODES.get(self.status, b"Error Occurred").decode()
+        return f"{self.status} — {status_text}"
+
+    def render(self):
+        output = (
+            self.full
+            if self.debug and not getattr(self.exception, "quiet", False)
+            else self.minimal
         )
+        return output()
 
-    # Keeping it minimal with trailing newline for pretty curl/console output
-    return html(
-        f"<!DOCTYPE html><meta charset=UTF-8><title>{title}</title>"
-        "<style>html { font-family: sans-serif }</style>\n"
-        f"<h1>⚠️ {title}</h1><p>{text}\n",
-        status=status,
-        headers=headers,
-    )
+    def minimal(self):  # noqa
+        raise NotImplementedError
 
+    def full(self):  # noqa
+        raise NotImplementedError
 
-def _render_exception(exception):
-    frames = extract_tb(exception.__traceback__)
-    frame_html = "".join(TRACEBACK_LINE_HTML.format(frame) for frame in frames)
-    return TRACEBACK_WRAPPER_HTML.format(
-        exc_name=escape(exception.__class__.__name__),
-        exc_value=escape(exception),
-        frame_html=frame_html,
+
+class HTMLRenderer(BaseRenderer):
+    TRACEBACK_STYLE = """
+        html { font-family: sans-serif }
+        h2 { color: #888; }
+        .tb-wrapper p { margin: 0 }
+        .frame-border { margin: 1rem }
+        .frame-line > * { padding: 0.3rem 0.6rem }
+        .frame-line { margin-bottom: 0.3rem }
+        .frame-code { font-size: 16px; padding-left: 4ch }
+        .tb-wrapper { border: 1px solid #eee }
+        .tb-header { background: #eee; padding: 0.3rem; font-weight: bold }
+        .frame-descriptor { background: #e2eafb; font-size: 14px }
+    """
+    TRACEBACK_WRAPPER_HTML = (
+        "<div class=tb-header>{exc_name}: {exc_value}</div>"
+        "<div class=tb-wrapper>{frame_html}</div>"
+    )
+    TRACEBACK_BORDER = (
+        "<div class=frame-border>"
+        "The above exception was the direct cause of the following exception:"
+        "</div>"
+    )
+    TRACEBACK_LINE_HTML = (
+        "<div class=frame-line>"
+        "<p class=frame-descriptor>"
+        "File {0.filename}, line <i>{0.lineno}</i>, "
+        "in <code><b>{0.name}</b></code>"
+        "<p class=frame-code><code>{0.line}</code>"
+        "</div>"
+    )
+    OUTPUT_HTML = (
+        "<!DOCTYPE html><html lang=en>"
+        "<meta charset=UTF-8><title>{title}</title>\n"
+        "<style>{style}</style>\n"
+        "<h1>{title}</h1><p>{text}\n"
+        "{body}"
     )
 
+    def full(self):
+        return html(
+            self.OUTPUT_HTML.format(
+                title=self.title,
+                text=self.text,
+                style=self.TRACEBACK_STYLE,
+                body=self._generate_body(),
+            ),
+            status=self.status,
+        )
 
-def _render_traceback_html(request, exception):
-    exc_type, exc_value, tb = sys.exc_info()
-    exceptions = []
-    while exc_value:
-        exceptions.append(_render_exception(exc_value))
-        exc_value = exc_value.__cause__
-
-    traceback_html = TRACEBACK_BORDER.join(reversed(exceptions))
-    appname = escape(request.app.name)
-    name = escape(exception.__class__.__name__)
-    value = escape(exception)
-    path = escape(request.path)
-    return (
-        f"<h2>Traceback of {appname} (most recent call last):</h2>"
-        f"{traceback_html}"
-        "<div class=summary><p>"
-        f"<b>{name}: {value}</b> while handling path <code>{path}</code>"
-    )
+    def minimal(self):
+        return html(
+            self.OUTPUT_HTML.format(
+                title=self.title,
+                text=self.text,
+                style=self.TRACEBACK_STYLE,
+                body="",
+            ),
+            status=self.status,
+            headers=self.headers,
+        )
 
+    @property
+    def text(self):
+        return escape(super().text)
 
-TRACEBACK_STYLE = """
-    html { font-family: sans-serif }
-    h2 { color: #888; }
-    .tb-wrapper p { margin: 0 }
-    .frame-border { margin: 1rem }
-    .frame-line > * { padding: 0.3rem 0.6rem }
-    .frame-line { margin-bottom: 0.3rem }
-    .frame-code { font-size: 16px; padding-left: 4ch }
-    .tb-wrapper { border: 1px solid #eee }
-    .tb-header { background: #eee; padding: 0.3rem; font-weight: bold }
-    .frame-descriptor { background: #e2eafb; font-size: 14px }
-"""
-
-TRACEBACK_WRAPPER_HTML = (
-    "<div class=tb-header>{exc_name}: {exc_value}</div>"
-    "<div class=tb-wrapper>{frame_html}</div>"
-)
+    @property
+    def title(self):
+        return escape(f"⚠️ {super().title}")
 
-TRACEBACK_BORDER = (
-    "<div class=frame-border>"
-    "The above exception was the direct cause of the following exception:"
-    "</div>"
-)
+    def _generate_body(self):
+        _, exc_value, __ = sys.exc_info()
+        exceptions = []
+        while exc_value:
+            exceptions.append(self._format_exc(exc_value))
+            exc_value = exc_value.__cause__
 
-TRACEBACK_LINE_HTML = (
-    "<div class=frame-line>"
-    "<p class=frame-descriptor>"
-    "File {0.filename}, line <i>{0.lineno}</i>, "
-    "in <code><b>{0.name}</b></code>"
-    "<p class=frame-code><code>{0.line}</code>"
-    "</div>"
-)
+        traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions))
+        appname = escape(self.request.app.name)
+        name = escape(self.exception.__class__.__name__)
+        value = escape(self.exception)
+        path = escape(self.request.path)
+        lines = [
+            f"<h2>Traceback of {appname} (most recent call last):</h2>",
+            f"{traceback_html}",
+            "<div class=summary><p>",
+            f"<b>{name}: {value}</b> while handling path <code>{path}</code>",
+            "</div>",
+        ]
+        return "\n".join(lines)
+
+    def _format_exc(self, exc):
+        frames = extract_tb(exc.__traceback__)
+        frame_html = "".join(
+            self.TRACEBACK_LINE_HTML.format(frame) for frame in frames
+        )
+        return self.TRACEBACK_WRAPPER_HTML.format(
+            exc_name=escape(exc.__class__.__name__),
+            exc_value=escape(exc),
+            frame_html=frame_html,
+        )
+
+
+class TextRenderer(BaseRenderer):
+    OUTPUT_TEXT = "{title}\n{bar}\n{text}\n\n{body}"
+    SPACER = "  "
+
+    def full(self):
+        return text(
+            self.OUTPUT_TEXT.format(
+                title=self.title,
+                text=self.text,
+                bar=("=" * len(self.title)),
+                body=self._generate_body(),
+            ),
+            status=self.status,
+        )
+
+    def minimal(self):
+        return text(
+            self.OUTPUT_TEXT.format(
+                title=self.title,
+                text=self.text,
+                bar=("=" * len(self.title)),
+                body="",
+            ),
+            status=self.status,
+            headers=self.headers,
+        )
+
+    @property
+    def title(self):
+        return f"⚠️ {super().title}"
+
+    def _generate_body(self):
+        _, exc_value, __ = sys.exc_info()
+        exceptions = []
+
+        # traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions))
+        lines = [
+            f"{self.exception.__class__.__name__}: {self.exception} while "
+            f"handling path {self.request.path}",
+            f"Traceback of {self.request.app.name} (most recent call last):\n",
+        ]
+
+        while exc_value:
+            exceptions.append(self._format_exc(exc_value))
+            exc_value = exc_value.__cause__
+
+        return "\n".join(lines + exceptions[::-1])
+
+    def _format_exc(self, exc):
+        frames = "\n\n".join(
+            [
+                f"{self.SPACER * 2}File {frame.filename}, "
+                f"line {frame.lineno}, in "
+                f"{frame.name}\n{self.SPACER * 2}{frame.line}"
+                for frame in extract_tb(exc.__traceback__)
+            ]
+        )
+        return f"{self.SPACER}{exc.__class__.__name__}: {exc}\n{frames}"
+
+
+class JSONRenderer(BaseRenderer):
+    def full(self):
+        output = self._generate_output(full=True)
+        return json(output, status=self.status, dumps=dumps)
+
+    def minimal(self):
+        output = self._generate_output(full=False)
+        return json(output, status=self.status, dumps=dumps)
+
+    def _generate_output(self, *, full):
+        output = {
+            "description": self.title,
+            "status": self.status,
+            "message": self.text,
+        }
+
+        if full:
+            _, exc_value, __ = sys.exc_info()
+            exceptions = []
+
+            while exc_value:
+                exceptions.append(
+                    {
+                        "type": exc_value.__class__.__name__,
+                        "exception": str(exc_value),
+                        "frames": [
+                            {
+                                "file": frame.filename,
+                                "line": frame.lineno,
+                                "name": frame.name,
+                                "src": frame.line,
+                            }
+                            for frame in extract_tb(exc_value.__traceback__)
+                        ],
+                    }
+                )
+                exc_value = exc_value.__cause__
+
+            output["path"] = self.request.path
+            output["args"] = self.request.args
+            output["exceptions"] = exceptions[::-1]
+
+        return output
+
+    @property
+    def title(self):
+        return STATUS_CODES.get(self.status, b"Error Occurred").decode()
+
+
+def escape(text):
+    """Minimal HTML escaping, not for attribute values (unlike html.escape)."""
+    return f"{text}".replace("&", "&amp;").replace("<", "&lt;")
+
+
+RENDERERS_BY_CONFIG = {
+    "html": HTMLRenderer,
+    "json": JSONRenderer,
+    "text": TextRenderer,
+}
+
+RENDERERS_BY_CONTENT_TYPE = {
+    "multipart/form-data": HTMLRenderer,
+    "application/json": JSONRenderer,
+    "text/plain": TextRenderer,
+}
+
+
+def exception_response(
+    request: Request,
+    exception: Exception,
+    debug: bool,
+    renderer: t.Type[t.Optional[BaseRenderer]] = None,
+) -> HTTPResponse:
+    """Render a response for the default FALLBACK exception handler"""
+
+    if not renderer:
+        renderer = HTMLRenderer
+
+        if request:
+            if request.app.config.FALLBACK_ERROR_FORMAT == "auto":
+                try:
+                    renderer = JSONRenderer if request.json else HTMLRenderer
+                except InvalidUsage:
+                    renderer = HTMLRenderer
+
+                content_type, *_ = request.headers.get(
+                    "content-type", ""
+                ).split(";")
+                renderer = RENDERERS_BY_CONTENT_TYPE.get(
+                    content_type, renderer
+                )
+            else:
+                render_format = request.app.config.FALLBACK_ERROR_FORMAT
+                renderer = RENDERERS_BY_CONFIG.get(render_format, renderer)
+
+    renderer = t.cast(t.Type[BaseRenderer], renderer)
+    return renderer(request, exception, debug).render()


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index dab46061..c9c44c39 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -126,7 +126,7 @@ def test_app_handle_request_handler_is_none(app, monkeypatch):
     def handler(request):
         return text("test")
 
-    request, response = app.test_client.get("/test")
+    _, response = app.test_client.get("/test")
 
     assert (
         "'None' was returned while requesting a handler from the router"
diff --git a/tests/test_errorpages.py b/tests/test_errorpages.py
new file mode 100644
index 00000000..495c764f
--- /dev/null
+++ b/tests/test_errorpages.py
@@ -0,0 +1,86 @@
+import pytest
+
+from sanic import Sanic
+from sanic.errorpages import exception_response
+from sanic.exceptions import NotFound
+from sanic.request import Request
+from sanic.response import HTTPResponse
+
+
+@pytest.fixture
+def app():
+    app = Sanic("error_page_testing")
+
+    @app.route("/error", methods=["GET", "POST"])
+    def err(request):
+        raise Exception("something went wrong")
+
+    return app
+
+
+@pytest.fixture
+def fake_request(app):
+    return Request(b"/foobar", {}, "1.1", "GET", None, app)
+
+
+@pytest.mark.parametrize(
+    "fallback,content_type, exception, status",
+    (
+        (None, "text/html; charset=utf-8", Exception, 500),
+        ("html", "text/html; charset=utf-8", Exception, 500),
+        ("auto", "text/html; charset=utf-8", Exception, 500),
+        ("text", "text/plain; charset=utf-8", Exception, 500),
+        ("json", "application/json", Exception, 500),
+        (None, "text/html; charset=utf-8", NotFound, 404),
+        ("html", "text/html; charset=utf-8", NotFound, 404),
+        ("auto", "text/html; charset=utf-8", NotFound, 404),
+        ("text", "text/plain; charset=utf-8", NotFound, 404),
+        ("json", "application/json", NotFound, 404),
+    ),
+)
+def test_should_return_html_valid_setting(
+    fake_request, fallback, content_type, exception, status
+):
+    if fallback:
+        fake_request.app.config.FALLBACK_ERROR_FORMAT = fallback
+
+    try:
+        raise exception("bad stuff")
+    except Exception as e:
+        response = exception_response(fake_request, e, True)
+
+    assert isinstance(response, HTTPResponse)
+    assert response.status == status
+    assert response.content_type == content_type
+
+
+def test_auto_fallback_with_data(app):
+    app.config.FALLBACK_ERROR_FORMAT = "auto"
+
+    _, response = app.test_client.get("/error")
+    assert response.status == 500
+    assert response.content_type == "text/html; charset=utf-8"
+
+    _, response = app.test_client.post("/error", json={"foo": "bar"})
+    assert response.status == 500
+    assert response.content_type == "application/json"
+
+    _, response = app.test_client.post("/error", data={"foo": "bar"})
+    assert response.status == 500
+    assert response.content_type == "text/html; charset=utf-8"
+
+
+def test_auto_fallback_with_content_type(app):
+    app.config.FALLBACK_ERROR_FORMAT = "auto"
+
+    _, response = app.test_client.get(
+        "/error", headers={"content-type": "application/json"}
+    )
+    assert response.status == 500
+    assert response.content_type == "application/json"
+
+    _, response = app.test_client.get(
+        "/error", headers={"content-type": "text/plain"}
+    )
+    assert response.status == 500
+    assert response.content_type == "text/plain; charset=utf-8"