Skip to content

Commit 83443e3

Browse files
committed
Merge branch 'error-changes'
Fixes #7, #4.
2 parents ebc1cc7 + a12c042 commit 83443e3

File tree

6 files changed

+152
-16
lines changed

6 files changed

+152
-16
lines changed

docs/tutorial.rst

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,56 @@ allowed by Restless. It's the only sane/safe default to have & it's very easy
451451
to fix.
452452

453453

454+
Error Handling
455+
==============
456+
457+
By default, Restless tries to serialize any exceptions that may be encountered.
458+
What gets serialized depends on two methods: ``Resource.is_debug()`` &
459+
``Resource.bubble_exceptions()``.
460+
461+
``is_debug``
462+
------------
463+
464+
Regardless of the error type, the exception's message will get serialized into
465+
the response under the ``"error"`` key. For example, if an ``IOError`` is
466+
raised during processing, you'll get a response like::
467+
468+
HTTP/1.0 500 INTERNAL SERVER ERROR
469+
Content-Type: application/json
470+
# Other headers...
471+
472+
{
473+
"error": "Whatever."
474+
}
475+
476+
If ``Resource.is_debug()`` returns ``True`` (the default is ``False``), Restless
477+
will also include a traceback. For example::
478+
479+
HTTP/1.0 500 INTERNAL SERVER ERROR
480+
Content-Type: application/json
481+
# Other headers...
482+
483+
{
484+
"error": "Whatever.",
485+
"traceback": "Traceback (most recent call last):\n # Typical traceback..."
486+
}
487+
488+
Each framework-specific ``Resource`` subclass implements ``is_debug()`` in a
489+
way most appropriate for the framework. In the case of the ``DjangoResource``,
490+
it returns ``settings.DEBUG``, allowing your resources to stay consistent with
491+
the rest of your application.
492+
493+
``bubble_exceptions``
494+
---------------------
495+
496+
If ``Resource.bubble_exceptions()`` returns ``True`` (the default is ``False``),
497+
any exception encountered will simply be re-raised & it's up to your setup to
498+
handle it. Typically, this behavior is undesirable except in development & with
499+
frameworks that can provide extra information/debugging on exceptions. Feel
500+
free to override it (``return True``) or implement application-specific logic
501+
if that meets your needs.
502+
503+
454504
Authentication
455505
==============
456506

restless/resources.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import six
2+
import sys
23

34
from .constants import OK, CREATED, ACCEPTED, NO_CONTENT
45
from .exceptions import MethodNotImplemented, Unauthorized
5-
from .utils import json, lookup_data, MoreTypesJSONEncoder
6+
from .utils import json, lookup_data, MoreTypesJSONEncoder, format_traceback
67

78

89
class Resource(object):
@@ -192,18 +193,23 @@ def build_error(self, err):
192193
193194
:returns: A response object
194195
"""
195-
data = json.dumps({
196+
data = {
196197
'error': six.text_type(err),
197-
})
198+
}
199+
200+
if self.is_debug():
201+
# Add the traceback.
202+
data['traceback'] = format_traceback(sys.exc_info())
203+
204+
body = self.raw_serialize(data)
198205
status = getattr(err, 'status', 500)
199-
return self.build_response(data, status=status)
206+
return self.build_response(body, status=status)
200207

201208
def is_debug(self):
202209
"""
203210
Controls whether or not the resource is in a debug environment.
204211
205-
If so, exceptions will be reraised instead of returning a serialized
206-
response.
212+
If so, tracebacks will be added to the serialized response.
207213
208214
The default implementation simply returns ``False``, so if you're
209215
integrating with a new web framework, you'll need to override this
@@ -214,6 +220,21 @@ def is_debug(self):
214220
"""
215221
return False
216222

223+
def bubble_exceptions(self):
224+
"""
225+
Controls whether or not exceptions will be re-raised when encountered.
226+
227+
The default implementation returns ``False``, which means errors should
228+
return a serialized response.
229+
230+
If you'd like exceptions to be re-raised, override this method & return
231+
``True``.
232+
233+
:returns: Whether exceptions should be re-raised or not
234+
:rtype: boolean
235+
"""
236+
return False
237+
217238
def handle(self, endpoint, *args, **kwargs):
218239
"""
219240
A convenient dispatching method, this centralized some of the common
@@ -257,14 +278,27 @@ def handle(self, endpoint, *args, **kwargs):
257278
data = view_method(*args, **kwargs)
258279
serialized = self.serialize(method, endpoint, data)
259280
except Exception as err:
260-
if self.is_debug():
261-
raise
262-
263-
return self.build_error(err)
281+
return self.handle_error(err)
264282

265283
status = self.status_map.get(self.http_methods[endpoint][method], OK)
266284
return self.build_response(serialized, status=status)
267285

