Merge lp:~cjwatson/launchpad/reduce-mp-timeouts into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18131
Proposed branch: lp:~cjwatson/launchpad/reduce-mp-timeouts
Merge into: lp:launchpad
Diff against target: 719 lines (+241/-88)
9 files modified
lib/lp/code/browser/branchmergeproposal.py (+27/-2)
lib/lp/code/browser/tests/test_branchmergeproposal.py (+12/-0)
lib/lp/code/model/diff.py (+10/-23)
lib/lp/code/model/githosting.py (+34/-17)
lib/lp/code/model/tests/test_diff.py (+45/-22)
lib/lp/code/model/tests/test_githosting.py (+38/-24)
lib/lp/services/tests/test_timeout.py (+36/-0)
lib/lp/services/timeout.py (+38/-0)
setup.py (+1/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/reduce-mp-timeouts
Reviewer Review Type Date Requested Status
Maximiliano Bertacchini (community) Approve
Launchpad code reviewers Pending
Review via email: mp+298967@code.launchpad.net

Commit message

Reduce timeout for Git hosting calls when rendering merge proposals.

Description of the change

Reduce timeout for Git hosting calls when rendering merge proposals. This means that we can cope with the backend taking longer than our timeout in some edge cases, falling back to leaving those parts of the rendered page empty.

This is based on an approach already used for fetching diffs from the librarian, but I took the opportunity to turn that into a generic reduced_timeout context manager since it seems likely that we'll find uses for it elsewhere.

To post a comment you must log in.
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

Looks good to me! Thanks

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/branchmergeproposal.py'
2--- lib/lp/code/browser/branchmergeproposal.py 2016-05-14 09:54:32 +0000
3+++ lib/lp/code/browser/branchmergeproposal.py 2016-07-01 22:03:52 +0000
4@@ -107,6 +107,11 @@
5 cachedproperty,
6 get_property_cache,
7 )
8+from lp.services.scripts import log
9+from lp.services.timeout import (
10+ reduced_timeout,
11+ TimeoutError,
12+ )
13 from lp.services.webapp import (
14 canonical_url,
15 ContextMenu,
16@@ -343,7 +348,17 @@
17 @cachedproperty
18 def unlanded_revisions(self):
19 """Return the unlanded revisions from the source branch."""
20- return self.context.getUnlandedSourceBranchRevisions()
21+ with reduced_timeout(1.0, webapp_max=5.0):
22+ try:
23+ return self.context.getUnlandedSourceBranchRevisions()
24+ except TimeoutError:
25+ log.exception(
26+ "Timeout fetching unlanded source revisions for merge "
27+ "proposal %s (%s => %s)" % (
28+ self.context.id,
29+ self.context.merge_source.identity,
30+ self.context.merge_target.identity))
31+ return []
32
33 @property
34 def pending_writes(self):
35@@ -615,7 +630,17 @@
36 """Return a conversation that is to be rendered."""
37 # Sort the comments by date order.
38 merge_proposal = self.context
39- groups = list(merge_proposal.getRevisionsSinceReviewStart())
40+ with reduced_timeout(1.0, webapp_max=5.0):
41+ try:
42+ groups = list(merge_proposal.getRevisionsSinceReviewStart())
43+ except TimeoutError:
44+ log.exception(
45+ "Timeout fetching revisions since review start for "
46+ "merge proposal %s (%s => %s)" % (
47+ merge_proposal.id,
48+ merge_proposal.merge_source.identity,
49+ merge_proposal.merge_target.identity))
50+ groups = []
51 source = merge_proposal.merge_source
52 if IBranch.providedBy(source):
53 source = DecoratedBranch(source)
54
55=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
56--- lib/lp/code/browser/tests/test_branchmergeproposal.py 2016-06-03 14:08:52 +0000
57+++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2016-07-01 22:03:52 +0000
58@@ -14,6 +14,7 @@
59 import hashlib
60 import re
61
62+from fixtures import FakeLogger
63 from lazr.lifecycle.event import ObjectModifiedEvent
64 from lazr.restful.interfaces import IJSONRequestCache
65 import pytz
66@@ -77,6 +78,7 @@
67 )
68 from lp.services.librarian.interfaces.client import LibrarianServerError
69 from lp.services.messages.model.message import MessageSet
70+from lp.services.timeout import TimeoutError
71 from lp.services.webapp import canonical_url
72 from lp.services.webapp.interfaces import BrowserNotificationLevel
73 from lp.services.webapp.servers import LaunchpadTestRequest
74@@ -1914,6 +1916,16 @@
75 self.assertThat(browser.contents, Not(has_read_more))
76 self.assertEqual(mp_url, browser.url)
77
78+ def test_timeout(self):
79+ """The page renders even if fetching revisions times out."""
80+ self.useFixture(FakeLogger())
81+ bmp = self.factory.makeBranchMergeProposalForGit()
82+ comment = self.factory.makeCodeReviewComment(
83+ body='x y' * 100, merge_proposal=bmp)
84+ self.hosting_client.getLog.failure = TimeoutError
85+ browser = self.getViewBrowser(comment.branch_merge_proposal)
86+ self.assertIn('x y' * 100, browser.contents)
87+
88
89 class TestLatestProposalsForEachBranchMixin:
90 """Confirm that the latest branch is returned."""
91
92=== modified file 'lib/lp/code/model/diff.py'
93--- lib/lp/code/model/diff.py 2015-08-28 15:04:26 +0000
94+++ lib/lp/code/model/diff.py 2016-07-01 22:03:52 +0000
95@@ -1,4 +1,4 @@
96-# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
97+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
98 # GNU Affero General Public License version 3 (see the file LICENSE).
99
100 """Implementation classes for IDiff, etc."""
101@@ -58,7 +58,10 @@
102 LIBRARIAN_SERVER_DEFAULT_TIMEOUT,
103 )
104 from lp.services.propertycache import get_property_cache
105-from lp.services.webapp.adapter import get_request_remaining_seconds
106+from lp.services.timeout import (
107+ get_default_timeout_function,
108+ reduced_timeout,
109+ )
110
111
112 @implementer(IDiff)
113@@ -97,32 +100,16 @@
114 if self.diff_text is None:
115 return ''
116 else:
117- self.diff_text.open(self._getDiffTimeout())
118+ with reduced_timeout(
119+ 0.01, webapp_max=2.0,
120+ default=LIBRARIAN_SERVER_DEFAULT_TIMEOUT):
121+ timeout = get_default_timeout_function()()
122+ self.diff_text.open(timeout)
123 try:
124 return self.diff_text.read(config.diff.max_read_size)
125 finally:
126 self.diff_text.close()
127
128- def _getDiffTimeout(self):
129- """Return the seconds allocated to get the diff from the librarian.
130-
131- the value will be Non for scripts, 2 for the webapp, or if thre is
132- little request time left, the number will be smaller or equal to
133- the remaining request time.
134- """
135- remaining = get_request_remaining_seconds()
136- if remaining is None:
137- return LIBRARIAN_SERVER_DEFAULT_TIMEOUT
138- elif remaining > 2.0:
139- # The maximum permitted time for webapp requests.
140- return 2.0
141- elif remaining > 0.01:
142- # Shave off 1 hundreth of a second off so that the call site
143- # has a chance to recover.
144- return remaining - 0.01
145- else:
146- return remaining
147-
148 @property
149 def oversized(self):
150 # If the size of the content of the librarian file is over the
151
152=== modified file 'lib/lp/code/model/githosting.py'
153--- lib/lp/code/model/githosting.py 2016-05-19 14:54:33 +0000
154+++ lib/lp/code/model/githosting.py 2016-07-01 22:03:52 +0000
155@@ -9,11 +9,13 @@
156 ]
157
158 import json
159+import sys
160 from urllib import quote
161 from urlparse import urljoin
162
163 from lazr.restful.utils import get_current_browser_request
164 import requests
165+from six import reraise
166 from zope.interface import implementer
167
168 from lp.code.errors import (
169@@ -24,11 +26,14 @@
170 from lp.code.interfaces.githosting import IGitHostingClient
171 from lp.services.config import config
172 from lp.services.timeline.requesttimeline import get_request_timeline
173-from lp.services.timeout import urlfetch
174-
175-
176-class HTTPResponseNotOK(Exception):
177- pass
178+from lp.services.timeout import (
179+ TimeoutError,
180+ urlfetch,
181+ )
182+
183+
184+class RequestExceptionWrapper(requests.RequestException):
185+ """A non-requests exception that occurred during a request."""
186
187
188 @implementer(IGitHostingClient)
189@@ -46,10 +51,18 @@
190 response = urlfetch(
191 urljoin(self.endpoint, path), trust_env=False, method=method,
192 **kwargs)
193- except requests.HTTPError as e:
194- raise HTTPResponseNotOK(e.response.content)
195+ except TimeoutError:
196+ # Re-raise this directly so that it can be handled specially by
197+ # callers.
198+ raise
199+ except Exception:
200+ _, val, tb = sys.exc_info()
201+ reraise(
202+ RequestExceptionWrapper, RequestExceptionWrapper(*val.args),
203+ tb)
204 finally:
205 action.finish()
206+ response.raise_for_status()
207 if response.content:
208 return response.json()
209 else:
210@@ -75,7 +88,7 @@
211 else:
212 request = {"repo_path": path}
213 self._post("/repo", json=request)
214- except Exception as e:
215+ except requests.RequestException as e:
216 raise GitRepositoryCreationFault(
217 "Failed to create Git repository: %s" % unicode(e))
218
219@@ -83,7 +96,7 @@
220 """See `IGitHostingClient`."""
221 try:
222 return self._get("/repo/%s" % path)
223- except Exception as e:
224+ except requests.RequestException as e:
225 raise GitRepositoryScanFault(
226 "Failed to get properties of Git repository: %s" % unicode(e))
227
228@@ -91,7 +104,7 @@
229 """See `IGitHostingClient`."""
230 try:
231 self._patch("/repo/%s" % path, json=props)
232- except Exception as e:
233+ except requests.RequestException as e:
234 raise GitRepositoryScanFault(
235 "Failed to set properties of Git repository: %s" % unicode(e))
236
237@@ -99,7 +112,7 @@
238 """See `IGitHostingClient`."""
239 try:
240 return self._get("/repo/%s/refs" % path)
241- except Exception as e:
242+ except requests.RequestException as e:
243 raise GitRepositoryScanFault(
244 "Failed to get refs from Git repository: %s" % unicode(e))
245
246@@ -111,7 +124,7 @@
247 logger.info("Requesting commit details for %s" % commit_oids)
248 return self._post(
249 "/repo/%s/commits" % path, json={"commits": commit_oids})
250- except Exception as e:
251+ except requests.RequestException as e:
252 raise GitRepositoryScanFault(
253 "Failed to get commit details from Git repository: %s" %
254 unicode(e))
255@@ -127,7 +140,7 @@
256 return self._get(
257 "/repo/%s/log/%s" % (path, quote(start)),
258 params={"limit": limit, "stop": stop})
259- except Exception as e:
260+ except requests.RequestException as e:
261 raise GitRepositoryScanFault(
262 "Failed to get commit log from Git repository: %s" %
263 unicode(e))
264@@ -143,7 +156,7 @@
265 url = "/repo/%s/compare/%s%s%s" % (
266 path, quote(old), separator, quote(new))
267 return self._get(url, params={"context_lines": context_lines})
268- except Exception as e:
269+ except requests.RequestException as e:
270 raise GitRepositoryScanFault(
271 "Failed to get diff from Git repository: %s" % unicode(e))
272
273@@ -157,7 +170,7 @@
274 url = "/repo/%s/compare-merge/%s:%s" % (
275 path, quote(base), quote(head))
276 return self._get(url, params={"sha1_prerequisite": prerequisite})
277- except Exception as e:
278+ except requests.RequestException as e:
279 raise GitRepositoryScanFault(
280 "Failed to get merge diff from Git repository: %s" %
281 unicode(e))
282@@ -173,7 +186,7 @@
283 return self._post(
284 "/repo/%s/detect-merges/%s" % (path, quote(target)),
285 json={"sources": sources})
286- except Exception as e:
287+ except requests.RequestException as e:
288 raise GitRepositoryScanFault(
289 "Failed to detect merges in Git repository: %s" % unicode(e))
290
291@@ -183,7 +196,7 @@
292 if logger is not None:
293 logger.info("Deleting repository %s" % path)
294 self._delete("/repo/%s" % path)
295- except Exception as e:
296+ except requests.RequestException as e:
297 raise GitRepositoryDeletionFault(
298 "Failed to delete Git repository: %s" % unicode(e))
299
300@@ -195,6 +208,10 @@
301 "Fetching file %s from repository %s" % (filename, path))
302 url = "/repo/%s/blob/%s" % (path, quote(filename))
303 response = self._get(url, params={"rev": rev})
304+ except requests.RequestException as e:
305+ raise GitRepositoryScanFault(
306+ "Failed to get file from Git repository: %s" % unicode(e))
307+ try:
308 blob = response["data"].decode("base64")
309 if len(blob) != response["size"]:
310 raise GitRepositoryScanFault(
311
312=== modified file 'lib/lp/code/model/tests/test_diff.py'
313--- lib/lp/code/model/tests/test_diff.py 2015-10-27 23:35:50 +0000
314+++ lib/lp/code/model/tests/test_diff.py 2016-07-01 22:03:52 +0000
315@@ -1,4 +1,4 @@
316-# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
317+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
318 # GNU Affero General Public License version 3 (see the file LICENSE).
319
320 """Tests for Diff, etc."""
321@@ -35,11 +35,19 @@
322 from lp.services.librarian.interfaces.client import (
323 LIBRARIAN_SERVER_DEFAULT_TIMEOUT,
324 )
325+from lp.services.timeout import (
326+ get_default_timeout_function,
327+ set_default_timeout_function,
328+ )
329 from lp.services.webapp import canonical_url
330+from lp.services.webapp.adapter import (
331+ clear_request_started,
332+ set_request_started,
333+ )
334+from lp.services.webapp.servers import LaunchpadTestRequest
335 from lp.testing import (
336 login,
337 login_person,
338- monkey_patch,
339 TestCaseWithFactory,
340 verifyObject,
341 )
342@@ -223,29 +231,44 @@
343 diff = self._create_diff(content)
344 self.assertTrue(diff.oversized)
345
346- def test_getDiffTimeout(self):
347+ def test_timeout(self):
348 # The time permitted to get the diff from the librarian may be None,
349 # or 2. If there is not 2 seconds left in the request, the number will
350- # will 0.01 smaller or the actual remaining time.
351- content = ''.join(unified_diff('', "1234567890" * 10))
352- diff = self._create_diff(content)
353+ # be 0.01 smaller or the actual remaining time.
354+ class DiffWithFakeText(Diff):
355+ diff_text = FakeMethod()
356+
357+ diff = DiffWithFakeText()
358+ diff.diff_text.open = FakeMethod()
359+ diff.diff_text.read = FakeMethod()
360+ diff.diff_text.close = FakeMethod()
361 value = None
362-
363- def fake():
364- return value
365-
366- from lp.code.model import diff as diff_module
367- with monkey_patch(diff_module, get_request_remaining_seconds=fake):
368- self.assertIs(
369- LIBRARIAN_SERVER_DEFAULT_TIMEOUT, diff._getDiffTimeout())
370- value = 3.1
371- self.assertEqual(2.0, diff._getDiffTimeout())
372- value = 1.11
373- self.assertEqual(1.1, diff._getDiffTimeout())
374- value = 0.11
375- self.assertEqual(0.1, diff._getDiffTimeout())
376- value = 0.01
377- self.assertEqual(0.01, diff._getDiffTimeout())
378+ original_timeout_function = get_default_timeout_function()
379+ set_default_timeout_function(lambda: value)
380+ try:
381+ LaunchpadTestRequest()
382+ set_request_started()
383+ try:
384+ diff.text
385+ self.assertEqual(
386+ LIBRARIAN_SERVER_DEFAULT_TIMEOUT,
387+ diff.diff_text.open.calls[-1][0][0])
388+ value = 3.1
389+ diff.text
390+ self.assertEqual(2.0, diff.diff_text.open.calls[-1][0][0])
391+ value = 1.11
392+ diff.text
393+ self.assertEqual(1.1, diff.diff_text.open.calls[-1][0][0])
394+ value = 0.11
395+ diff.text
396+ self.assertEqual(0.1, diff.diff_text.open.calls[-1][0][0])
397+ value = 0.01
398+ diff.text
399+ self.assertEqual(0.01, diff.diff_text.open.calls[-1][0][0])
400+ finally:
401+ clear_request_started()
402+ finally:
403+ set_default_timeout_function(original_timeout_function)
404
405
406 class TestDiffInScripts(DiffTestCase):
407
408=== modified file 'lib/lp/code/model/tests/test_githosting.py'
409--- lib/lp/code/model/tests/test_githosting.py 2016-06-06 11:09:00 +0000
410+++ lib/lp/code/model/tests/test_githosting.py 2016-07-01 22:03:52 +0000
411@@ -64,13 +64,17 @@
412 self.request = None
413
414 @contextmanager
415- def mockRequests(self, status_code=200, content=b"",
416+ def mockRequests(self, status_code=200, content=b"", reason=None,
417 set_default_timeout=True):
418 @all_requests
419 def handler(url, request):
420 self.assertIsNone(self.request)
421 self.request = request
422- return {"status_code": status_code, "content": content}
423+ return {
424+ "status_code": status_code,
425+ "content": content,
426+ "reason": reason,
427+ }
428
429 with HTTMock(handler):
430 original_timeout_function = get_default_timeout_function()
431@@ -106,10 +110,11 @@
432 json_data={"repo_path": "123", "clone_from": "122"})
433
434 def test_create_failure(self):
435- with self.mockRequests(status_code=400, content=b"Bad request"):
436+ with self.mockRequests(status_code=400, reason=b"Bad request"):
437 self.assertRaisesWithContent(
438 GitRepositoryCreationFault,
439- "Failed to create Git repository: Bad request",
440+ "Failed to create Git repository: "
441+ "400 Client Error: Bad request",
442 self.client.create, "123")
443
444 def test_getProperties(self):
445@@ -120,10 +125,11 @@
446 self.assertRequest("repo/123", method="GET")
447
448 def test_getProperties_failure(self):
449- with self.mockRequests(status_code=400, content=b"Bad request"):
450+ with self.mockRequests(status_code=400, reason=b"Bad request"):
451 self.assertRaisesWithContent(
452 GitRepositoryScanFault,
453- "Failed to get properties of Git repository: Bad request",
454+ "Failed to get properties of Git repository: "
455+ "400 Client Error: Bad request",
456 self.client.getProperties, "123")
457
458 def test_setProperties(self):
459@@ -134,10 +140,11 @@
460 json_data={"default_branch": "refs/heads/a"})
461
462 def test_setProperties_failure(self):
463- with self.mockRequests(status_code=400, content=b"Bad request"):
464+ with self.mockRequests(status_code=400, reason=b"Bad request"):
465 self.assertRaisesWithContent(
466 GitRepositoryScanFault,
467- "Failed to set properties of Git repository: Bad request",
468+ "Failed to set properties of Git repository: "
469+ "400 Client Error: Bad request",
470 self.client.setProperties, "123",
471 default_branch="refs/heads/a")
472
473@@ -148,10 +155,11 @@
474 self.assertRequest("repo/123/refs", method="GET")
475
476 def test_getRefs_failure(self):
477- with self.mockRequests(status_code=400, content=b"Bad request"):
478+ with self.mockRequests(status_code=400, reason=b"Bad request"):
479 self.assertRaisesWithContent(
480 GitRepositoryScanFault,
481- "Failed to get refs from Git repository: Bad request",
482+ "Failed to get refs from Git repository: "
483+ "400 Client Error: Bad request",
484 self.client.getRefs, "123")
485
486 def test_getCommits(self):
487@@ -162,11 +170,11 @@
488 "repo/123/commits", method="POST", json_data={"commits": ["0"]})
489
490 def test_getCommits_failure(self):
491- with self.mockRequests(status_code=400, content=b"Bad request"):
492+ with self.mockRequests(status_code=400, reason=b"Bad request"):
493 self.assertRaisesWithContent(
494 GitRepositoryScanFault,
495 "Failed to get commit details from Git repository: "
496- "Bad request",
497+ "400 Client Error: Bad request",
498 self.client.getCommits, "123", ["0"])
499
500 def test_getLog(self):
501@@ -185,10 +193,11 @@
502 method="GET")
503
504 def test_getLog_failure(self):
505- with self.mockRequests(status_code=400, content=b"Bad request"):
506+ with self.mockRequests(status_code=400, reason=b"Bad request"):
507 self.assertRaisesWithContent(
508 GitRepositoryScanFault,
509- "Failed to get commit log from Git repository: Bad request",
510+ "Failed to get commit log from Git repository: "
511+ "400 Client Error: Bad request",
512 self.client.getLog, "123", "refs/heads/master")
513
514 def test_getDiff(self):
515@@ -211,10 +220,11 @@
516 "repo/123/compare/a..b?context_lines=4", method="GET")
517
518 def test_getDiff_failure(self):
519- with self.mockRequests(status_code=400, content=b"Bad request"):
520+ with self.mockRequests(status_code=400, reason=b"Bad request"):
521 self.assertRaisesWithContent(
522 GitRepositoryScanFault,
523- "Failed to get diff from Git repository: Bad request",
524+ "Failed to get diff from Git repository: "
525+ "400 Client Error: Bad request",
526 self.client.getDiff, "123", "a", "b")
527
528 def test_getMergeDiff(self):
529@@ -242,10 +252,11 @@
530 self.assertRequest("repo/123/compare-merge/a:b", method="GET")
531
532 def test_getMergeDiff_failure(self):
533- with self.mockRequests(status_code=400, content=b"Bad request"):
534+ with self.mockRequests(status_code=400, reason=b"Bad request"):
535 self.assertRaisesWithContent(
536 GitRepositoryScanFault,
537- "Failed to get merge diff from Git repository: Bad request",
538+ "Failed to get merge diff from Git repository: "
539+ "400 Client Error: Bad request",
540 self.client.getMergeDiff, "123", "a", "b")
541
542 def test_detectMerges(self):
543@@ -257,10 +268,11 @@
544 json_data={"sources": ["b", "c"]})
545
546 def test_detectMerges_failure(self):
547- with self.mockRequests(status_code=400, content=b"Bad request"):
548+ with self.mockRequests(status_code=400, reason=b"Bad request"):
549 self.assertRaisesWithContent(
550 GitRepositoryScanFault,
551- "Failed to detect merges in Git repository: Bad request",
552+ "Failed to detect merges in Git repository: "
553+ "400 Client Error: Bad request",
554 self.client.detectMerges, "123", "a", ["b", "c"])
555
556 def test_delete(self):
557@@ -269,10 +281,11 @@
558 self.assertRequest("repo/123", method="DELETE")
559
560 def test_delete_failed(self):
561- with self.mockRequests(status_code=400, content=b"Bad request"):
562+ with self.mockRequests(status_code=400, reason=b"Bad request"):
563 self.assertRaisesWithContent(
564 GitRepositoryDeletionFault,
565- "Failed to delete Git repository: Bad request",
566+ "Failed to delete Git repository: "
567+ "400 Client Error: Bad request",
568 self.client.delete, "123")
569
570 def test_getBlob(self):
571@@ -294,10 +307,11 @@
572 "repo/123/blob/dir/path/file/name?rev=dev", method="GET")
573
574 def test_getBlob_failure(self):
575- with self.mockRequests(status_code=400, content=b"Bad request"):
576+ with self.mockRequests(status_code=400, reason=b"Bad request"):
577 self.assertRaisesWithContent(
578 GitRepositoryScanFault,
579- "Failed to get file from Git repository: Bad request",
580+ "Failed to get file from Git repository: "
581+ "400 Client Error: Bad request",
582 self.client.getBlob, "123", "dir/path/file/name")
583
584 def test_getBlob_url_quoting(self):
585
586=== modified file 'lib/lp/services/tests/test_timeout.py'
587--- lib/lp/services/tests/test_timeout.py 2016-04-19 12:16:01 +0000
588+++ lib/lp/services/tests/test_timeout.py 2016-07-01 22:03:52 +0000
589@@ -23,12 +23,18 @@
590
591 from lp.services.timeout import (
592 get_default_timeout_function,
593+ reduced_timeout,
594 set_default_timeout_function,
595 TimeoutError,
596 TransportWithTimeout,
597 urlfetch,
598 with_timeout,
599 )
600+from lp.services.webapp.adapter import (
601+ clear_request_started,
602+ set_request_started,
603+ )
604+from lp.services.webapp.servers import LaunchpadTestRequest
605 from lp.testing import TestCase
606
607
608@@ -201,6 +207,36 @@
609 no_default_timeout()
610 self.assertEqual([True], using_default)
611
612+ def test_reduced_timeout(self):
613+ """reduced_timeout caps the available timeout in various ways."""
614+ self.addCleanup(set_default_timeout_function, None)
615+ with reduced_timeout(1.0):
616+ self.assertIsNone(get_default_timeout_function()())
617+ with reduced_timeout(1.0, default=5.0):
618+ self.assertEqual(5.0, get_default_timeout_function()())
619+ set_default_timeout_function(lambda: 5.0)
620+ with reduced_timeout(1.0):
621+ self.assertEqual(4.0, get_default_timeout_function()())
622+ with reduced_timeout(1.0, default=5.0, webapp_max=2.0):
623+ self.assertEqual(4.0, get_default_timeout_function()())
624+ with reduced_timeout(1.0, default=5.0, webapp_max=6.0):
625+ self.assertEqual(4.0, get_default_timeout_function()())
626+ with reduced_timeout(6.0, default=5.0, webapp_max=2.0):
627+ self.assertEqual(5.0, get_default_timeout_function()())
628+ LaunchpadTestRequest()
629+ set_request_started()
630+ try:
631+ with reduced_timeout(1.0):
632+ self.assertEqual(4.0, get_default_timeout_function()())
633+ with reduced_timeout(1.0, default=5.0, webapp_max=2.0):
634+ self.assertEqual(2.0, get_default_timeout_function()())
635+ with reduced_timeout(1.0, default=5.0, webapp_max=6.0):
636+ self.assertEqual(4.0, get_default_timeout_function()())
637+ with reduced_timeout(6.0, default=5.0, webapp_max=2.0):
638+ self.assertEqual(2.0, get_default_timeout_function()())
639+ finally:
640+ clear_request_started()
641+
642 def make_test_socket(self):
643 """One common use case for timing out is when making an HTTP request
644 to an external site to fetch content. To this end, the timeout
645
646=== modified file 'lib/lp/services/timeout.py'
647--- lib/lp/services/timeout.py 2016-04-19 17:18:39 +0000
648+++ lib/lp/services/timeout.py 2016-07-01 22:03:52 +0000
649@@ -6,6 +6,7 @@
650 __metaclass__ = type
651 __all__ = [
652 "get_default_timeout_function",
653+ "reduced_timeout",
654 "SafeTransportWithTimeout",
655 "set_default_timeout_function",
656 "TimeoutError",
657@@ -14,6 +15,7 @@
658 "with_timeout",
659 ]
660
661+from contextlib import contextmanager
662 import socket
663 import sys
664 from threading import (
665@@ -53,6 +55,42 @@
666 default_timeout_function = timeout_function
667
668
669+@contextmanager
670+def reduced_timeout(clearance, webapp_max=None, default=None):
671+ """A context manager that reduces the default timeout.
672+
673+ :param clearance: The number of seconds by which to reduce the default
674+ timeout, to give the call site a chance to recover.
675+ :param webapp_max: The maximum permitted time for webapp requests.
676+ :param default: The default timeout to use if none is set.
677+ """
678+ # Circular import.
679+ from lp.services.webapp.adapter import get_request_start_time
680+
681+ original_timeout_function = get_default_timeout_function()
682+
683+ def timeout():
684+ if original_timeout_function is None:
685+ return default
686+ remaining = original_timeout_function()
687+
688+ if remaining is None:
689+ return default
690+ elif (webapp_max is not None and remaining > webapp_max and
691+ get_request_start_time() is not None):
692+ return webapp_max
693+ elif remaining > clearance:
694+ return remaining - clearance
695+ else:
696+ return remaining
697+
698+ set_default_timeout_function(timeout)
699+ try:
700+ yield
701+ finally:
702+ set_default_timeout_function(original_timeout_function)
703+
704+
705 class TimeoutError(Exception):
706 """Exception raised when a function doesn't complete within time."""
707
708
709=== modified file 'setup.py'
710--- setup.py 2016-05-18 14:54:44 +0000
711+++ setup.py 2016-07-01 22:03:52 +0000
712@@ -90,6 +90,7 @@
713 's4',
714 'setproctitle',
715 'setuptools',
716+ 'six',
717 'Sphinx',
718 'soupmatchers',
719 'sourcecodegen',