summaryrefslogtreecommitdiff
path: root/flup/middleware/error.py
blob: 637e8428496d1301f519e404a69429f23862fbc2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
# Copyright (c) 2005 Allan Saddi <allan@saddi.com>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
#
# $Id$

__author__ = 'Allan Saddi <allan@saddi.com>'
__version__ = '$Revision$'

import sys
import os
import traceback
import time
from email.Message import Message
from email.MIMEMultipart import MIMEMultipart
from email.MIMEText import MIMEText
import smtplib

try:
    import thread
except ImportError:
    import dummy_thread as thread

__all__ = ['ErrorMiddleware']

def _wrapIterator(appIter, errorMiddleware, environ, start_response):
    """
    Wrapper around the application's iterator which catches any unhandled
    exceptions. Forwards close() and __len__ to the application iterator,
    if necessary.
    """
    class metaIterWrapper(type):
        def __init__(cls, name, bases, clsdict):
            super(metaIterWrapper, cls).__init__(name, bases, clsdict)
            if hasattr(appIter, '__len__'):
                cls.__len__ = appIter.__len__

    class iterWrapper(object):
        __metaclass__ = metaIterWrapper
        def __init__(self):
            self._next = iter(appIter).next
            if hasattr(appIter, 'close'):
                self.close = appIter.close

        def __iter__(self):
            return self

        def next(self):
            try:
                return self._next()
            except StopIteration:
                raise
            except:
                errorMiddleware.exceptionHandler(environ)

                # I'm not sure I like this next part.
                try:
                    errorIter = errorMiddleware.displayErrorPage(environ,
                                                                 start_response)
                except:
                    # Headers already sent, what can be done?
                    raise
                else:
                    # The exception occurred early enough for start_response()
                    # to succeed. Swap iterators!
                    self._next = iter(errorIter).next
                    return self._next()

    return iterWrapper()

