Merge lp:~gmb/maas/rpc-error-handling-middleware into lp:~maas-committers/maas/trunk

Proposed by Graham Binns
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: 3128
Proposed branch: lp:~gmb/maas/rpc-error-handling-middleware
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 217 lines (+148/-2)
4 files modified
src/maas/settings.py (+1/-0)
src/maasserver/middleware.py (+41/-0)
src/maasserver/tests/test_middleware.py (+103/-0)
src/provisioningserver/rpc/power.py (+3/-2)
To merge this branch: bzr merge lp:~gmb/maas/rpc-error-handling-middleware
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+236396@code.launchpad.net

Commit message

Add middleware to handle errors from RPC connections.

At present, "handling" means "display an error message to the user". There may be a Better Way but this is a good start.

Description of the change

I've tested this on a local test rig with VMs, so I'm reasonably confident that it'll work fine. I'm not sure whether we should *also* be logging the errors to maas-django.log; that seems sane but there's already a lot of cruft in there.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Looks good, but I think the handling of MultipleFailures needs changing.

review: Needs Fixing
Revision history for this message
Gavin Panella (allenap) wrote :

Bootiful.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maas/settings.py'
2--- src/maas/settings.py 2014-09-24 14:54:56 +0000
3+++ src/maas/settings.py 2014-09-29 21:37:53 +0000
4@@ -233,6 +233,7 @@
5 'maasserver.middleware.ErrorsMiddleware',
6 'maasserver.middleware.APIErrorsMiddleware',
7 'maasserver.middleware.ExternalComponentsMiddleware',
8+ 'maasserver.middleware.RPCErrorsMiddleware',
9 'metadataserver.middleware.MetadataErrorsMiddleware',
10 'django.middleware.transaction.TransactionMiddleware',
11 'django.middleware.clickjacking.XFrameOptionsMiddleware',
12
13=== modified file 'src/maasserver/middleware.py'
14--- src/maasserver/middleware.py 2014-09-02 16:20:36 +0000
15+++ src/maasserver/middleware.py 2014-09-29 21:37:53 +0000
16@@ -43,6 +43,11 @@
17 HttpResponseForbidden,
18 HttpResponseRedirect,
19 )
20+from provisioningserver.rpc.exceptions import (
21+ MultipleFailures,
22+ NoConnectionsAvailable,
23+ PowerActionAlreadyInProgress,
24+ )
25
26
27 try:
28@@ -260,3 +265,39 @@
29 self.log_level,
30 "%s\n%s", header, decoded_content)
31 return response # Return response unaltered.
32+
33+
34+class RPCErrorsMiddleware:
35+ """A middleware for handling RPC errors."""
36+
37+ handled_exceptions = (
38+ NoConnectionsAvailable,
39+ PowerActionAlreadyInProgress,
40+ )
41+
42+ def _handle_exception(self, request, exception):
43+ logging.exception(exception)
44+ error_message = unicode(exception)
45+ if len(error_message) == 0:
46+ # Make a pretty message for exceptions without a message.
47+ error_message = (
48+ "Unexpected exception: %s. See "
49+ "/var/log/maas/maas-django.log "
50+ "on the region server for more information." %
51+ exception.__class__.__name__)
52+ messages.error(request, error_message)
53+
54+ def process_exception(self, request, exception):
55+ if isinstance(exception, MultipleFailures):
56+ exceptions = [
57+ failure.value for failure in exception.args]
58+ for exception in exceptions:
59+ self._handle_exception(request, exception)
60+ return HttpResponseRedirect(request.path)
61+ elif isinstance(exception, self.handled_exceptions):
62+ self._handle_exception(request, exception)
63+ return HttpResponseRedirect(request.path)
64+ else:
65+ # Nothing to do, since we don't care about anything other
66+ # than MultipleFailures and handled_exception.
67+ return None
68
69=== modified file 'src/maasserver/tests/test_middleware.py'
70--- src/maasserver/tests/test_middleware.py 2014-09-02 16:12:03 +0000
71+++ src/maasserver/tests/test_middleware.py 2014-09-29 21:37:53 +0000
72@@ -17,6 +17,7 @@
73 import httplib
74 import json
75 import logging
76+import random
77
78 from django.contrib.messages import constants
79 from django.core.exceptions import (
80@@ -37,15 +38,22 @@
81 DebuggingLoggerMiddleware,
82 ErrorsMiddleware,
83 ExceptionMiddleware,
84+ RPCErrorsMiddleware,
85 )
86 from maasserver.testing import extract_redirect
87 from maasserver.testing.factory import factory
88 from maasserver.testing.testcase import MAASServerTestCase
89 from maastesting.utils import sample_binary_data
90+from provisioningserver.rpc.exceptions import (
91+ MultipleFailures,
92+ NoConnectionsAvailable,
93+ PowerActionAlreadyInProgress,
94+ )
95 from testtools.matchers import (
96 Contains,
97 Not,
98 )
99+from twisted.python.failure import Failure
100
101
102 class ExceptionMiddlewareTest(MAASServerTestCase):
103@@ -247,3 +255,98 @@
104 # An error message has been published.
105 self.assertEqual(
106 [(constants.ERROR, error_message, '')], request._messages.messages)
107+
108+
109+class RPCErrorsMiddlewareTest(MAASServerTestCase):
110+
111+ def test_handles_PowerActionAlreadyInProgress(self):
112+ middleware = RPCErrorsMiddleware()
113+ request = factory.make_fake_request(factory.make_string(), 'POST')
114+ error_message = (
115+ "Unable to execute power action: another action is already in "
116+ "progress for node %s" % factory.make_name('node'))
117+ error = PowerActionAlreadyInProgress(error_message)
118+ response = middleware.process_exception(request, error)
119+
120+ # The response is a redirect.
121+ self.assertEqual(request.path, extract_redirect(response))
122+ # An error message has been published.
123+ self.assertEqual(
124+ [(constants.ERROR, error_message, '')],
125+ request._messages.messages)
126+
127+ def test_handles_MultipleFailures(self):
128+ middleware = RPCErrorsMiddleware()
129+ request = factory.make_fake_request(factory.make_string(), 'POST')
130+ failures = []
131+ for _ in range(3):
132+ error_message = factory.make_name("error-")
133+ exception_class = random.choice(
134+ (NoConnectionsAvailable, PowerActionAlreadyInProgress))
135+ failures.append(Failure(exception_class(error_message)))
136+ exception = MultipleFailures(*failures)
137+ response = middleware.process_exception(request, exception)
138+
139+ # The response is a redirect.
140+ self.assertEqual(request.path, extract_redirect(response))
141+ # An error message has been published for each exception.
142+ self.assertEqual(
143+ [(constants.ERROR, unicode(failure.value), '')
144+ for failure in failures],
145+ request._messages.messages)
146+
147+ def test_handles_NoConnectionsAvailable(self):
148+ middleware = RPCErrorsMiddleware()
149+ request = factory.make_fake_request(factory.make_string(), 'POST')
150+ error_message = (
151+ "No connections availble for cluster %s" %
152+ factory.make_name('cluster'))
153+ error = PowerActionAlreadyInProgress(error_message)
154+ response = middleware.process_exception(request, error)
155+
156+ # The response is a redirect.
157+ self.assertEqual(request.path, extract_redirect(response))
158+ # An error message has been published.
159+ self.assertEqual(
160+ [(constants.ERROR, error_message, '')],
161+ request._messages.messages)
162+
163+ def test_ignores_non_rpc_errors(self):
164+ middleware = RPCErrorsMiddleware()
165+ request = factory.make_fake_request(factory.make_string(), 'POST')
166+ exception = ZeroDivisionError(
167+ "You may think it's a long walk down the street to the chemist "
168+ "but that's just peanuts to space!")
169+ response = middleware.process_exception(request, exception)
170+ self.assertIsNone(response)
171+
172+ def test_adds_message_for_unknown_errors_in_multiple_failures(self):
173+ # If an exception has no message, the middleware will generate a
174+ # useful one and display it to the user.
175+ middleware = RPCErrorsMiddleware()
176+ request = factory.make_fake_request(factory.make_string(), 'POST')
177+ unknown_exception = ZeroDivisionError()
178+ failures = [
179+ Failure(unknown_exception),
180+ Failure(PowerActionAlreadyInProgress("Unzip a banana!")),
181+ ]
182+ exception = MultipleFailures(*failures)
183+ response = middleware.process_exception(request, exception)
184+ self.assertEqual(request.path, extract_redirect(response))
185+
186+ expected_messages = [
187+ (
188+ constants.ERROR,
189+ (
190+ "Unexpected exception: %s. See "
191+ "/var/log/maas/maas-django.log "
192+ "on the region server for more information." %
193+ unknown_exception.__class__.__name__
194+ ),
195+ ''
196+ ),
197+ (constants.ERROR, unicode(failures[1].value), ''),
198+ ]
199+ self.assertEqual(
200+ expected_messages,
201+ request._messages.messages)
202
203=== modified file 'src/provisioningserver/rpc/power.py'
204--- src/provisioningserver/rpc/power.py 2014-09-26 19:14:19 +0000
205+++ src/provisioningserver/rpc/power.py 2014-09-29 21:37:53 +0000
206@@ -164,8 +164,9 @@
207 registered_power_action = power_action_registry.get(system_id, None)
208 if registered_power_action is not None:
209 raise PowerActionAlreadyInProgress(
210- "Power action %s is already in progress for node %s" %
211- (registered_power_action, system_id))
212+ "Unable to change power state to '%s' for node %s: another "
213+ "action is already in progress for that node." %
214+ (power_change, hostname))
215 power_action_registry[system_id] = power_change
216
217 yield power_change_starting(system_id, hostname, power_change)