Merge lp:~leonardr/lazr.restful/consistent-error-exposing into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Approved by: Tim Penhey
Approved revision: 183
Merged at revision: 176
Proposed branch: lp:~leonardr/lazr.restful/consistent-error-exposing
Merge into: lp:lazr.restful
Diff against target: 403 lines (+126/-86)
7 files modified
src/lazr/restful/_resource.py (+10/-12)
src/lazr/restful/configure.zcml (+3/-11)
src/lazr/restful/declarations.py (+16/-4)
src/lazr/restful/docs/webservice.txt (+5/-4)
src/lazr/restful/error.py (+18/-22)
src/lazr/restful/interfaces/_rest.py (+2/-12)
src/lazr/restful/tests/test_error.py (+72/-21)
To merge this branch: bzr merge lp:~leonardr/lazr.restful/consistent-error-exposing
Reviewer Review Type Date Requested Status
Tim Penhey (community) Approve
Review via email: mp+51946@code.launchpad.net

Description of the change

Currently it's very difficult to get lazr.restful to associate a certain HTTP response code with a given exception. There are two techniques for associating a response code with an exception _class_, but it turns out these techniques actually do nothing whatsoever. To get this to work, you have to call expose() on an exception _instance_. And that only works when you the HTTP response code you want to use is 400.

This branch gets to the bottom of the problem and fixes it. Now you can use expose() to associate an exception instance with _any_ HTTP response code. (This fixes bug 631711.) You can use the @error_status decorator to set a response code for all instances of a given class. Every single exception will have its associated status code set, if it has an associated response code. Exceptions that correspond to 4xx response codes will be handled by lazr.restful; all other exceptions will be re-raised so they can be handled by the publisher (eg. retrying transient database errors or doing OOPS processing).

To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

Here's one change we might not like:

- >>> put_request.traverse(app)()
- Traceback (most recent call last):
- ...
- Unauthorized: (<Recipe object...>, 'dish', ...)
+ >>> print put_request.traverse(app)()
+ (<Recipe object...>, 'dish', ...)

The publisher puts the name of the exception class before the string. lazr.restful doesn't do this. This wasn't a problem before because lazr.restful only handled 400 errors. Now it's handling all 4xx errors.

Possible solutions:

1. lazr.restful re-raises all but 400 errors.
2. lazr.restful re-raises *all* errors, after setting the status (effectively abdicating any responsibility for the content of the response)
3. lazr.restful sticks the name of the exception class before the string.
4. Leave it as is. 401 means "Unauthorized", so there's no loss of information as far as the client is concerned.

Revision history for this message
Leonard Richardson (leonardr) wrote :

Oh, duh--the problem isn't the name of the exception class. The problem is that calling the resource used to raise an exception, and now it just returns the error string. I think this is fine.

Revision history for this message
Tim Penhey (thumper) wrote :