class ErrorMiddleware(object):
    """
    Middleware that catches any unhandled exceptions from the application.
    Displays a (static) error page to the user while emailing details about
    the exception to an administrator.
    """
    def __init__(self, application, adminAddress,
                 fromAddress='wsgiapp',
                 smtpHost='localhost',

                 applicationName=None,

                 errorPageMimeType='text/html',
                 errorPage=None,
                 errorPageFile='error.html',

                 emailInterval=15,
                 intervalCheckFile='errorEmailCheck',

                 debug=False):
        """
        Explanation of parameters:

        application - WSGI application.

        adminAddress - Email address of administrator.
        fromAddress - Email address that the error email should appear to
          originate from. By default 'wsgiapp@hostname.of.server'.
        smtpHost - SMTP email server, through which to send the email.

        applicationName - Name of your WSGI application, to help differentiate
          it from other applications in email. By default, this is the Python
          name of the application object. (You should explicitly set this
          if you use other middleware components, otherwise the name
          deduced will probably be that of a middleware component.)

        errorPageMimeType - MIME type of the static error page. 'text/html'
          by default.
        errorPage - String representing the body of the static error page.
          If None (the default), errorPageFile must point to an existing file.
        errorPageFile - File from which to take the static error page (may
          be relative to current directory or an absolute filename).

        emailInterval - Minimum number of minutes between error mailings,
          to prevent the administrator's mailbox from filling up.
        intervalCheckFile - When running in one-shot mode (as determined by
          the 'wsgi.run_once' environment variable), this file is used to
          keep track of the last time an email was sent. May be relative
          (to the current directory) or an absolute filename.

        debug - If True, will attempt to display the traceback as a webpage.
          No email is sent. If False (the default), the static error page is
          displayed and the error email is sent, if necessary.
        """
        self._application = application

        self._adminAddress = adminAddress
        self._fromAddress = fromAddress
        self._smtpHost = smtpHost

        # Set up a generic application name if not specified.
        if applicationName is None:
            applicationName = []
            if application.__module__ != '__main__':
                applicationName.append('%s.' % application.__module__)
            applicationName.append(application.__name__)
            applicationName = ''.join(applicationName)
        self._applicationName = applicationName

        self._errorPageMimeType = errorPageMimeType
        # If errorPage was unspecified, set it from the static file
        # specified by errorPageFile.
        if errorPage is None:
            f = open(errorPageFile)
            errorPage = f.read()
            f.close
        self._errorPage = errorPage

        self._emailInterval = emailInterval * 60
        self._lastEmailTime = 0
        self._intervalCheckFile = intervalCheckFile

        # Set up displayErrorPage appropriately.
        self._debug = debug
        if debug:
            self.displayErrorPage = self._displayDebugPage
        else:
            self.displayErrorPage = self._displayErrorPage

        # Lock for _lastEmailTime
        self._lock = thread.allocate_lock()

    def _displayErrorPage(self, environ, start_response):
        """
        Displays the static error page. May be overridden. (Maybe you'd
        rather redirect or something?) This is basically a mini-WSGI
        application, except that start_response() is called with the third
        argument.

        Really, there's nothing keeping you from overriding this method
        and displaying a dynamic error page. But I thought it might be safer
        to display a static page. :)
        """
        start_response('200 OK', [('Content-Type', self._errorPageMimeType),
                                  ('Content-Length',
                                   str(len(self._errorPage)))],
                       sys.exc_info())
        return [self._errorPage]

    def _displayDebugPage(self, environ, start_response):
        """
        When debugging, display an informative traceback of the exception.
        """
        import cgitb
        result = [cgitb.html(sys.exc_info())]
        start_response('200 OK', [('Content-Type', 'text/html'),
                                  ('Content-Length', str(len(result[0])))],
                       sys.exc_info())
        return result

    def _generateHTMLErrorEmail(self):
        """
        Generates the HTML version of the error email. Must return a string.
        """
        import cgitb
        return cgitb.html(sys.exc_info())

    def _generatePlainErrorEmail(self):
        """
        Generates the plain-text version of the error email. Must return a
        string.
        """
        import cgitb
        return cgitb.text(sys.exc_info())

    def _generateErrorEmail(self):
        """
        Generates the error email. Must return an instance of email.Message
        or subclass.

        This implementation generates a MIME multipart/alternative email with
        an HTML description of the error and a simpler plain-text alternative
        of the traceback.
        """
        msg = MIMEMultipart('alternative')
        msg.attach(MIMEText(self._generatePlainErrorEmail()))
        msg.attach(MIMEText(self._generateHTMLErrorEmail(), 'html'))
        return msg

    def _sendErrorEmail(self, environ):
        """
        Sends the error email as generated by _generateErrorEmail(). If
        anything goes wrong sending the email, the exception is caught
        and reported to wsgi.errors. I don't think there's really much else
        that can be done in that case.
        """
        msg = self._generateErrorEmail()

        msg['From'] = self._fromAddress
        msg['To'] = self._adminAddress
        msg['Subject'] = '%s: unhandled exception' % self._applicationName

        try:
            server = smtplib.SMTP(self._smtpHost)
            server.sendmail(self._fromAddress, self._adminAddress,
                            msg.as_string())
            server.quit()
        except Exception, e:
            stderr = environ['wsgi.errors']
            stderr.write('%s: Failed to send error email: %r %s\n' %
                         (self.__class__.__name__, e, e))
            stderr.flush()

    def _shouldSendEmail(self, environ):
        """
        Returns True if an email should be sent. The last time an email was
        sent is tracked by either an instance variable (if oneShot is False),
        or the mtime of a file on the filesystem (if oneShot is True).
        """
        if self._debug or self._adminAddress is None:
            # Never send email when debugging or when there's no admin
            # address.
            return False

        now = time.time()
        if not environ['wsgi.run_once']:
            self._lock.acquire()
            ret = (self._lastEmailTime + self._emailInterval) < now
            if ret:
                self._lastEmailTime = now
            self._lock.release()
        else:
            # The following should be protected, but do I *really* want
            # to get into the mess of using filesystem and file-based locks?
            # At worse, multiple emails get sent.
            ret = True

            try:
                mtime = os.path.getmtime(self._intervalCheckFile)
            except:
                # Assume file doesn't exist, which is OK. Send email
                # unconditionally.
                pass
            else:
                if (mtime + self._emailInterval) >= now:
                    ret = False

            if ret:
                # NB: If _intervalCheckFile cannot be created or written to
                # for whatever reason, you will *always* get an error email.
                try:
                    open(self._intervalCheckFile, 'w').close()
                except:
                    # Probably a good idea to report failure.
                    stderr = environ['wsgi.errors']
                    stderr.write('%s: Error writing intervalCheckFile %r\n'
                                 % (self.__class__.__name__,
                                    self._intervalCheckFile))
                    stderr.flush()
        return ret

    def exceptionHandler(self, environ):
        """
        Common handling of exceptions.
        """
        # Unconditionally report to wsgi.errors.
        stderr = environ['wsgi.errors']
        traceback.print_exc(file=stderr)
        stderr.flush()

        # Send error email, if needed.
        if self._shouldSendEmail(environ):
            self._sendErrorEmail(environ)

    def __call__(self, environ, start_response):
        """
        WSGI application interface. Simply wraps the call to the application
        with a try ... except. All the fancy stuff happens in the except
        clause.
        """
        try:
            return _wrapIterator(self._application(environ, start_response),
                                 self, environ, start_response)
        except:
            # Report the exception.
            self.exceptionHandler(environ)

            # Display static error page.
            return self.displayErrorPage(environ, start_response)

if __name__ == '__main__':
    def myapp(environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        raise RuntimeError, "I'm broken!"
        return ['Hello World!\n']

    # Note - email address is taken from sys.argv[1]. I'm not leaving
    # my email address here. ;)
    app = ErrorMiddleware(myapp, sys.argv[1])

    from ajp import WSGIServer
    WSGIServer(app).run()