RFC-7807 implementation for Tornado

build coverage docs download license source

This library provides a version of tornado.web.RequestHandler.send_error that speaks application/problem+json instead of HTML. The easiest way to use this library is to inherit from problemdetails.ErrorWriter and raise problemdetails.Problem exceptions instead of HTTPError.

class MyHandler(problemdetails.ErrorWriter, web.RequestHandler):
   def get(self):
      if not self.do_something_hard():
         raise problemdetails.Problem(status_code=500,
                                      title='Failed to do_something_hard')
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
   "status": 500,
   "title": "Failed to do_something_hard",
   "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1"
}

You can easily construct more substantial response documents by passing additional keyword parameters to the problemdetails.Problem initializer. They become top-level properties in the response document.

You can also call send_error directly and produce a response docuemnt. The following snippet produces the same output as the previous snippet.

class MyHandler(problemdetails.ErrorWriter, web.RequestHandler):
   def get(self):
      try:
         self.do_something_hard()
      except SomeException as error:
         self.send_error(500, title="Failed to do_something_hard")

The interface of tornado.web.RequestHandler.send_error is less expressive since keyword parameters may be swallowed by intervening code. The only parameters that are recognized are: instance, title, and type. Use the exception-based interface for more substantial documents.

Examples

You can run any of the examples in this section by preparing a Python environment:

$ python3 -mvenv env
$ env/bin/pip install -qe .
$ env/bin/python examples/httpbin.py
D - asyncio: Using selector: KqueueSelector
I - root: listening on http://127.0.0.1:8000
I - root: GET http://127.0.0.1:8000/?status=419

The examples run the application in debug mode so changing the source code results in the application reloading immediately. Feel free to play with the examples.

httpbin.org

This example sends a request to httpbin.org that generates a HTTP failure. The failure is caught as a tornado.web.HTTPError instance and translated into a problemdetails.Problem instance. You can run this example with python examples/httpbin.py and send requests to http://localhost:8000/?status=500.

    @gen.coroutine
    def get(self):
        logger = logging.getLogger('HttpBinHandler')
        url = 'http://httpbin.org/status/{0}'.format(
            self.get_query_argument('status', '500'))
        logger.info('retrieving %s', url)

        client = httpclient.AsyncHTTPClient()
        try:
            response = yield client.fetch(url)
            self.add_header('Content-Type', 'application/json')
            self.write(response.body)
        except httpclient.HTTPError as error:
            raise problemdetails.Problem(
                status_code=error.code,
                httpbin_headers=dict(error.response.headers.items()))

RFC-7807 examples

This example includes two endpoints that return the examples from RFC 7807. The first example returns the 403 credit error from section 3. This example exemplifies raising an exception where the failure is detected and finishing the request processing by not catching the exception. The raised exception is fairly complex but constructed from literals.

class AccountHandler(problemdetails.ErrorWriter, web.RequestHandler):
    def post(self, account):
        price = float(self.get_query_argument('price'))
        self.verify_funds(account, price)
        self.buy_thing()

    def verify_funds(self, account, price):
        balance = self.get_balance(account)
        if price > balance:
            raise problemdetails.Problem(
                status_code=403,
                title='You do not have enough credit.',
                detail=(f'Your current balance is {balance}, but that '
                        f'costs {price}'),
                instance=self.reverse_url('account-handler', account),
                balance=balance,
                accounts=self.lookup_accounts(account),
                type='https://example.com/probs/out-of-credit',
            )

    def get_balance(self, account):
        return 30.0  # TODO look up balance

    def lookup_accounts(self, primary_account):
        related = [12345, 67890]  # TODO lookup related accounts
        return [self.reverse_url('account-handler', aid) for aid in related]

    def buy_thing(self):
        pass

The second example returns the more complex 400 invalid parameter error from section 3. The errors are accumulated into a list and raised from the top-level after the parameters are extracted. This is a different approach where all of the error handling is explicit and at the top-level.

class ValidationError(problemdetails.ErrorWriter, web.RequestHandler):
    def initialize(self):
        super(ValidationError, self).initialize()
        self.errors = []

    def get(self):
        age = self._extract_age()
        color = self._extract_color()
        if self.errors:
            raise problemdetails.Problem(**{
                'status_code': 400,
                'type': 'https://example.net/validation-error',
                'invalid-params': self.errors,
            })
        self.write({'age': age, 'color': color})

    def _record_error(self, field, error):
        self.errors.append({'name': field, 'reason': error})

    def _extract_age(self):
        try:
            age = self.get_query_argument('age')
        except web.MissingArgumentError:
            self._record_error('age', 'is required')
        else:
            try:
                age = int(age)
                if age > 0:
                    return age
                self._record_error('age', 'must be a positive integer')
            except ValueError:
                self._record_error('age', 'must be a integer')
        return None

    def _extract_color(self):
        try:
            color = self.get_query_argument('color')
        except web.MissingArgumentError:
            self._record_error('color', 'is required')
        else:
            if color in ('blue', 'green', 'red'):
                return color
            self._record_error('color', "must be 'green', 'red' or 'blue'")
        return None

Reference

class problemdetails.ErrorWriter(application: tornado.web.Application, request: tornado.httputil.HTTPServerRequest, **kwargs)[source]

Render application/problem+json in write_error

Include this class in the base class list to return errors as application/problem+json documents instead of HTML.

PROBLEM_DETAILS_MIME_TYPE

This class-level attribute controls the content type used by write_error. Due to the way that write_error is called, it is necessary to configure this. Setting the content type header before calling send_error() DOES NOT work as expected.

If you need to change the response content type, change this attribute at the class-level.

write_error(status_code, **kwargs)[source]

Render application/problem+json documents instead of HTML.

Parameters
  • status_code (int) – HTTP status code that we returned

  • detail (str) – optional detail field to include in the error document. This field is omitted by default.

  • instance (str) – optional instance to include in the error document. This field is omitted by default.

  • title (str) – optional title to include in the error document. THis field is omitted by default.

  • type (str) – optional type field to include in the error document. This field defaults to a link to the official HTTP specification of status_code if omitted and status_code is a standard code.

See RFC 7807#section-3.1 for a description of each optional field.

class problemdetails.Problem(status_code, log_message=None, *args, **kwargs)[source]

An exception that will be translated into a json document.

Parameters
  • status_code (int) – HTTP status code to return

  • log_message (str) – optional log message that is passed to the tornado.web.HTTPError initializer

  • args – parameters that are passed to log_message in the HTTPError initializer.

  • reason (str) – optional reason phrase to use in the HTTP response line. This value is NOT included in the response document.

  • kwargs – additional keyword parameters are included in the response document

problemdetails.ErrorWriter.write_error() recognizes this exception type and renders document as the problem+json result. The status property is set to status_code and the type property will be set by write_error unless it is explicitly set.

document

The keyword parameters are collected into this dict and rendered as the response document

Mapping of HTTP status code to type link.

This table maps HTTP status codes to the IANA registered specification for the code. You can add additional links or replace ones that are here as you see fit. The error writer uses this table to generate the default type link in responses.

Release History

0.0.5 (4 Apr 2019)

  • Make content type configurable.

0.0.4 (2 Apr 2019)

  • Made compatible with Python 2.7, Tornado 4.4, Tornado 4.5, & Tornado 5.

0.0.2 (1 Apr 2019)

0.0.1 (31 Mar 2019)

  • Initial alpha release containing a very simple implementation.