0
0
mirror of https://github.com/django/django.git synced 2024-11-21 19:09:18 +01:00

WIP: Refs #35281 -- Unified and generalized request error handling.

This commit is contained in:
Claude Paroz 2024-10-26 19:06:15 +02:00
parent 2debd018db
commit 73b904c5e0
16 changed files with 285 additions and 66 deletions

View File

@ -1,9 +1,3 @@
from django.urls import include
from django.views import defaults
__all__ = ["handler400", "handler403", "handler404", "handler500", "include"]
handler400 = defaults.bad_request
handler403 = defaults.permission_denied
handler404 = defaults.page_not_found
handler500 = defaults.server_error
__all__ = ["include"]

View File

@ -131,7 +131,7 @@ def check_custom_error_handlers(app_configs, **kwargs):
errors = []
# All handlers take (request, exception) arguments except handler500
# which takes (request).
for status_code, num_parameters in [(400, 2), (403, 2), (404, 2), (500, 1)]:
for status_code, num_parameters in [(400, 2), (403, 2), (404, 2), (500, 2)]:
try:
handler = resolver.resolve_error_handler(status_code)
except (ImportError, ViewDoesNotExist) as e:

View File

@ -211,6 +211,23 @@ class HttpResponseBase:
def get(self, header, alternate=None):
return self.headers.get(header, alternate)
@classmethod
def response_class_by_status_code(cls, status_code):
return {
200: HttpResponse,
301: HttpResponsePermanentRedirect,
302: HttpResponseRedirect,
304: HttpResponseNotModified,
307: HttpResponseRedirect,
308: HttpResponsePermanentRedirect,
400: HttpResponseBadRequest,
403: HttpResponseForbidden,
404: HttpResponseNotFound,
405: HttpResponseNotAllowed,
410: HttpResponseGone,
500: HttpResponseServerError,
}.get(status_code, cls)
def set_cookie(
self,
key,

View File

@ -10,6 +10,7 @@ import functools
import inspect
import re
import string
import warnings
from importlib import import_module
from pickle import PicklingError
from urllib.parse import quote
@ -21,6 +22,7 @@ from django.core.checks import Error, Warning
from django.core.checks.urls import check_resolver
from django.core.exceptions import ImproperlyConfigured
from django.utils.datastructures import MultiValueDict
from django.utils.deprecation import RemovedInDjango61Warning
from django.utils.functional import cached_property
from django.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes
from django.utils.regex_helper import _lazy_re_compile, normalize
@ -728,15 +730,26 @@ class URLResolver:
raise ImproperlyConfigured(msg.format(name=self.urlconf_name)) from e
return patterns
def resolve_error_handler(self, view_type):
callback = getattr(self.urlconf_module, "handler%s" % view_type, None)
if not callback:
def resolve_error_handler(self, status_code):
# RemovedInDjango61Warning.
callback = getattr(self.urlconf_module, f"handler{status_code}", None)
if callback:
warnings.warn(
"handler<status> custom error handlers are deprecated, please "
"replace them by a generic `error_handler` view function.",
RemovedInDjango61Warning,
)
return get_callable(callback)
error_view = getattr(self.urlconf_module, "error_handler", None)
if error_view:
error_view.status_code = status_code
else:
# No handler specified in file; use lazy import, since
# django.conf.urls imports this file.
from django.conf import urls
# django.views.defaults imports this file.
from django.views.defaults import DefaultErrorView
callback = getattr(urls, "handler%s" % view_type)
return get_callable(callback)
error_view = DefaultErrorView.as_view(status_code=status_code)
return error_view
def reverse(self, lookup_view, *args, **kwargs):
return self._reverse_with_prefix(lookup_view, "", *args, **kwargs)

View File

@ -1,13 +1,17 @@
from urllib.parse import quote
from django.http import (
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
HttpResponseNotFound,
HttpResponseServerError,
)
from django.template import Context, Engine, TemplateDoesNotExist, loader
from django.utils.decorators import method_decorator
from django.views.debug import DEBUG_ENGINE
from django.views.decorators.csrf import requires_csrf_token
from django.views.generic.base import ContextMixin, View
ERROR_404_TEMPLATE_NAME = "404.html"
ERROR_403_TEMPLATE_NAME = "403.html"
@ -148,3 +152,63 @@ def permission_denied(request, exception, template_name=ERROR_403_TEMPLATE_NAME)
return HttpResponseForbidden(
template.render(request=request, context={"exception": str(exception)})
)
@method_decorator(requires_csrf_token, name="dispatch")
class DefaultErrorView(ContextMixin, View):
status_code = None
context_by_status = {
400: {"title": "Bad Request (400)", "details": ""},
403: {"title": "403 Forbidden", "details": ""},
404: {
"title": "Not Found",
"details": "The requested resource was not found on this server.",
},
500: {"title": "Server Error (500)", "details": ""},
}
def setup(self, request, exception=None, **kwargs):
self.exception = exception
return super().setup(request, **kwargs)
def get(self, request, *args, **kwargs):
response_class = HttpResponse.response_class_by_status_code(self.status_code)
context = self.get_context_data(**kwargs)
try:
template = loader.get_template(self.get_template_name())
content = template.render(context, request)
except TemplateDoesNotExist:
template = DEBUG_ENGINE.from_string(ERROR_PAGE_TEMPLATE % context)
content = template.render(context=Context(context))
return response_class(content, status=self.status_code)
def post(self, *args, **kwargs):
return self.get(*args, **kwargs)
def get_template_name(self):
return f"{self.status_code}.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context |= self.context_by_status.get(
self.status_code, {"title": f"Error ({self.status_code})", "details": ""}
)
context |= {
"request_path": quote(self.request.path),
"exception": self.exception_as_string(),
}
return context
def exception_as_string(self):
if self.status_code == 404:
# Try to get an "interesting" exception message, if any (and not the
# ugly Resolver404 dictionary)
try:
message = self.exception.args[0]
except (AttributeError, IndexError):
pass
else:
if isinstance(message, str):
return message
return self.exception.__class__.__name__
return str(self.exception)

