Merge lp:~rvb/maas/retry-with-files into lp:~maas-committers/maas/trunk

Proposed by Raphaël Badin
Status: Merged
Approved by: Raphaël Badin
Approved revision: no longer in the source branch.
Merged at revision: 3708
Proposed branch: lp:~rvb/maas/retry-with-files
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 270 lines (+93/-34)
2 files modified
src/maasserver/utils/tests/test_views.py (+70/-26)
src/maasserver/utils/views.py (+23/-8)
To merge this branch: bzr merge lp:~rvb/maas/retry-with-files
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+253966@code.launchpad.net

Commit message

When retrying a view, restore the request's _files attribute so that the files can be re-read again. Previously, the read files would be reused and reading them again would return an empty string.

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

This is good, and it should land.

I did have a thought about it while reviewing. I'm a little concerned about the use (internally) of NamedTemporaryFile, and how copies of it will behave. I put together an incomplete alternative, which resets the input stream at the WSGI level: http://paste.ubuntu.com/10671243/

review: Approve
Revision history for this message
Raphaël Badin (rvb) wrote :

> This is good, and it should land.
>
> I did have a thought about it while reviewing. I'm a little concerned about
> the use (internally) of NamedTemporaryFile, and how copies of it will behave.
> I put together an incomplete alternative, which resets the input stream at the
> WSGI level: http://paste.ubuntu.com/10671243/

