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,13 +1,283 @@
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
+
+
+FALLBACK_TEXT = (
+ "The server encountered an internal error and "
+ "cannot complete your request."
+)
+FALLBACK_STATUS = 500
+
+
+class BaseRenderer:
+ def __init__(self, request, exception, debug):
+ self.request = request
+ self.exception = exception
+ self.debug = debug
+
+ @property
+ def headers(self):
+ if isinstance(self.exception, SanicException):
+ return getattr(self.exception, "headers", {})
+ return {}
+
+ @property
+ def status(self):
+ if isinstance(self.exception, SanicException):
+ return getattr(self.exception, "status_code", FALLBACK_STATUS)
+ return FALLBACK_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()
+
+ def minimal(self): # noqa
+ raise NotImplementedError
+
+ def full(self): # noqa
+ raise NotImplementedError
+
+
+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 = (
+ "
"
+ "{frame_html}
"
+ )
+ TRACEBACK_BORDER = (
+ ""
+ "The above exception was the direct cause of the following exception:"
+ "
"
+ )
+ TRACEBACK_LINE_HTML = (
+ ""
+ "
"
+ "File {0.filename}, line {0.lineno}, "
+ "in {0.name}
"
+ "
{0.line}
"
+ "
"
+ )
+ OUTPUT_HTML = (
+ ""
+ "{title}\n"
+ "\n"
+ "{title}
{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 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)
+
+ @property
+ def title(self):
+ return escape(f"⚠️ {super().title}")
+
+ 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_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"
Traceback of {appname} (most recent call last):
",
+ f"{traceback_html}",
+ "",
+ f"{name}: {value} while handling path {path}
",
+ "
",
+ ]
+ 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):
@@ -15,103 +285,46 @@ def escape(text):
return f"{text}".replace("&", "&").replace("<", "<")
-def exception_response(request, exception, debug):
- status = 500
- text = (
- "The server encountered an internal error "
- "and cannot complete your request."
- )
+RENDERERS_BY_CONFIG = {
+ "html": HTMLRenderer,
+ "json": JSONRenderer,
+ "text": TextRenderer,
+}
- headers = {}
- if isinstance(exception, SanicException):
- text = f"{exception}"
- status = getattr(exception, "status_code", status)
- headers = getattr(exception, "headers", headers)
- elif debug:
- text = f"{exception}"
-
- status_text = STATUS_CODES.get(status, b"Error Occurred").decode()
- title = escape(f"{status} — {status_text}")
- text = escape(text)
-
- if debug and not getattr(exception, "quiet", False):
- return html(
- f"{title}"
- f"\n"
- f"⚠️ {title}
{text}\n"
- f"{_render_traceback_html(request, exception)}",
- status=status,
- )
-
- # Keeping it minimal with trailing newline for pretty curl/console output
- return html(
- f"
{title}"
- "\n"
- f"⚠️ {title}
{text}\n",
- status=status,
- headers=headers,
- )
+RENDERERS_BY_CONTENT_TYPE = {
+ "multipart/form-data": HTMLRenderer,
+ "application/json": JSONRenderer,
+ "text/plain": TextRenderer,
+}
-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,
- )
+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
-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__
+ if request:
+ if request.app.config.FALLBACK_ERROR_FORMAT == "auto":
+ try:
+ renderer = JSONRenderer if request.json else HTMLRenderer
+ except InvalidUsage:
+ renderer = HTMLRenderer
- 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"
Traceback of {appname} (most recent call last):
"
- f"{traceback_html}"
- ""
- f"{name}: {value} while handling path {path}
"
- )
+ 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)
-
-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 = (
- "
"
- "
{frame_html}
"
-)
-
-TRACEBACK_BORDER = (
- "
"
- "The above exception was the direct cause of the following exception:"
- "
"
-)
-
-TRACEBACK_LINE_HTML = (
- "
"
- "
"
- "File {0.filename}, line {0.lineno}, "
- "in {0.name}
"
- "
{0.line}
"
- "
"
-)
+ renderer = t.cast(t.Type[BaseRenderer], renderer)
+ return renderer(request, exception, debug).render()
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"