View File

@ -1179,9 +1179,8 @@ details on these changes.
``django.contrib.gis.utils`` will be removed.
* ``django.conf.urls.defaults`` will be removed. The functions
``include()``, ``patterns()``, and ``url()``, plus
:data:`~django.conf.urls.handler404` and :data:`~django.conf.urls.handler500`
are now available through ``django.conf.urls``.
``include()``, ``patterns()``, and ``url()``, plus ``handler404` and
``handler500`` are now available through ``django.conf.urls``.
* The functions ``setup_environ()`` and ``execute_manager()`` will be removed
from :mod:`django.core.management`. This also means that the old (pre-1.4)

View File

@ -187,6 +187,11 @@ By default, this is :func:`django.views.defaults.bad_request`. If you
implement a custom view, be sure it accepts ``request`` and ``exception``
arguments and returns an :class:`~django.http.HttpResponseBadRequest`.
.. deprecated:: 5.2
The ``handler<code>`` handlers are deprecated and should be replaced by a
generic ``error_handler`` view function.
``handler403``
==============
@ -200,6 +205,12 @@ By default, this is :func:`django.views.defaults.permission_denied`. If you
implement a custom view, be sure it accepts ``request`` and ``exception``
arguments and returns an :class:`~django.http.HttpResponseForbidden`.
.. deprecated:: 5.2
The ``handler<code>`` handlers are deprecated and should be replaced by a
generic ``error_handler`` view function.
``handler404``
==============
@ -212,6 +223,12 @@ By default, this is :func:`django.views.defaults.page_not_found`. If you
implement a custom view, be sure it accepts ``request`` and ``exception``
arguments and returns an :class:`~django.http.HttpResponseNotFound`.
.. deprecated:: 5.2
The ``handler<code>`` handlers are deprecated and should be replaced by a
generic ``error_handler`` view function.
``handler500``
==============
@ -224,3 +241,8 @@ have runtime errors in view code.
By default, this is :func:`django.views.defaults.server_error`. If you
implement a custom view, be sure it accepts a ``request`` argument and returns
an :class:`~django.http.HttpResponseServerError`.
.. deprecated:: 5.2
The ``handler<code>`` handlers are deprecated and should be replaced by a
generic ``error_handler`` view function.

View File

@ -58,8 +58,61 @@ parameter will be transparently passed to the view.
Error views
===========
Django comes with a few views by default for handling HTTP errors. To override
these with your own custom views, see :ref:`customizing-error-views`.
When any uncaught exception is produced by a view of your site, Django is
calling an error handling view, which is by default the following view.
.. _default_error_view:
The default error view
----------------------
.. class:: defaults.DefaultErrorView
.. versionadded:: 5.2
The view is a class-based-view inheriting from
:class:`~django.views.generic.base.ContextMixin`
and class:`~django.views.generic.base.View`, so refer to theses classes for
common attributes and methods.
.. attribute:: status_code
The status code of the HTTP response to return.
.. attribute:: exception
The exception that produced the error leading to the call of this error
view.
.. method:: get_template_name()
Return by default a name on the pattern ``<status_code>.html``.
.. method:: exception_as_string()
Produce a string from the exception having triggered the error view.
If you provide any template in your root template directory named after an error
response code (``404.html``, ``500.html``, etc.), that template will be used to
produce an error path. Otherwise, Django will produce a very simple page
containing an error title and a standard message.
The context used to render the error templates contains the following keys:
* ``title``: A short title for the error (like ``Not Found`` for a 404
error).
* ``details``: A short message explaining the error, possibley empty.
* ``request_path``: The original request path of the view producing the
error, quoted to prevent a content injection attack.
* ``exception``: The uncaught exception that triggered the error view, as a
string.
which either produces a "Not
Found" message or loads and renders the template ``404.html`` if you created it
in your root template directory.
To override this default view with your custom view, see
:ref:`customizing-error-views`.
.. _http_not_found_view:
@ -68,6 +121,11 @@ The 404 (page not found) view
.. function:: defaults.page_not_found(request, exception, template_name='404.html')
.. deprecated:: 5.2
This view is deprecated as it has been replaced by the :ref:`default error
view <_default_error_view>`.
When you raise :exc:`~django.http.Http404` from within a view, Django loads a
special view devoted to handling 404 errors. By default, it's the view
:func:`django.views.defaults.page_not_found`, which either produces a "Not
@ -99,6 +157,11 @@ The 500 (server error) view
.. function:: defaults.server_error(request, template_name='500.html')
.. deprecated:: 5.2
This view is deprecated as it has been replaced by the :ref:`default error
view <_default_error_view>`.
Similarly, Django executes special-case behavior in the case of runtime errors
in view code. If a view results in an exception, Django will, by default, call
the view ``django.views.defaults.server_error``, which either produces a
@ -119,6 +182,11 @@ The 403 (HTTP Forbidden) view
.. function:: defaults.permission_denied(request, exception, template_name='403.html')
.. deprecated:: 5.2
This view is deprecated as it has been replaced by the :ref:`default error
view <_default_error_view>`.
In the same vein as the 404 and 500 views, Django has a view to handle 403
Forbidden errors. If a view results in a 403 exception then Django will, by
default, call the view ``django.views.defaults.permission_denied``.
@ -148,6 +216,11 @@ The 400 (bad request) view
.. function:: defaults.bad_request(request, exception, template_name='400.html')
.. deprecated:: 5.2
This view is deprecated as it has been replaced by the :ref:`default error
view <_default_error_view>`.
When a :exc:`~django.core.exceptions.SuspiciousOperation` is raised in Django,
it may be handled by a component of Django (for example resetting the session
data). If not specifically handled, Django will consider the current request a

View File

@ -342,25 +342,28 @@ Error handling
When Django can't find a match for the requested URL, or when an exception is
raised, Django invokes an error-handling view.
The views to use for these cases are specified by four variables. Their
default values should suffice for most projects, but further customization is
possible by overriding their default values.
The default view Django uses for these cases is
:class:`django.views.defaults.DefaultErrorView`. This view should suffice for
most projects but further customization is possible by defining a custom view
to handle errors, either by subclassing this view or by creating your own.
See the documentation on :ref:`customizing error views
See the documentation on :ref:`customizing error view
<customizing-error-views>` for the full details.
Such values can be set in your root URLconf. Setting these variables in any
other URLconf will have no effect.
.. versionchanged:: 5.2
Values must be callables, or strings representing the full Python import path
to the view that should be called to handle the error condition at hand.
In previous versions, the views to use for these cases are specified by four
variables.
The variables are:
Values must be callables, or strings representing the full Python import path
to the view that should be called to handle the error condition at hand.
* ``handler400`` -- See :data:`django.conf.urls.handler400`.
* ``handler403`` -- See :data:`django.conf.urls.handler403`.
* ``handler404`` -- See :data:`django.conf.urls.handler404`.
* ``handler500`` -- See :data:`django.conf.urls.handler500`.
The variables are:
* ``handler400``
* ``handler403``
* ``handler404``
* ``handler500``
.. _including-other-urlconfs:

