Repository: plone/Products.CMFPlone

Issue:
Plone does not redirect ajax calls
## BUG/PROBLEM REPORT (OR OTHER COMMON ISSUE)

### What I did:

1. Create a document with id "a"
2. Rename it to "b"
3. Run `curl -sI 'http://localhost:8080/Plone/a/@@getVocabulary'  -H 'Authorization: Basic YOURB64'  -H 'Accept: text/html'`: you will get a redirect (301) + some not useful text ignored by the browser
4. Run `curl -sI 'http://localhost:8080/Plone/a/@@getVocabulary'  -H 'Authorization: Basic YOURB64'  -H 'Accept: application/json'` you will get a not found json text (status 404)

Note that the two commands differ only for the `Accept` header.

### What I expect to happen:

I expect a 301 when I browse to http://localhost:8080/Plone/a/@@getVocabulary for both the values of the Accept header



Discussion:
The reason for this inconsistency is here https://github.com/plone/Products.CMFPlone/blob/fc45ebf97b5f10aa690e405c9acd5cfde65a5616/Products/CMFPlone/browser/exceptions.py#L30-L34
and here:
https://github.com/plone/Products.CMFPlone/blob/fc45ebf97b5f10aa690e405c9acd5cfde65a5616/Products/CMFPlone/browser/templates/error_message.pt#L19-L21

IMHO the redirection attempt should be done directly in the Python code (and not on the template) and the page should return nothing.

E.g. adding this to the `ExceptionView.__call__` method:
```
    def __call__(self):
        exception = self.context
        error_type = exception.__class__.__name__
        if error_type == "NotFound" and self.attempt_redirect():
            # attempt_redirect returns True and sets a location header so that the
            # browser gets a redirect
            return
        ...
```
we will get something like:
```
[ale@emily omelette]$ curl -sI 'http://localhost:8080/Plone/a/@@getVocabulary' -H 'Authorization: Basic ...' -H 'Accept: text/html'
HTTP/1.1 301 Moved Permanently
Location: http://localhost:8080/Plone/b/@@getVocabulary
[ale@emily omelette]$ curl -sI 'http://localhost:8080/Plone/a/@@getVocabulary' -H 'Authorization: Basic ...' -H 'Accept: text/json'
HTTP/1.1 301 Moved Permanently
Location: http://localhost:8080/Plone/b/@@getVocabulary
```
I will make a PR

Resolving patch:
diff --git a/Products/CMFPlone/browser/exceptions.py b/Products/CMFPlone/browser/exceptions.py
--- a/Products/CMFPlone/browser/exceptions.py
+++ b/Products/CMFPlone/browser/exceptions.py
@@ -1,8 +1,10 @@
 # -*- coding: utf-8 -*-
 from AccessControl import getSecurityManager
+from plone.memoize.view import memoize
 from Products.Five import BrowserView
 from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
 from zExceptions.ExceptionFormatter import format_exception
+from zope.component import getMultiAdapter
 
 import json
 import sys
@@ -15,12 +17,24 @@ def is_manager(self):
         return getSecurityManager().checkPermission(
             'Manage portal', self.context)
 
+    @property
+    @memoize
+    def plone_redirector_view(self):
+        return getMultiAdapter(
+            (self.__parent__, self.request), name="plone_redirector_view"
+        )
+
     def __call__(self):
         exception = self.context
+        error_type = exception.__class__.__name__
+        if error_type == "NotFound" and self.plone_redirector_view.attempt_redirect():
+            # if a redirect is possible attempt_redirect returns True
+            # and sets the proper location header
+            return
+
         self.context = self.__parent__
         request = self.request
 
-        error_type = exception.__class__.__name__
         exc_type, value, traceback = sys.exc_info()
         error_tb = ''.join(
             format_exception(exc_type, value, traceback, as_html=False))


Test patch:
diff --git a/Products/CMFPlone/tests/test_error_message.py b/Products/CMFPlone/tests/test_error_message.py
new file mode 100644
--- /dev/null
+++ b/Products/CMFPlone/tests/test_error_message.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+from plone.app.redirector.interfaces import IRedirectionStorage
+from Products.CMFPlone.testing import PRODUCTS_CMFPLONE_INTEGRATION_TESTING
+from zExceptions import HTTPNotImplemented
+from zExceptions import NotFound
+from zope.component import getMultiAdapter
+from zope.component import getUtility
+
+import unittest
+
+
+class TestErrorMessage(unittest.TestCase):
+
+    layer = PRODUCTS_CMFPLONE_INTEGRATION_TESTING
+
+    def setUp(self):
+        self.request = self.layer["request"]
+
+    def test_error_message_3xx(self):
+        # Prepare a fake request
+        request = self.request.clone()
+        request.other["URL"] = "http://nohost/foo"
+        # prepare the redirection tool
+        storage = getUtility(IRedirectionStorage)
+        storage.add("/foo", "/bar")
+
+        try:
+            raise NotFound
+        except NotFound as exc:
+            view = getMultiAdapter((exc, request), name="index.html")
+            self.assertIsNone(view())
+            self.assertEqual(
+                view.request.response.getHeader("Location"), "http://nohost/bar"
+            )
+            self.assertEqual(view.request.response.getStatus(), 302)
+
+    def test_error_message_4xx(self):
+        try:
+            raise NotFound
+        except NotFound as exc:
+            view = getMultiAdapter((exc, self.request.clone()), name="index.html")
+            self.assertEqual('{"error_type": "NotFound"}', view())
+            self.assertEqual(view.request.response.getStatus(), 404)
+
+    def test_error_message_5xx(self):
+        class CustomException(Exception):
+            pass
+
+        try:
+            raise CustomException
+        except CustomException as exc:
+            view = getMultiAdapter((exc, self.request.clone()), name="index.html")
+            self.assertEqual('{"error_type": "CustomException"}', view())
+
+            # Check the response page (served only when accepting html)
+            view.request.environ["HTTP_ACCEPT"] = "text/html"
+            self.assertIn("Error", view())
+            self.assertEqual(view.request.response.getStatus(), 500)
+
+        # Zope might set a more specific status based on the exception
+        try:
+            raise KeyError
+        except KeyError as exc:
+            view = getMultiAdapter((exc, self.request.clone()), name="index.html")
+            view()
+            self.assertEqual('{"error_type": "KeyError"}', view())
+
+            # Check the response page (served only when accepting html)
+            view.request.environ["HTTP_ACCEPT"] = "text/html"
+            self.assertIn("Error", view())
+            self.assertEqual(view.request.response.getStatus(), 503)