Table Of Contents

Custom Error Reporting

We’re not perfect. Sometimes we don’t catch every exception, even after piles of testing. This is why it’s a good idea to be notified if disaster strikes and one of your pages bombs out. This is especially a good idea during so-called “beta testing” (otherwise known as “releasing unfinished product”) since you can’t always rely on your users informing you of such problems.

There are several approaches to the problem, depending on whether you want to catch exceptions in a specific controller or method or application wide.

Method 1: Controller-wide Handlers with dispatch_error

You can set a controller method as the handler for specific exceptions using the errorhandling.dispatch_error decorator. The following example code shows how to catch any exception that occurs in the same controller and return a custom error page. You could also send and email to the programmer in this method, but this is not covered here. See the code from method two or check out the Python library reference for examples on how to send email from your code.

from peak.rules import when
from turbogears.errorhandling import dispatch_error

class YourController(controller.Controller):
    ...

    @when(dispatch_error, "tg_exceptions is not None")
    def unhandled_exception(self, tg_source, tg_errors, tg_exception, *args, **kw):
        try:
            # Spam me here, whatever
            programmer_notified = True
            pass
        except:
            programmer_notified = False
        return dict(tg_template='.templates.unhandled_exception',
                title="500 Internal error",
                programmer_notified=programmer_notified)

And a matching template (called unhandled_exception.html if you follow the code above), like this one:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
                      "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:py="http://genshi.edgewall.org/"
      xmlns:xi="http://www.w3.org/2001/XInclude">
<xi:include href="master.html" />

<head>
    <meta content="text/html; charset=UTF-8" http-equiv="content-type" py:replace="''"/>
    <title py:content="title">Error</title>
</head>

<body>

<h1>Oops! An error occurred</h1>

<p>Something went wonky and we weren't expecting it. Sorry about that.</p>

<p>This is commonly known as an &quot;Error 500  &quot; or
&quot;Internal Server Error&quot;. It means we messed up.</p>

<py:choose>

<p py:when="programmer_notified">A programmer has been notified
and will try to fix the problem as soon as possible.</p>

<p py:otherwise="">The problem was actually so bad that we couldn't even
send an e-mail to our team to sort the problem out!
If you keep getting this message, please send us an e-mail with
some information about what you were doing when this happened,
so we can try and smooth things over :-)</p>

</py:choose>

<p>Our sincerest apologies,</p>

<p><i>-- The Team</i></p>

</body>
</html>

Normally, you don’t really want to hide exceptions while you’re developing, so you may want to comment out the @when(dispatch_error, ...) decorator until people are going to start visiting the site while you’re not sitting over the server output.

Bear in mind that this method suppresses exception output in the log, so there is a chance that the exception could be lost forever.

Method 2: Method-specific Handlers with exception_handler

The controllers.exception_handler decorator can be used to set exception handlers for each method. See the code below for a usage example.

Readers should also check out the Error Handling page for more information on the exception_handler decorator.

from turbogears import expose, exception_handler

class Root(controllers.RootController):

    @expose()
    def error(self):
        return "An error occurred"

    @expose()
    @exception_handler(error, "ValueError")
    def index(self):
        raise ValueError

Method 3: Application-wide Catch-all with error_response

If you have multiple controllers, you may want to use the same error handler for all of them. For this, you need to make use custom CherryPy error handling. This can be enabled by adding the following code to your root controller:

if config.get('error_catcher.on', False):
    _cp_config = {
        'error_page.default': error_page,
        'request.error_response': handle_error
    }

Here is an example module implementing error_page and handle_error methods specified in the _cp_config above. You only need to add Genshi templates for the various error pages, and for the email to be sent out to the admin:

# -*- coding: utf-8 -*-

"""Functions for custom error pages and sending email notifications.

These error catcher functions can be used with TurboGears 1.5 (CherryPy 3.1).

To enable the custom error pages, you must add the following code to the root
controller and set ``error_catcher.on = True`` in the deployment configuration::

    # Hook in error handler if enabled in configuration
    if config.get('error_catcher.on', False):
        _cp_config = {
            'error_page.default': error_page,
            'request.error_response': handle_error
        }

When the error catcher is enabled and an HTML error (including an unhandled
exception) occurs in the controller, an error page is displayed using a
template whose name is looked up in the ``error_page_templates`` dictionary
by the HTML status code.

Currently, there are default templates for the status codes 401, 403 and 404,
called ``401_error``, ``403_error`` and ``404_error`` resp. and
``unhandled_exception`` for all other errors. The templates are searched in the
``templates`` sub-package of the application.

Also, if ``mail.on`` is ``True`` sends an email to the admin, when an error
occurs. No email is sent if the HTML status code is contained in the list set
by the option ``error_catcher.no_email_on``. The default is not to send emails
for 401, 403 and 404 errors.

For email sending to work, at least the configuration options
``error_catcher.sender_email`` and ``error_catcher.admin_email`` must be
set to valid email addresses.

See the docstring for the function ``send_exception_email`` for more email
related configuration information.

See http://docs.turbogears.org/1.5/ErrorReporting

"""