View File

@ -138,33 +138,46 @@ template.
.. _customizing-error-views:
Customizing error views
=======================
Customizing error view
======================
The default error views in Django should suffice for most web applications,
but can easily be overridden if you need any custom behavior. Specify the
handlers as seen below in your URLconf (setting them anywhere else will have no
effect).
The default error view in Django should suffice for most web applications, but
but can easily be overridden if you need any custom behavior.
The :func:`~django.views.defaults.page_not_found` view is overridden by
:data:`~django.conf.urls.handler404`::
The default error view can be overridden by setting a view in the
``error_handler`` variable in your URLconf (setting it anywhere else will have
no effect)::
handler404 = "mysite.views.my_custom_page_not_found_view"
error_handler = views.my_custom_error_view
The :func:`~django.views.defaults.server_error` view is overridden by
:data:`~django.conf.urls.handler500`::
or if you have a class-based-view::
handler500 = "mysite.views.my_custom_error_view"
error_handler = views.MyCustomErrorView.as_view()
The :func:`~django.views.defaults.permission_denied` view is overridden by
:data:`~django.conf.urls.handler403`::
.. versionchanged:: 5.2
handler403 = "mysite.views.my_custom_permission_denied_view"
On previous versions, there are multiple variables to define to override
particular views.
The :func:`~django.views.defaults.bad_request` view is overridden by
:data:`~django.conf.urls.handler400`::
The :func:`~django.views.defaults.page_not_found` view is overridden by
:data:`~django.conf.urls.handler404`::
handler400 = "mysite.views.my_custom_bad_request_view"
handler404 = "mysite.views.my_custom_page_not_found_view"
The :func:`~django.views.defaults.server_error` view is overridden by
:data:`~django.conf.urls.handler500`::
handler500 = "mysite.views.my_custom_error_view"
The :func:`~django.views.defaults.permission_denied` view is overridden by
:data:`~django.conf.urls.handler403`::
handler403 = "mysite.views.my_custom_permission_denied_view"
The :func:`~django.views.defaults.bad_request` view is overridden by
:data:`~django.conf.urls.handler400`::
handler400 = "mysite.views.my_custom_bad_request_view"
.. seealso::