Looks good, and will help LP :-)

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/lazr/restful/_resource.py'
2--- src/lazr/restful/_resource.py 2011-02-17 18:13:25 +0000
3+++ src/lazr/restful/_resource.py 2011-03-02 19:52:53 +0000
4@@ -84,8 +84,7 @@
5 from lazr.lifecycle.event import ObjectModifiedEvent
6 from lazr.lifecycle.snapshot import Snapshot
7 from lazr.restful.interfaces import (
8- IClientError,
9- IClientErrorView,
10+ IWebServiceErrorView,
11 ICollectionField,
12 ICollectionResource,
13 IEntry,
14@@ -917,18 +916,17 @@
15 self.request.response.setStatus(405)
16 self.request.response.setHeader("Allow", allow_string)
17 except Exception, e:
18- # If the exception was decorated in such a way to indicate that
19- # the error is the client's fault (i.e., not a bug in the web
20- # service), then the error should be communicated to the client
21- # without the publisher framework treating the response as an
22- # error.
23- if IClientError.providedBy(e):
24- view = getMultiAdapter((e, self.request), IClientErrorView)
25+ view = getMultiAdapter((e, self.request), name="index.html")
26+ self.request.response.setStatus(view.status)
27+ if view.status / 100 == 4:
28+ # This exception corresponds to a client-side error
29 result = view()
30- self.request.response.setStatus(view.status)
31 else:
32- raise
33-
34+ # This exception corresponds to a server-side error
35+ # (or, less likely, some non-error condition). The
36+ # publisher will know better than lazr.restful how to
37+ # handle it.
38+ raise e
39 return result
40
41
42
43=== modified file 'src/lazr/restful/configure.zcml'
44--- src/lazr/restful/configure.zcml 2010-09-06 19:25:17 +0000
45+++ src/lazr/restful/configure.zcml 2011-03-02 19:52:53 +0000
46@@ -285,12 +285,13 @@
47
48 <adapter factory="lazr.restful.render_field_to_html" />
49
50- <!-- System Errors -->
51+ <!-- System Errors, and errors published to the web service using
52+ annotations or the expose() method. -->
53 <adapter
54 for="zope.interface.common.interfaces.IException
55 lazr.restful.interfaces.IWebServiceLayer"
56 provides="zope.interface.Interface"
57- factory="lazr.restful.error.SystemErrorView"
58+ factory="lazr.restful.error.WebServiceExceptionView"
59 name="index.html"
60 />
61
62@@ -312,13 +313,4 @@
63 name="index.html"
64 />
65
66- <!-- Exceptions that are explicitly marked to be exposed via the web
67- service. -->
68- <adapter
69- for="lazr.restful.interfaces.IClientError
70- lazr.restful.interfaces.IWebServiceLayer"
71- provides="lazr.restful.interfaces.IClientErrorView"
72- factory="lazr.restful.error.ClientErrorView"
73- />
74-
75 </configure>
76
77=== modified file 'src/lazr/restful/declarations.py'
78--- src/lazr/restful/declarations.py 2011-01-24 22:00:56 +0000
79+++ src/lazr/restful/declarations.py 2011-03-02 19:52:53 +0000
80@@ -42,7 +42,10 @@
81 import sys
82
83 from zope.component import getUtility, getGlobalSiteManager
84-from zope.interface import classImplements
85+from zope.interface import (
86+ implements,
87+ classImplements,
88+ )
89 from zope.interface.advice import addClassAdvisor
90 from zope.interface.interface import fromFunction, InterfaceClass, TAGGED_DATA
91 from zope.interface.interfaces import IInterface, IMethod
92@@ -60,9 +63,17 @@
93 from lazr.restful.fields import CollectionField, Reference
94 from lazr.restful.interface import copy_field
95 from lazr.restful.interfaces import (
96- ICollection, IEntry, IReference, IResourceDELETEOperation,
97- IResourceGETOperation, IResourcePOSTOperation, IWebServiceConfiguration,
98- IWebServiceVersion, LAZR_WEBSERVICE_NAME, LAZR_WEBSERVICE_NS)
99+ ICollection,
100+ IEntry,
101+ IReference,
102+ IResourceDELETEOperation,
103+ IResourceGETOperation,
104+ IResourcePOSTOperation,
105+ IWebServiceConfiguration,
106+ IWebServiceVersion,
107+ LAZR_WEBSERVICE_NAME,
108+ LAZR_WEBSERVICE_NS,
109+ )
110 from lazr.restful import (
111 Collection, Entry, EntryAdapterUtility, ResourceOperation, ObjectLink)
112 from lazr.restful.security import protect_schema
113@@ -373,6 +384,7 @@
114
115 f_locals[WEBSERVICE_ERROR] = int(status)
116
117+
118 def error_status(status):
119 """Make a Python 2.6 class decorator for the given status.
120
121
122=== modified file 'src/lazr/restful/docs/webservice.txt'
123--- src/lazr/restful/docs/webservice.txt 2011-01-25 16:14:36 +0000
124+++ src/lazr/restful/docs/webservice.txt 2011-03-02 19:52:53 +0000
125@@ -1824,10 +1824,11 @@
126 >>> body = simplejson.dumps(author)
127 >>> put_request = create_web_service_request(
128 ... beard_url, body=body, environ=headers, method='PUT')
129- >>> put_request.traverse(app)()
130- Traceback (most recent call last):
131- ...
132- Unauthorized: (<Recipe object...>, 'dish', ...)
133+ >>> print put_request.traverse(app)()
134+ (<Recipe object...>, 'dish', ...)
135+
136+ >>> print put_request.response.getStatus()
137+ 401
138
139 Stored file resources
140 =====================
141
142=== modified file 'src/lazr/restful/error.py'
143--- src/lazr/restful/error.py 2011-01-31 22:34:54 +0000
144+++ src/lazr/restful/error.py 2011-03-02 19:52:53 +0000
145@@ -31,7 +31,9 @@
146 def isSystemError():
147 """Return a boolean indicating whether the error is a system error"""
148
149-from lazr.restful.interfaces import IWebServiceConfiguration, IClientError
150+from lazr.restful.interfaces import (
151+ IWebServiceConfiguration,
152+ )
153
154
155 class WebServiceExceptionView:
156@@ -48,7 +50,11 @@
157 By default, use the __lazr_webservice_error__ attribute on
158 the exception.
159 """
160- return self.context.__lazr_webservice_error__
161+ return getattr(self.context, '__lazr_webservice_error__', 500)
162+
163+ def isSystemError(self):
164+ """See `ISystemErrorView`."""
165+ return self.status / 100 == 5
166
167 def __call__(self):
168 """Generate the HTTP response describing the exception."""
169@@ -76,19 +82,14 @@
170 return ''.join(result)
171
172
173+# lazr/restful/configure.zcml registers these classes as adapter
174+# factories for common Zope exceptions.
175 class ClientErrorView(WebServiceExceptionView):
176 """Client-induced error."""
177 implements(ISystemErrorView)
178
179 status = 400
180
181- def isSystemError(self):
182- """See `ISystemErrorView`.
183-
184- We *do not* want these logged in the SiteError log.
185- """
186- return False
187-
188
189 class SystemErrorView(WebServiceExceptionView):
190 """Server error."""
191@@ -96,13 +97,6 @@
192
193 status = 500
194
195- def isSystemError(self):
196- """See `ISystemErrorView`.
197-
198- We want these logged in the SiteError log.
199- """
200- return True
201-
202
203 class NotFoundView(WebServiceExceptionView):
204 """View for NotFound."""
205@@ -115,6 +109,7 @@
206
207 status = 401
208
209+
210 # This is currently only configured/tested in Launchpad.
211 class RequestExpiredView(WebServiceExceptionView):
212 """View for RequestExpired exception."""
213@@ -122,18 +117,19 @@
214 status = 503
215
216
217-def expose(exception):
218+def expose(exception, status=400):
219 """Tell lazr.restful to deliver details about the exception to the client.
220 """
221- # Bug 631711 describes the possibility of controlling the HTTP status of
222- # the response (so it can be something other than 400).
223-
224 # Since Python lets us raise exception types without instantiating them
225 # (like "raise RuntimeError" instead of "raise RuntimeError()", we want to
226 # make sure the caller doesn't get confused and try that with us.
227 if not isinstance(exception, BaseException):
228- raise ValueError('this function only accepts exception instanaces')
229+ raise ValueError('This function only accepts exception instances. '
230+ 'Use the @error_status decorator to publish an '
231+ 'exception class as a web service error.')
232+
233+ exception.__lazr_webservice_error__ = status
234+
235 # Mark the exception to indicate that its details should be sent to the
236 # web service client.
237- directlyProvides(exception, IClientError)
238 return exception
239
240=== modified file 'src/lazr/restful/interfaces/_rest.py'
241--- src/lazr/restful/interfaces/_rest.py 2011-02-17 12:48:46 +0000
242+++ src/lazr/restful/interfaces/_rest.py 2011-03-02 19:52:53 +0000
243@@ -22,8 +22,6 @@
244 __all__ = [
245 'IByteStorage',
246 'IByteStorageResource',
247- 'IClientError',
248- 'IClientErrorView',
249 'ICollection',
250 'ICollectionResource',
251 'IEntry',
252@@ -52,6 +50,7 @@
253 'LAZR_WEBSERVICE_NS',
254 'IWebBrowserOriginatingRequest',
255 'IWebServiceClientRequest',
256+ 'IWebServiceErrorView',
257 'IWebServiceLayer',
258 'IWebServiceVersion',
259 ]
260@@ -237,16 +236,7 @@
261 """Render the given string as HTML."""
262
263
264-class IClientError(Interface):
265- """This denotes that an exception should be relayed to the API client.
266-
267- This is a marker interface that is "slammed" onto exceptions by users of
268- lazr.restful by calling lazr.restful.error.expose on an exception before
269- they raise it.
270- """
271-
272-
273-class IClientErrorView(Interface):
274+class IWebServiceErrorView(Interface):
275 """The view that defines how errors are related to API clients."""
276
277
278
279=== modified file 'src/lazr/restful/tests/test_error.py'
280--- src/lazr/restful/tests/test_error.py 2010-11-04 18:21:58 +0000
281+++ src/lazr/restful/tests/test_error.py 2011-03-02 19:52:53 +0000
282@@ -12,9 +12,12 @@
283 from zope.component import getMultiAdapter
284 from zope.interface import Interface
285
286+from lazr.restful.declarations import error_status
287 from lazr.restful._resource import ReadWriteResource, UnknownEntryAdapter
288 from lazr.restful.error import expose, ClientErrorView, SystemErrorView
289-from lazr.restful.interfaces import IClientErrorView
290+from lazr.restful.interfaces import (
291+ IWebServiceErrorView,
292+ )
293 from lazr.restful.testing.webservice import FakeRequest
294
295
296@@ -28,17 +31,55 @@
297 return self.callable()
298
299
300+class ExposeTestCase(unittest.TestCase):
301+ # Test the behavior of the expose() method.
302+
303+ def test_exposed_does_not_work_on_class(self):
304+ self.assertRaises(ValueError, expose, Exception)
305+
306+ def test_default_exposed_status_is_400(self):
307+ exception = expose(ValueError())
308+ self.assertEquals(exception.__lazr_webservice_error__, 400)
309+
310+ def test_expose_can_specify_status(self):
311+ exception = expose(ValueError(), 409)
312+ self.assertEquals(exception.__lazr_webservice_error__, 409)
313+
314+
315 class ErrorsTestCase(unittest.TestCase):
316
317- def test_client_error_view_lookup(self):
318- # An error decorated with lazr.restful.error.exposed will be adaptable
319- # to IClientErrorView.
320- request = FakeRequest(version='trunk')
321- exception_view = getMultiAdapter(
322- (expose(ValueError()), request), IClientErrorView)
323- self.assertEquals(exception_view.__class__, ClientErrorView)
324-
325- def test_undecorated_exception(self):
326+ def test_decorated_exception_class_becomes_error_view(self):
327+ # An exposed exception, when raised, will set the response
328+ # status code appropriately, and set the response text to the
329+ # exception body.
330+ def broken():
331+ @error_status(404)
332+ class MyException(Exception):
333+ pass
334+ raise MyException("something broke")
335+
336+ request = FakeRequest(version='trunk')
337+ resource = TestResource(broken, request)
338+ result = resource()
339+ self.assertEquals(request.response.status, 404)
340+ self.assert_(result.startswith('something broke'))
341+
342+ def test_decorated_exception_instance_becomes_error_view(self):
343+ # An exposed exception, when raised, will set the response
344+ # status code appropriately, and set the response text to the
345+ # exception body.
346+ def broken():
347+ error = RuntimeError('something broke')
348+ error = expose(error, 404)
349+ raise error
350+
351+ request = FakeRequest(version='trunk')
352+ resource = TestResource(broken, request)
353+ result = resource()
354+ self.assertEquals(request.response.status, 404)
355+ self.assert_(result.startswith('something broke'))
356+
357+ def test_undecorated_exception_is_propagated(self):
358 # If an undecorated exception (a regular Python exception) is
359 # generated during a request the exception percolates all the way out
360 # of the resource (to be handled by the publisher).
361@@ -50,21 +91,31 @@
362 resource = TestResource(broken, request)
363 self.assertRaises(RuntimeError, resource)
364
365- def test_decorated_exception(self):
366- # If an undecorated exception is generated during a request the
367- # SystemErrorView is used to generate the response.
368-
369+ def test_exception_decorated_as_system_error_is_propagated(self):
370+ # If an exception is decorated with a response code that
371+ # indicates a server-side problem, the exception percolates
372+ # all the way out of the resource (to be handled by the
373+ # publisher).
374 def broken():
375- raise expose(RuntimeError('something broke'))
376+ raise expose(RuntimeError('something broke'), 503)
377
378 request = FakeRequest(version='trunk')
379 resource = TestResource(broken, request)
380- result = resource()
381- # The client error view sets the status code to 400 (client error).
382- self.assertEqual(request.response.getStatus(), 400)
383- # The message used to construct the exception is included in the body
384- # of the response.
385- self.assert_(result.startswith('something broke'))
386+ self.assertRaises(RuntimeError, resource)
387+ self.assertEquals(request.response.getStatus(), 503)
388+
389+ def test_exception_decorated_as_nonerror_is_propagated(self):
390+ # If an exception is decorated with a response code that
391+ # indicates a non-error condition, the exception percolates
392+ # all the way out of the resource (to be handled by the
393+ # publisher).
394+ def if_you_say_so():
395+ raise expose(RuntimeError("everything's fine"), 200)
396+
397+ request = FakeRequest(version='trunk')
398+ resource = TestResource(if_you_say_so, request)
399+ self.assertRaises(RuntimeError, resource)
400+ self.assertEquals(request.response.getStatus(), 200)
401
402 def test_passing_bad_things_to_expose(self):
403 # The expose function only accepts instances of exceptions. It

Subscribers

People subscribed via source and target branches