import datetime
import logging
import socket

import cherrypy
from cherrypy import __version__ as cherrypy_version
from turbogears import config, controllers, identity, util
from turbogears.release import version as turbogears_version

try:
    import turbomail
except ImportError:
    turbomail = None
    from email.mime.multipart import MIMEMultipart
    from email.mime.text import MIMEText
    from email.Utils import formatdate

__all__ = ['error_page', 'handle_error']

log = logging.getLogger("turbogears.controllers")


admin_group_name = 'admin'

error_codes = {
    None: u'Unknown Error',
    400: u'400 - Bad Request',
    401: u'401 - Unauthorized',
    403: u'403 - Forbidden',
    404: u'404 - Not Found',
    500: u'500 - Internal Server Error',
    501: u'501 - Not Implemented',
    502: u'502 - Bad Gateway',
}

error_page_templates = {
    None: '.templates.unhandled_exception',
    401: '.templates.401_error',
    403: '.templates.403_error',
    404: '.templates.404_error',
}

error_mail_templates = {
    None: '.templates.email_unhandled_exception',
}


def format_request_info(req):
    """Return string with formatted info about important request properties."""
    data = []  # headers are reported separately
    for attr in ['remoteHost', 'remoteAddr', 'remotePort',
            'requestLine', 'body', 'protocol', 'method',
            'query_string', 'browser_url', 'originalPath']:
        value = getattr(req, attr, None)
        if value:
            data.append(u'%s: %r' % (attr, value))
    return u'\n'.join(data)


def add_webpath(req):
    """Correct browser_url by adding the webpath."""
    # correct browser_url by adding the webpath
    webpath = config.get('server.webpath')
    if webpath and webpath != '/':
        base = getattr(req, 'base', None)
        path = getattr(req, 'path', None)
        if (base and not base.endswith(webpath)
                and path and not path.startswith(webpath)):
            req.base += webpath


def format_request_headers(req):
    """Return string with formatted request headers."""
    add_webpath(req)
    data = []
    for key, value in req.header_list:
        data.append(u'%s: %r' % (key, value))
    return u'\n'.join(data)


def format_user_info(user):
    """Return string with formatted info about request user."""
    data = []
    if user:
        for key in ['user_id', 'user_name', 'display_name', 'email_address']:
            value = getattr(user, key, None)
            if value:
                data.append(u'%s: %r' % (key, value))
    else:
        data.append(u'Anonymous user')
    return u'\n'.join(data)


def error_page(status, message, traceback, version):
    """Error page for any kind of error."""
    try:
        if status:
            if not isinstance(status, int):
                try:
                    status = int(status.split(None, 1)[0])
                except ValueError:
                    status = 500
        else:
            status = 500
        error_msg = get_error_message(status)
        if not traceback and (config.get(
                'error_catcher.show_tracebacks', True)
                or cherrypy.request.show_tracebacks):
            traceback = cherrypy._cperror.format_exc()
        if not traceback:
            traceback = "No traceback available (check the log file)."
        if not version:
            version = cherrypy_version
        url = cherrypy.request.request_line
        log.exception("CherryPy %s error (%s) for request '%s'", status,
            message, url)
        url = url.split(None, 2)[1]
        webpath = config.get('server.webpath', '')
        if not url.startswith(webpath):
            url = webpath + url
        if status == 404:
            path = url.split('?', 1)[0]
            mailto_redirect = config.get('error_catcher.mailto_redirect')
            if mailto_redirect and '/mailto:' in path:
                # some bots generate such false links from mailto: addresses
                cherrypy.response.status = 302
                cherrypy.response.headers['Content-Length'] = 0
                cherrypy.response.headers['Location'] = mailto_redirect
                return
        try:
            is_admin = identity.in_group(admin_group_name)
            user_info = format_user_info(identity.current.user)
        except Exception, exc:
            is_admin = False
            user_info = 'Error getting user info: ' + str(exc)
        # get request info
        request = cherrypy.request
        data = dict(
            cherrypy_version=version,
            error_msg=error_msg,
            header_info=format_request_headers(request),
            is_admin=is_admin,
            message=message,
            request_info=format_request_info(request),
            server=request.headers.get('host', socket.getfqdn()),
            status=status,
            timestamp=datetime.datetime.now(),
            traceback=traceback,
            url=url,
            user_info=user_info,
            turbogears_version=turbogears_version
        )
        no_email_on = config.get(
            'error_catcher.no_email_on', (401, 403, 404))
        if config.get('mail.on') and status not in no_email_on:
            try:
                send_exception_email(**data)
                data['email_sent'] = True
            except Exception, err:
                log.exception('Error email failed: %s', err)
                data['email_sent'] = False
        else:
            data['email_sent'] = False
        return get_error_page(**data)
    # don't catch SystemExit
    except Exception, err:
        log.exception('Error handler failed: %s', err)
        return "Unrecoverable error %s in the server - %s" % (
            status, message)