You're right, your solution is better. I've tested it manually and added a test case for it (it's a bit painful to fake a proper WSGIRequest!).

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

Nifty!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/utils/tests/test_views.py'
2--- src/maasserver/utils/tests/test_views.py 2015-03-17 20:55:18 +0000
3+++ src/maasserver/utils/tests/test_views.py 2015-03-25 11:31:43 +0000
4@@ -15,14 +15,19 @@
5 __all__ = []
6
7 import httplib
8+import io
9 from random import randint
10 from weakref import WeakSet
11
12+from apiclient.multipart import encode_multipart_data
13 from django.core import signals
14-from django.core.handlers.wsgi import WSGIHandler
15+from django.core.handlers.wsgi import (
16+ WSGIHandler,
17+ WSGIRequest,
18+ )
19 from django.core.urlresolvers import get_resolver
20 from django.db import connection
21-from django.http import HttpRequest
22+from django.http import HttpResponse
23 from django.http.response import HttpResponseServerError
24 from fixtures import FakeLogger
25 from maasserver.testing.testcase import SerializationFailureTestCase
26@@ -34,6 +39,7 @@
27 MockCallsMatch,
28 )
29 from maastesting.testcase import MAASTestCase
30+from maastesting.utils import sample_binary_data
31 from mock import (
32 call,
33 sentinel,
34@@ -47,13 +53,22 @@
35 Not,
36 )
37 from twisted.python import log
38+from twisted.web import wsgi
39+
40+
41+def make_request():
42+ # Return a minimal WSGIRequest.
43+ return WSGIRequest({
44+ "REQUEST_METHOD": "GET",
45+ "wsgi.input": wsgi._InputStream(io.BytesIO()),
46+ })
47
48
49 class TestLogRetry(MAASTestCase):
50 """Tests for :py:func:`maasserver.utils.views.log_retry`."""
51
52 def test__logs_warning(self):
53- request = HttpRequest()
54+ request = make_request()
55 request.path = factory.make_name("path")
56 attempt = randint(1, 10)
57
58@@ -69,23 +84,11 @@
59 """Tests for :py:func:`maasserver.utils.views.reset_request`."""
60
61 def test__clears_messages_from_cookies(self):
62- request = HttpRequest()
63+ request = make_request()
64 request.COOKIES["messages"] = sentinel.messages
65- views.reset_request(request)
66+ request = views.reset_request(request)
67 self.assertEqual({}, request.COOKIES)
68
69- def test__clears_only_messages_from_cookies(self):
70- request = HttpRequest()
71- request.COOKIES["messages"] = sentinel.messages
72- request.COOKIES.update({
73- factory.make_name("cookie"): sentinel.cookie
74- for _ in xrange(10)
75- })
76- keys_before = set(request.COOKIES)
77- views.reset_request(request)
78- keys_after = set(request.COOKIES)
79- self.assertEqual({"messages"}, keys_before - keys_after)
80-
81
82 class TestWebApplicationHandler(SerializationFailureTestCase):
83
84@@ -109,7 +112,7 @@
85
86 def test__handle_uncaught_exception_notes_serialization_failure(self):
87 handler = views.WebApplicationHandler()
88- request = HttpRequest()
89+ request = make_request()
90 request.path = factory.make_name("path")
91 failure = self.capture_serialization_failure()
92 response = handler.handle_uncaught_exception(
93@@ -125,7 +128,7 @@
94
95 def test__handle_uncaught_exception_does_not_note_other_failure(self):
96 handler = views.WebApplicationHandler()
97- request = HttpRequest()
98+ request = make_request()
99 request.path = factory.make_name("path")
100 failure_type = factory.make_exception_type()
101 failure = failure_type, failure_type(), None
102@@ -142,7 +145,7 @@
103
104 def test__handle_uncaught_exception_logs_other_failure(self):
105 handler = views.WebApplicationHandler()
106- request = HttpRequest()
107+ request = make_request()
108 request.path = factory.make_name("path")
109 exc_type = factory.make_exception_type()
110 exc_info = exc_type, exc_type(), None
111@@ -164,7 +167,7 @@
112 lambda request: self.cause_serialization_failure())
113
114 handler = views.WebApplicationHandler(1)
115- request = HttpRequest()
116+ request = make_request()
117 request.path = factory.make_name("path")
118 response = handler.get_response(request)
119
120@@ -182,7 +185,7 @@
121 signals.got_request_exception, "send")
122
123 handler = views.WebApplicationHandler(1)
124- request = HttpRequest()
125+ request = make_request()
126 request.path = factory.make_name("path")
127 handler.get_response(request)
128
129@@ -195,7 +198,7 @@
130 get_response_original.return_value = sentinel.response
131
132 handler = views.WebApplicationHandler()
133- request = HttpRequest()
134+ request = make_request()
135 request.path = factory.make_name("path")
136 response = handler.get_response(request)
137
138@@ -218,7 +221,10 @@
139 get_response_original = self.patch(WSGIHandler, "get_response")
140 get_response_original.side_effect = set_retry
141
142- request = HttpRequest()
143+ reset_request = self.patch_autospec(views, "reset_request")
144+ reset_request.side_effect = lambda request: request
145+
146+ request = make_request()
147 request.path = factory.make_name("path")
148 response = handler.get_response(request)
149
150@@ -241,7 +247,7 @@
151 log_retry = self.patch_autospec(views, "log_retry")
152 reset_request = self.patch_autospec(views, "reset_request")
153
154- request = HttpRequest()
155+ request = make_request()
156 request.path = factory.make_name("path")
157 handler.get_response(request)
158
159@@ -257,8 +263,46 @@
160 get_response_original = self.patch(WSGIHandler, "get_response")
161 get_response_original.side_effect = check_in_transaction
162
163- request = HttpRequest()
164+ request = make_request()
165 request.path = factory.make_name("path")
166 handler.get_response(request)
167
168 self.assertThat(get_response_original, MockCalledOnceWith(request))
169+
170+ def test__get_response_restores_files_across_requests(self):
171+ handler = views.WebApplicationHandler(3)
172+ file_content = sample_binary_data
173+ file_name = 'content'
174+
175+ recorder = []
176+
177+ def get_response_read_content_files(self, request):
178+ # Simple get_response method which returns the 'file_name' file
179+ # from the request in the response.
180+ content = request.FILES[file_name].read()
181+ # Record calls.
182+ recorder.append(content)
183+ response = HttpResponse(
184+ content=content,
185+ status=httplib.OK,
186+ mimetype=b"text/plain; charset=utf-8")
187+ handler._WebApplicationHandler__retry.add(response)
188+ return response
189+
190+ self.patch(
191+ WSGIHandler, "get_response", get_response_read_content_files)
192+
193+ body, headers = encode_multipart_data(
194+ [], [[file_name, io.BytesIO(file_content)]])
195+ env = {
196+ 'REQUEST_METHOD': 'POST',
197+ 'wsgi.input': wsgi._InputStream(io.BytesIO(body)),
198+ 'CONTENT_TYPE': headers['Content-Type'],
199+ 'CONTENT_LENGTH': headers['Content-Length'],
200+ 'HTTP_MIME_VERSION': headers['MIME-Version'],
201+ }
202+ request = WSGIRequest(env)
203+
204+ response = handler.get_response(request)
205+ self.assertEqual(file_content, response.content)
206+ self.assertEqual(recorder, [file_content] * 3)
207
208=== modified file 'src/maasserver/utils/views.py'
209--- src/maasserver/utils/views.py 2015-03-17 20:54:00 +0000
210+++ src/maasserver/utils/views.py 2015-03-25 11:31:43 +0000
211@@ -30,6 +30,7 @@
212 from provisioningserver.logger.log import get_maas_logger
213 from twisted.python import log
214 from twisted.python.failure import Failure
215+from twisted.web import wsgi
216
217
218 maaslog = get_maas_logger("views")
219@@ -41,13 +42,28 @@
220
221
222 def reset_request(request):
223- """Undo non-transaction changes to the request.
224-
225- Clear any message from the previous attempt. TODO: this assumes
226- we're using the cookies as a container for messages; we need to
227- clear the session as well.
228+ """Return a pristine new request object.
229+
230+ Use this after a transaction failure, before retrying.
231+
232+ This is needed so that we don't carry over messages, for example.
233+ TODO: this assumes we're using the cookies as a container for
234+ messages; we need to clear the session as well.
235+
236+ This also resets the input stream.
237 """
238- request.COOKIES.pop('messages', None)
239+ wsgi_input = request.environ.get("wsgi.input")
240+ if isinstance(wsgi_input, wsgi._InputStream):
241+ # This is what we are going to see within Twisted. The wrapped
242+ # file supports seeking so this is safe.
243+ wsgi_input._wrapped.seek(0)
244+ else:
245+ # Neither PEP 0333 nor PEP 3333 require that the input stream
246+ # supports seeking, but we need it, and it would be better that
247+ # this crashed here than continuing on if it's not available.
248+ wsgi_input.seek(0)
249+
250+ return request.__class__(request.environ)
251
252
253 class WebApplicationHandler(WSGIHandler):
254@@ -57,7 +73,6 @@
255 :ivar __retry: A weak set containing responses that have been generated as
256 a result of a serialization failure.
257 """
258-
259 def __init__(self, attempts=10):
260 super(WebApplicationHandler, self).__init__()
261 self.__attempts = attempts
262@@ -124,7 +139,7 @@
263 response = get_response(request)
264 if response in self.__retry:
265 log_retry(request, attempt)
266- reset_request(request)
267+ request = reset_request(request)
268 continue
269 else:
270 return response