View File

@ -8,8 +8,9 @@ from django.core.checks.urls import (
check_url_settings,
get_warning_for_invalid_pattern,
)
from django.test import SimpleTestCase
from django.test import SimpleTestCase, ignore_warnings
from django.test.utils import override_settings
from django.utils.deprecation import RemovedInDjango61Warning
class CheckUrlConfigTests(SimpleTestCase):
@ -243,10 +244,11 @@ class CheckCustomErrorHandlersTests(SimpleTestCase):
@override_settings(
ROOT_URLCONF="check_framework.urls.bad_function_based_error_handlers",
)
@ignore_warnings(category=RemovedInDjango61Warning)
def test_bad_function_based_handlers(self):
result = check_custom_error_handlers(None)
self.assertEqual(len(result), 4)
for code, num_params, error in zip([400, 403, 404, 500], [2, 2, 2, 1], result):
for code, error in zip([400, 403, 404, 500], result):
with self.subTest("handler{}".format(code)):
self.assertEqual(
error,
@ -254,9 +256,7 @@ class CheckCustomErrorHandlersTests(SimpleTestCase):
"The custom handler{} view 'check_framework.urls."
"bad_function_based_error_handlers.bad_handler' "
"does not take the correct number of arguments "
"(request{}).".format(
code, ", exception" if num_params == 2 else ""
),
"(request, exception).".format(code),
id="urls.E007",
),
)
@ -264,10 +264,11 @@ class CheckCustomErrorHandlersTests(SimpleTestCase):
@override_settings(
ROOT_URLCONF="check_framework.urls.bad_class_based_error_handlers",
)
@ignore_warnings(category=RemovedInDjango61Warning)
def test_bad_class_based_handlers(self):
result = check_custom_error_handlers(None)
self.assertEqual(len(result), 4)
for code, num_params, error in zip([400, 403, 404, 500], [2, 2, 2, 1], result):
for code, error in zip([400, 403, 404, 500], result):
with self.subTest("handler%s" % code):
self.assertEqual(
error,
@ -275,11 +276,7 @@ class CheckCustomErrorHandlersTests(SimpleTestCase):
"The custom handler%s view 'check_framework.urls."
"bad_class_based_error_handlers.HandlerView.as_view."
"<locals>.view' does not take the correct number of "
"arguments (request%s)."
% (
code,
", exception" if num_params == 2 else "",
),
"arguments (request, exception)." % code,
id="urls.E007",
),
)
@ -287,6 +284,7 @@ class CheckCustomErrorHandlersTests(SimpleTestCase):
@override_settings(
ROOT_URLCONF="check_framework.urls.bad_error_handlers_invalid_path"
)
@ignore_warnings(category=RemovedInDjango61Warning)
def test_bad_handlers_invalid_path(self):
result = check_custom_error_handlers(None)
paths = [
@ -318,6 +316,7 @@ class CheckCustomErrorHandlersTests(SimpleTestCase):
@override_settings(
ROOT_URLCONF="check_framework.urls.good_function_based_error_handlers",
)
@ignore_warnings(category=RemovedInDjango61Warning)
def test_good_function_based_handlers(self):
result = check_custom_error_handlers(None)
self.assertEqual(result, [])
@ -325,6 +324,7 @@ class CheckCustomErrorHandlersTests(SimpleTestCase):
@override_settings(
ROOT_URLCONF="check_framework.urls.good_class_based_error_handlers",
)
@ignore_warnings(category=RemovedInDjango61Warning)
def test_good_class_based_handlers(self):
result = check_custom_error_handlers(None)
self.assertEqual(result, [])

View File

@ -22,7 +22,8 @@ from django.middleware.csrf import (
get_token,
rotate_token,
)
from django.test import SimpleTestCase, override_settings
from django.test import SimpleTestCase, ignore_warnings, override_settings
from django.utils.deprecation import RemovedInDjango61Warning
from django.views.decorators.csrf import csrf_exempt, requires_csrf_token
from .views import (
@ -1477,6 +1478,7 @@ class CsrfViewMiddlewareUseSessionsTests(CsrfViewMiddlewareTestMixin, SimpleTest
@override_settings(ROOT_URLCONF="csrf_tests.csrf_token_error_handler_urls", DEBUG=False)
class CsrfInErrorHandlingViewsTests(CsrfFunctionTestMixin, SimpleTestCase):
@ignore_warnings(category=RemovedInDjango61Warning)
def test_csrf_token_on_404_stays_constant(self):
response = self.client.get("/does not exist/")
# The error handler returns status code 599.

View File

@ -1,7 +1,13 @@
from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse
from django.test import SimpleTestCase, modify_settings, override_settings
from django.test import (
SimpleTestCase,
ignore_warnings,
modify_settings,
override_settings,
)
from django.urls import path
from django.utils.deprecation import RemovedInDjango61Warning
class MiddlewareAccessingContent:
@ -38,6 +44,7 @@ handler403 = template_response_error_handler
}
)
class CustomErrorHandlerTests(SimpleTestCase):
@ignore_warnings(category=RemovedInDjango61Warning)
def test_handler_renders_template_response(self):
"""
BaseHandler should render TemplateResponse if necessary.

View File

@ -13,7 +13,13 @@ from django.contrib.auth.models import User
from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist
from django.http import HttpRequest, HttpResponsePermanentRedirect, HttpResponseRedirect
from django.shortcuts import redirect
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
from django.test import (
RequestFactory,
SimpleTestCase,
TestCase,
ignore_warnings,
override_settings,
)
from django.test.utils import override_script_prefix
from django.urls import (
NoReverseMatch,
@ -32,6 +38,7 @@ from django.urls import (
reverse_lazy,
)
from django.urls.resolvers import RegexPattern
from django.utils.deprecation import RemovedInDjango61Warning
from . import middleware, urlconf_outer, views
from .utils import URLObject
@ -1473,12 +1480,15 @@ class ErrorHandlerResolutionTests(SimpleTestCase):
self.resolver = URLResolver(RegexPattern(r"^$"), urlconf)
self.callable_resolver = URLResolver(RegexPattern(r"^$"), urlconf_callables)
@ignore_warnings(category=RemovedInDjango61Warning)
def test_named_handlers(self):
for code in [400, 403, 404, 500]:
with self.subTest(code=code):
self.assertEqual(self.resolver.resolve_error_handler(code), empty_view)
@ignore_warnings(category=RemovedInDjango61Warning)
def test_callable_handlers(self):
# After Django 6.1 removal, only test with 'error_handler' and one code.
for code in [400, 403, 404, 500]:
with self.subTest(code=code):
self.assertEqual(

View File

@ -4,7 +4,9 @@ from .views import empty_view
urlpatterns = []
# RemovedInDjango61Warning: the handler<...> variables can be removed
handler400 = empty_view
handler403 = empty_view
handler404 = empty_view
handler500 = empty_view
error_handler = empty_view

View File

@ -1,4 +1,4 @@
# A URLconf that doesn't define any handlerXXX.
# A URLconf that doesn't define any error handler view.
from django.urls import path
from .views import bad_view, empty_view