def handle_error():
    """Error handling of unanticipated errors (error 500)."""
    status = 500
    cherrypy.response.status = status
    cherrypy.response.body = error_page(status, None, None, None)


def get_error_page(**data):
    """Render error page using template from error_templates."""
    template = error_page_templates.get(data['status'],
        error_page_templates.get(None))
    return render_error_template(template, data=data)


def send_exception_email(**data):
    """Send an email with the error info to the admin.

    Uses TurboMail if installed and activated, otherwise tries to send
    email with the ``smtplib`` module. The SMTP settings can be configured
    with the following settings:

    ``mail.smtp.server`` - Mail server to connect to (default 'localhost').
    ``mail.username``    - User name for SMTP authentication.
                           If the value is unset or evaluates to False
                           no SMTP login is performed.
    ``mail.password``    - Password for SMTP authentication.
                           May be an empty string.

    See also the module docstring for information on setting the
    sender and recipient address.

    """
    sender_email = config.get('error_catcher.sender_email')
    admin_email = config.get('error_catcher.admin_email')
    if not sender_email or not admin_email:
        msg = ('Configuration error: could not send error notification'
          ' because sender and/or admin email address is not set.')
        log.exception(msg)
        raise RuntimeError(msg)
    template = error_mail_templates.get(data['status'],
        error_mail_templates.get(None))
    subject = 'Error %(status)s on server %(server)s' % data
    body = render_error_template(template, 'plain', 'text/plain', data)
    try:
        if turbomail:
            msg = turbomail.Message(
                sender_email, admin_email, subject, plain=body)
            if not msg.send():
                raise RuntimeError('TurboMail could not deliver the message.')
        else:
            msg = MIMEMultipart()
            msg['From'] = sender_email
            msg['To'] = admin_email
            msg['Date'] = formatdate(localtime=True)
            msg['Subject'] = subject
            msg.attach(MIMEText(body))
            send_email_by_smtp(sender_email, admin_email, msg.as_string())
    except Exception, err:
        log.exception('Could not send error notification: %s', err)
        raise


def send_email_by_smtp(from_addr, to_addr, message):
    """Send email via SMTP."""
    import smtplib
    server = config.get('mail.smtp.server', 'localhost')
    smtp = smtplib.SMTP(server)
    username = config.get('mail.username')
    password = config.get('mail.password')
    if username and password is not None:
        smtp.login(username, password)
    smtp.sendmail(from_addr, to_addr, message)
    smtp.close()


def get_error_message(status, default=None):
    """Return string error for HTTP status code."""
    return error_codes.get(status, default or error_codes[None])


def render_error_template(template, format='html',
        content_type='text/html', data={}):
    if ':' in template:
        prefix, template = template.split(':', 1)
        prefix += ':'
    else:
        prefix = ''
    if template.startswith('.'):
        package = util.get_package_name()
    else:
        package = ''
    template = "%s%s%s" % (prefix, package, template)
    return controllers._process_output(data, template, format,
        content_type, None)

The corresponding email template could look like this, for instance:

<mail>
A ${status} error occurred on server ${server} at ${timestamp}.

---------- Request ----------------------------------

${url}

---------- Exception traceback ------------------

${traceback}

---------- Request information ------------------

${request_info}

---------- Request headers ----------------------

${header_info}

---------- User information ------------------

${user_info}

</mail>

References

Custom Error Pages on the TurboGears mailing list.

Exception Reporting on the TurboGears mailing list.

Defining an Error Handler for a particular exception on the TurboGears mailing list.