JsonableError: Optionally carry error codes and structured data.

This provides the main infrastructure for fixing #5598.  From here,
it's a matter of on the one hand upgrading exception handlers -- the
many except-blocks in the codebase that look for JsonableError -- to
look beyond the string `msg` and pass on the machine-readable full
error information to their various downstream recipients, and on the
other hand adjusting places where we raise errors to take advantage
of this mechanism to give the errors structured details.

In an ideal future, I think all exception handlers that look (or
should look) for a JsonableError would use its contents in structured
form, never mentioning `msg`; but the majority of error sites might
continue to just instantiate JsonableError with a string message.  The
latter is the simplest thing to do, and probably most error types will
never have code looking for them specifically.

Because the new API refactors the `to_json_error_msg` method which was
designed for subclasses to override, update the 4 subclasses that did
so to take full advantage of the new API instead.
This commit is contained in:
Greg Price 2017-07-20 17:17:28 -07:00 committed by Tim Abbott
parent 4837d4178d
commit 9faa44af60
7 changed files with 165 additions and 30 deletions

View File

@ -1,23 +1,143 @@
from __future__ import absolute_import from __future__ import absolute_import
from typing import Text from enum import Enum
from typing import Any, Dict, List, Optional, Text, Type
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
class AbstractEnum(Enum):
'''An enumeration whose members are used strictly for their names.'''
def __new__(cls):
# type: (Type[AbstractEnum]) -> AbstractEnum
obj = object.__new__(cls)
obj._value_ = len(cls.__members__) + 1 # type: ignore # typeshed enum missing Enum.__members__
return obj
# Override all the `Enum` methods that use `_value_`.
def __repr__(self):
# type: () -> str
return str(self)
def value(self):
# type: () -> None
assert False
def __reduce_ex__(self, proto):
# type: (int) -> None
assert False
class ErrorCode(AbstractEnum):
BAD_REQUEST = () # Generic name, from the name of HTTP 400.
REQUEST_VARIABLE_MISSING = ()
REQUEST_VARIABLE_INVALID = ()
BAD_NARROW = ()
UNAUTHORIZED_PRINCIPAL = ()
class JsonableError(Exception): class JsonableError(Exception):
msg = None # type: Text '''A standardized error format we can turn into a nice JSON HTTP response.
This class can be invoked in several ways.
* Easiest, but completely machine-unreadable:
raise JsonableError(_("No such widget: {}").format(widget_name))
The message may be passed through to clients and shown to a user,
so translation is required. Because the text will vary depending
on the user's language, it's not possible for code to distinguish
this error from others in a non-buggy way.
* Partially machine-readable, with an error code:
raise JsonableError(_("No such widget: {}").format(widget_name),
ErrorCode.NO_SUCH_WIDGET)
Now the error's `code` attribute can be used, both in server
and client code, to identify this type of error. The data
(here, the widget name) is still embedded inside a translated
string, and can't be accessed by code.
* Fully machine-readable, with an error code and structured data:
class NoSuchWidgetError(JsonableError):
code = ErrorCode.NO_SUCH_WIDGET
data_fields = ['widget_name']
def __init__(self, widget_name):
self.widget_name = widget_name # type: str
@staticmethod
def msg_format():
return _("No such widget: {widget_name}")
raise NoSuchWidgetError(widget_name)
Now both server and client code see a `widget_name` attribute.
Subclasses may also override `http_status_code`.
'''
# Override this in subclasses, or just pass a `code` argument
# to the JsonableError constructor.
code = ErrorCode.BAD_REQUEST # type: ErrorCode
# Override this in subclasses if providing structured data.
data_fields = [] # type: List[str]
# Optionally override this in subclasses to return a different HTTP status,
# like 403 or 404.
http_status_code = 400 # type: int http_status_code = 400 # type: int
def __init__(self, msg): def __init__(self, msg, code=None):
# type: (Text) -> None # type: (Text, Optional[ErrorCode]) -> None
self.msg = msg if code is not None:
self.code = code
# `_msg` is an implementation detail of `JsonableError` itself.
self._msg = msg # type: Text
@staticmethod
def msg_format():
# type: () -> Text
'''Override in subclasses. Gets the items in `data_fields` as format args.
This should return (a translation of) a string literal.
The reason it's not simply a class attribute is to allow
translation to work.
'''
# Secretly this gets one more format arg not in `data_fields`: `_msg`.
# That's for the sake of the `JsonableError` base logic itself, for
# the simplest form of use where we just get a plain message string
# at construction time.
return '{_msg}'
#
# Infrastructure -- not intended to be overridden in subclasses.
#
@property
def msg(self):
# type: () -> Text
format_data = dict(((f, getattr(self, f)) for f in self.data_fields),
_msg=getattr(self, '_msg', None))
return self.msg_format().format(**format_data)
@property
def data(self):
# type: () -> Dict[str, Any]
return dict(((f, getattr(self, f)) for f in self.data_fields),
code=self.code.name)
def to_json(self):
# type: () -> Dict[str, Any]
d = {'result': 'error', 'msg': self.msg}
d.update(self.data)
return d
def __str__(self): def __str__(self):
# type: () -> str # type: () -> str
return self.to_json_error_msg() # type: ignore # remove once py3-only return self.msg # type: ignore # remove once py3-only
def to_json_error_msg(self):
# type: () -> Text
return self.msg
class RateLimited(PermissionDenied): class RateLimited(PermissionDenied):
def __init__(self, msg=""): def __init__(self, msg=""):

View File

@ -8,23 +8,30 @@ from six.moves import zip
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError, ErrorCode
class RequestVariableMissingError(JsonableError): class RequestVariableMissingError(JsonableError):
def __init__(self, var_name): code = ErrorCode.REQUEST_VARIABLE_MISSING
self.var_name = var_name data_fields = ['var_name']
def to_json_error_msg(self): def __init__(self, var_name):
return _("Missing '%s' argument") % (self.var_name,) self.var_name = var_name # type: str
@staticmethod
def msg_format():
return _("Missing '{var_name}' argument")
class RequestVariableConversionError(JsonableError): class RequestVariableConversionError(JsonableError):
code = ErrorCode.REQUEST_VARIABLE_INVALID
data_fields = ['var_name', 'bad_value']
def __init__(self, var_name, bad_value): def __init__(self, var_name, bad_value):
self.var_name = var_name self.var_name = var_name # type: str
self.bad_value = bad_value self.bad_value = bad_value
def to_json_error_msg(self): @staticmethod
return (_("Bad value for '%(var_name)s': %(value)s") % def msg_format():
{'var_name': self.var_name, 'value': self.bad_value}) return _("Bad value for '{var_name}': {bad_value}")
# Used in conjunction with @has_request_variables, below # Used in conjunction with @has_request_variables, below
class REQ(object): class REQ(object):

View File

@ -286,7 +286,7 @@ class JsonErrorHandler(MiddlewareMixin):
def process_exception(self, request, exception): def process_exception(self, request, exception):
# type: (HttpRequest, Exception) -> Optional[HttpResponse] # type: (HttpRequest, Exception) -> Optional[HttpResponse]
if isinstance(exception, JsonableError): if isinstance(exception, JsonableError):
return json_error(exception.to_json_error_msg(), status=exception.http_status_code) return json_error(exception.msg, status=exception.http_status_code)
if request.error_format == "JSON": if request.error_format == "JSON":
logging.error(traceback.format_exc()) logging.error(traceback.format_exc())
return json_error(_("Internal server error"), status=500) return json_error(_("Internal server error"), status=500)

View File

@ -86,7 +86,7 @@ class JsonTranslationTestCase(ZulipTestCase):
@mock.patch('zerver.lib.request._') @mock.patch('zerver.lib.request._')
def test_json_error(self, mock_gettext): def test_json_error(self, mock_gettext):
# type: (Any) -> None # type: (Any) -> None
dummy_value = "this arg is bad: '%s' (translated to German)" dummy_value = "this arg is bad: '{var_name}' (translated to German)"
mock_gettext.return_value = dummy_value mock_gettext.return_value = dummy_value
email = self.example_email('hamlet') email = self.example_email('hamlet')

View File

@ -539,7 +539,7 @@ def fetch_events(query):
logging.info("Disconnected handler for queue %s (%s/%s)" % (queue_id, user_profile_email, logging.info("Disconnected handler for queue %s (%s/%s)" % (queue_id, user_profile_email,
client_type_name)) client_type_name))
except JsonableError as e: except JsonableError as e:
return dict(type="error", handler_id=handler_id, message=e.to_json_error_msg()) return dict(type="error", handler_id=handler_id, message=e.msg)
client.connect_handler(handler_id, client_type_name) client.connect_handler(handler_id, client_type_name)
return dict(type="async") return dict(type="async")

View File

@ -10,9 +10,10 @@ from django.http import HttpRequest, HttpResponse
from typing import Dict, List, Set, Text, Any, AnyStr, Callable, Iterable, \ from typing import Dict, List, Set, Text, Any, AnyStr, Callable, Iterable, \
Optional, Tuple, Union Optional, Tuple, Union
from zerver.lib.str_utils import force_text from zerver.lib.str_utils import force_text
from zerver.lib.exceptions import JsonableError, ErrorCode
from zerver.lib.html_diff import highlight_html_differences from zerver.lib.html_diff import highlight_html_differences
from zerver.decorator import authenticated_json_post_view, has_request_variables, \ from zerver.decorator import authenticated_json_post_view, has_request_variables, \
REQ, JsonableError, to_non_negative_int REQ, to_non_negative_int
from django.utils.html import escape as escape_html from django.utils.html import escape as escape_html
from zerver.lib import bugdown from zerver.lib import bugdown
from zerver.lib.actions import recipient_for_emails, do_update_message_flags, \ from zerver.lib.actions import recipient_for_emails, do_update_message_flags, \
@ -56,13 +57,17 @@ import six
LARGER_THAN_MAX_MESSAGE_ID = 10000000000000000 LARGER_THAN_MAX_MESSAGE_ID = 10000000000000000
class BadNarrowOperator(JsonableError): class BadNarrowOperator(JsonableError):
code = ErrorCode.BAD_NARROW
data_fields = ['desc']
def __init__(self, desc): def __init__(self, desc):
# type: (str) -> None # type: (str) -> None
self.desc = desc self.desc = desc # type: str
def to_json_error_msg(self): @staticmethod
def msg_format():
# type: () -> str # type: () -> str
return _('Invalid narrow operator: {}').format(self.desc) return _('Invalid narrow operator: {desc}')
Query = Any # TODO: Should be Select, but sqlalchemy stubs are busted Query = Any # TODO: Should be Select, but sqlalchemy stubs are busted
ConditionTransform = Any # TODO: should be Callable[[ColumnElement], ColumnElement], but sqlalchemy stubs are busted ConditionTransform = Any # TODO: should be Callable[[ColumnElement], ColumnElement], but sqlalchemy stubs are busted

View File

@ -6,7 +6,8 @@ from django.conf import settings
from django.db import transaction from django.db import transaction
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from zerver.lib.request import JsonableError, REQ, has_request_variables from zerver.lib.exceptions import JsonableError, ErrorCode
from zerver.lib.request import REQ, has_request_variables
from zerver.decorator import authenticated_json_post_view, \ from zerver.decorator import authenticated_json_post_view, \
authenticated_json_view, require_realm_admin, to_non_negative_int authenticated_json_view, require_realm_admin, to_non_negative_int
from zerver.lib.actions import bulk_remove_subscriptions, \ from zerver.lib.actions import bulk_remove_subscriptions, \
@ -35,16 +36,18 @@ from six.moves import urllib
import six import six
class PrincipalError(JsonableError): class PrincipalError(JsonableError):
code = ErrorCode.UNAUTHORIZED_PRINCIPAL
data_fields = ['principal']
http_status_code = 403 http_status_code = 403
def __init__(self, principal): def __init__(self, principal):
# type: (Text) -> None # type: (Text) -> None
self.principal = principal # type: Text self.principal = principal # type: Text
def to_json_error_msg(self): @staticmethod
def msg_format():
# type: () -> Text # type: () -> Text
return ("User not authorized to execute queries on behalf of '%s'" return _("User not authorized to execute queries on behalf of '{principal}'")
% (self.principal,))
def principal_to_user_profile(agent, principal): def principal_to_user_profile(agent, principal):
# type: (UserProfile, Text) -> UserProfile # type: (UserProfile, Text) -> UserProfile