286+
def handle_error(self, err):
287+
"""
288+
When an exception is encountered, this generates a serialized error
289+
message to return the user.
290+
291+
:param err: The exception seen. The message is exposed to the user, so
292+
beware of sensitive data leaking.
293+
:type err: Exception
294+
295+
:returns: A response object
296+
"""
297+
if self.bubble_exceptions():
298+
raise err
299+
300+
return self.build_error(err)
301+
268302
def raw_deserialize(self, body):
269303
"""
270304
The low-level deserialization.

restless/utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22
import decimal
3+
import traceback
34

45
try:
56
import json
@@ -82,3 +83,15 @@ def default(self, data):
8283
return str(data)
8384
else:
8485
return super(MoreTypesJSONEncoder, self).default(data)
86+
87+
88+
def format_traceback(exc_info):
89+
stack = traceback.format_stack()
90+
stack = stack[:-2]
91+
stack.extend(traceback.format_tb(exc_info[2]))
92+
stack.extend(traceback.format_exception_only(exc_info[0], exc_info[1]))
93+
stack_str = "Traceback (most recent call last):\n"
94+
stack_str += "".join(stack)
95+
# Remove the last \n
96+
stack_str = stack_str[:-1]
97+
return stack_str

tests/test_dj.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,25 +189,46 @@ def test_as_view(self):
189189
def test_handle_not_implemented(self):
190190
self.res.request = FakeHttpRequest('TRACE')
191191

192-
with self.assertRaises(MethodNotImplemented):
193-
self.res.handle('list')
192+
resp = self.res.handle('list')
193+
self.assertEqual(resp['Content-Type'], 'application/json')
194+
self.assertEqual(resp.status_code, 501)
195+
resp_json = json.loads(resp.content.decode('utf-8'))
196+
self.assertEqual(resp_json['error'], "Unsupported method 'TRACE' for list endpoint.")
197+
self.assertTrue('traceback' in resp_json)
194198

195199
def test_handle_not_authenticated(self):
196200
# Special-cased above for testing.
197201
self.res.request = FakeHttpRequest('DELETE')
198202

199-
with self.assertRaises(Unauthorized):
200-
self.res.handle('list')
203+
# First with DEBUG on
204+
resp = self.res.handle('list')
205+
self.assertEqual(resp['Content-Type'], 'application/json')
206+
self.assertEqual(resp.status_code, 401)
207+
resp_json = json.loads(resp.content.decode('utf-8'))
208+
self.assertEqual(resp_json['error'], 'Unauthorized.')
209+
self.assertTrue('traceback' in resp_json)
201210

202211
# Now with DEBUG off.
203212
settings.DEBUG = False
204213
self.addCleanup(setattr, settings, 'DEBUG', True)
205214
resp = self.res.handle('list')
206215
self.assertEqual(resp['Content-Type'], 'application/json')
207216
self.assertEqual(resp.status_code, 401)
208-
self.assertEqual(json.loads(resp.content.decode('utf-8')), {
217+
resp_json = json.loads(resp.content.decode('utf-8'))
218+
self.assertEqual(resp_json, {
209219
'error': 'Unauthorized.',
210220
})
221+
self.assertFalse('traceback' in resp_json)
222+
223+
# Last, with bubble_exceptions.
224+
class Bubbly(DjTestResource):
225+
def bubble_exceptions(self):
226+
return True
227+
228+
with self.assertRaises(Unauthorized):
229+
bubb = Bubbly()
230+
bubb.request = FakeHttpRequest('DELETE')
231+
bubb.handle('list')
211232

212233
def test_handle_build_err(self):
213234
# Special-cased above for testing.

tests/test_resources.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ def test_build_error(self):
100100
def test_is_debug(self):
101101
self.assertFalse(self.res.is_debug())
102102

103+
def test_bubble_exceptions(self):
104+
self.assertFalse(self.res.bubble_exceptions())
105+
103106
def test_raw_deserialize(self):
104107
body = '{"title": "Hitchhiker\'s Guide To The Galaxy", "author": "Douglas Adams"}'
105108
self.assertEqual(self.res.raw_deserialize(body), {

tests/test_utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import sys
12
import unittest
23

3-
from restless.utils import lookup_data
4+
from restless.utils import lookup_data, format_traceback
45

56

67
class InstaObj(object):
@@ -65,3 +66,17 @@ def test_empty_lookup(self):
6566
def test_complex_miss(self):
6667
with self.assertRaises(AttributeError):
6768
lookup_data('more.nested.nope', self.dict_data)
69+
70+
71+
class FormatTracebackTestCase(unittest.TestCase):
72+
def test_format_traceback(self):
73+
try:
74+
raise ValueError("Because we need an exception.")
75+
except:
76+
exc_info = sys.exc_info()
77+
result = format_traceback(exc_info)
78+
self.assertTrue(result.startswith('Traceback (most recent call last):\n'))
79+
self.assertFalse(result.endswith('\n'))
80+
lines = result.split('\n')
81+
self.assertTrue(len(lines) > 3)
82+
self.assertEqual(lines[-1], 'ValueError: Because we need an exception.')

0 commit comments

Comments
 (0)