feat(Api): Catch and log non-HTTPError exceptions thrown by responders
This commit is contained in:
@@ -64,29 +64,32 @@ class API:
|
||||
req = Request(env)
|
||||
resp = Response()
|
||||
|
||||
path = req.path
|
||||
for path_template, method_map in self._routes:
|
||||
m = path_template.match(path)
|
||||
if m:
|
||||
req._params.update(m.groupdict())
|
||||
|
||||
try:
|
||||
responder = method_map[req.method]
|
||||
except KeyError:
|
||||
responder = responders.bad_request
|
||||
|
||||
break
|
||||
else:
|
||||
responder = responders.path_not_found
|
||||
responder, params = self._get_responder(req.path, req.method)
|
||||
req._params.update(params)
|
||||
|
||||
try:
|
||||
responder(req, resp)
|
||||
|
||||
except HTTPError as ex:
|
||||
resp.status = ex.status
|
||||
|
||||
if req.client_accepts_json():
|
||||
resp.body = ex.json()
|
||||
|
||||
except Exception as ex:
|
||||
# Reset to a known state and respond with a generic error
|
||||
req = Request(env)
|
||||
resp = Response()
|
||||
|
||||
message = 'Responder raised ' + ex.__class__.__name__
|
||||
|
||||
details = str(ex)
|
||||
if details:
|
||||
message = ': '.join([message, details])
|
||||
|
||||
req.log_error(message)
|
||||
responders.server_error(req, resp)
|
||||
|
||||
#
|
||||
# Set status and headers
|
||||
#
|
||||
@@ -127,3 +130,33 @@ class API:
|
||||
method_map = create_http_method_map(resource)
|
||||
|
||||
self._routes.append((path_template, method_map))
|
||||
|
||||
def _get_responder(self, path, method):
|
||||
"""Searches routes for a matching responder
|
||||
|
||||
Args:
|
||||
path: URI path to search (without query stirng)
|
||||
method: HTTP method (uppercase) requested
|
||||
Returns:
|
||||
A 2-member tuple, containing a responder callable and a dict
|
||||
containing parsed path fields, if any were specified in
|
||||
the matching route's URI template
|
||||
|
||||
"""
|
||||
|
||||
params = {}
|
||||
for path_template, method_map in self._routes:
|
||||
m = path_template.match(path)
|
||||
if m:
|
||||
params.update(m.groupdict())
|
||||
|
||||
try:
|
||||
responder = method_map[method]
|
||||
except KeyError:
|
||||
responder = responders.bad_request
|
||||
|
||||
break
|
||||
else:
|
||||
responder = responders.path_not_found
|
||||
|
||||
return (responder, params)
|
||||
|
||||
@@ -16,6 +16,9 @@ limitations under the License.
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from .request_helpers import *
|
||||
from .exceptions import *
|
||||
|
||||
@@ -31,7 +34,8 @@ class Request:
|
||||
'_params',
|
||||
'path',
|
||||
'protocol',
|
||||
'query_string'
|
||||
'query_string',
|
||||
'_wsgierrors'
|
||||
)
|
||||
|
||||
def __init__(self, env):
|
||||
@@ -53,6 +57,21 @@ class Request:
|
||||
self.query_string = query_string = env['QUERY_STRING']
|
||||
self._params = parse_query_string(query_string)
|
||||
self._headers = parse_headers(env)
|
||||
self._wsgierrors = env['wsgi.errors']
|
||||
|
||||
def log_error(self, message):
|
||||
if sys.version_info[0] == 2 and isinstance(message, str):
|
||||
unicode_message = message.decode('utf-8')
|
||||
else:
|
||||
unicode_message = message
|
||||
|
||||
log_line = (
|
||||
u'{0:%Y:%m:%d %H:%M:%S} [FALCON] [ERROR] {1} {2}?{3} => {4}'.
|
||||
format(datetime.now(), self.method, self.path, self.query_string,
|
||||
unicode_message)
|
||||
)
|
||||
|
||||
self._wsgierrors.write(log_line)
|
||||
|
||||
def client_accepts_json(self):
|
||||
"""Return True if the Accept header indicates JSON support"""
|
||||
|
||||
@@ -29,6 +29,11 @@ def bad_request(req, resp):
|
||||
resp.status = HTTP_400
|
||||
|
||||
|
||||
def server_error(req, resp):
|
||||
"""Sets response to "500 Internal Server Error", no body."""
|
||||
resp.status = HTTP_500
|
||||
|
||||
|
||||
def create_method_not_allowed(allowed_methods):
|
||||
"""Creates a responder for "405 Method Not Allowed".ipyth
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ limitations under the License.
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
class Response:
|
||||
"""Represents an HTTP response to a client request"""
|
||||
@@ -23,10 +25,14 @@ class Response:
|
||||
__slots__ = ('status', '_headers', 'body', 'stream', 'stream_len')
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize response attributes to default values"""
|
||||
"""Initialize response attributes to default values
|
||||
|
||||
Args:
|
||||
wsgierrors: File-like stream for logging errors
|
||||
|
||||
"""
|
||||
|
||||
self.status = None
|
||||
|
||||
self._headers = {}
|
||||
|
||||
self.body = None
|
||||
|
||||
21
foo.py
Normal file
21
foo.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import falcon
|
||||
|
||||
class StorageEngine:
|
||||
pass
|
||||
|
||||
|
||||
class ThingsResource:
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def on_get(self, req, resp):
|
||||
raise IOError()
|
||||
|
||||
app = api = falcon.API()
|
||||
|
||||
things = ThingsResource()
|
||||
api.add_route('/{user_id}/things', things)
|
||||
@@ -4,8 +4,8 @@ def application(environ, start_response):
|
||||
|
||||
body = '\n{\n'
|
||||
for key, value in environ.items():
|
||||
if isinstance(value, str):
|
||||
body += ' "{0}": "{1}",\n'.format(key, value)
|
||||
#if isinstance(value, str):
|
||||
body += ' "{0}": "{1}",\n'.format(key, value)
|
||||
|
||||
body += '}\n\n'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import random
|
||||
from io import BytesIO
|
||||
import io
|
||||
|
||||
import testtools
|
||||
|
||||
@@ -73,7 +73,8 @@ class TestSuite(testtools.TestCase):
|
||||
|
||||
|
||||
def create_environ(path='/', query_string='', protocol='HTTP/1.1', port='80',
|
||||
headers=None, script='', body='', method='GET'):
|
||||
headers=None, script='', body='', method='GET',
|
||||
wsgierrors=None):
|
||||
|
||||
env = {
|
||||
'SERVER_PROTOCOL': protocol,
|
||||
@@ -92,7 +93,8 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1', port='80',
|
||||
'SERVER_PORT': port,
|
||||
|
||||
'wsgi.url_scheme': 'http',
|
||||
'wsgi.input': BytesIO(body.encode('utf-8'))
|
||||
'wsgi.input': io.BytesIO(body.encode('utf-8')),
|
||||
'wsgi.errors': wsgierrors or io.StringIO()
|
||||
}
|
||||
|
||||
if protocol != 'HTTP/1.0':
|
||||
|
||||
34
test/test_wsgi_errors.py
Normal file
34
test/test_wsgi_errors.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import io
|
||||
from . import helpers
|
||||
|
||||
|
||||
class BombResource:
|
||||
|
||||
def on_get(self, req, resp):
|
||||
raise IOError()
|
||||
|
||||
def on_head(self, req, resp):
|
||||
raise MemoryError()
|
||||
|
||||
|
||||
class LoggerResource:
|
||||
|
||||
def on_get(self, req, resp):
|
||||
pass
|
||||
|
||||
|
||||
class TestWSGIError(helpers.TestSuite):
|
||||
|
||||
def prepare(self):
|
||||
self.tehbomb = BombResource()
|
||||
self.tehlogger = LoggerResource()
|
||||
|
||||
self.api.add_route('/bomb', self.tehbomb)
|
||||
self.api.add_route('/logger', self.tehlogger)
|
||||
self.wsgierrors = io.StringIO()
|
||||
|
||||
def test_exception_logged(self):
|
||||
self._simulate_request('/bomb', wsgierrors=self.wsgierrors)
|
||||
log = self.wsgierrors.getvalue()
|
||||
|
||||
self.assertIn(u'Responder raised IOError', log)
|
||||
Reference in New Issue
Block a user