Merge branch 'master' into 3620-admin-only-gear-menu
[arvados.git] / sdk / python / arvados / retry.py
1 #!/usr/bin/env python
2
3 import time
4
5 from collections import deque
6
7 import arvados.errors
8
9 _HTTP_SUCCESSES = set(xrange(200, 300))
10 _HTTP_CAN_RETRY = set([408, 409, 422, 423, 500, 502, 503, 504])
11
12 class RetryLoop(object):
13     """Coordinate limited retries of code.
14
15     RetryLoop coordinates a loop that runs until it records a
16     successful result or tries too many times, whichever comes first.
17     Typical use looks like:
18
19         loop = RetryLoop(num_retries=2)
20         for tries_left in loop:
21             try:
22                 result = do_something()
23             except TemporaryError as error:
24                 log("error: {} ({} tries left)".format(error, tries_left))
25             else:
26                 loop.save_result(result)
27         if loop.success():
28             return loop.last_result()
29     """
30     def __init__(self, num_retries, success_check=lambda r: True,
31                  backoff_start=0, backoff_growth=2, save_results=1):
32         """Construct a new RetryLoop.
33
34         Arguments:
35         * num_retries: The maximum number of times to retry the loop if it
36           doesn't succeed.  This means the loop could run at most 1+N times.
37         * success_check: This is a function that will be called each
38           time the loop saves a result.  The function should return
39           True if the result indicates loop success, False if it
40           represents a permanent failure state, and None if the loop
41           should continue.  If no function is provided, the loop will
42           end as soon as it records any result.
43         * backoff_start: The number of seconds that must pass before the
44           loop's second iteration.  Default 0, which disables all waiting.
45         * backoff_growth: The wait time multiplier after each iteration.
46           Default 2 (i.e., double the wait time each time).
47         * save_results: Specify a number to save the last N results
48           that the loop recorded.  These records are available through
49           the results attribute, oldest first.  Default 1.
50         """
51         self.tries_left = num_retries + 1
52         self.check_result = success_check
53         self.backoff_wait = backoff_start
54         self.backoff_growth = backoff_growth
55         self.next_start_time = 0
56         self.results = deque(maxlen=save_results)
57         self._running = None
58         self._success = None
59
60     def __iter__(self):
61         return self
62
63     def running(self):
64         return self._running and (self._success is None)
65
66     def next(self):
67         if self._running is None:
68             self._running = True
69         if (self.tries_left < 1) or not self.running():
70             self._running = False
71             raise StopIteration
72         else:
73             wait_time = max(0, self.next_start_time - time.time())
74             time.sleep(wait_time)
75             self.backoff_wait *= self.backoff_growth
76         self.next_start_time = time.time() + self.backoff_wait
77         self.tries_left -= 1
78         return self.tries_left
79
80     def save_result(self, result):
81         """Record a loop result.
82
83         Save the given result, and end the loop if it indicates
84         success or permanent failure.  See __init__'s documentation
85         about success_check to learn how to make that indication.
86         """
87         if not self.running():
88             raise arvados.errors.AssertionError(
89                 "recorded a loop result after the loop finished")
90         self.results.append(result)
91         self._success = self.check_result(result)
92
93     def success(self):
94         """Return the loop's end state.
95
96         Returns True if the loop obtained a successful result, False if it
97         encountered permanent failure, or else None.
98         """
99         return self._success
100
101     def last_result(self):
102         """Return the most recent result the loop recorded."""
103         try:
104             return self.results[-1]
105         except IndexError:
106             raise arvados.errors.AssertionError(
107                 "queried loop results before any were recorded")
108
109
110 def check_http_response_success(result):
111     """Convert an httplib2 request result to a loop control flag.
112
113     Pass this method the 2-tuple returned by httplib2.Http.request.  It
114     returns True if the response indicates success, None if it indicates
115     temporary failure, and False otherwise.  You can use this as the
116     success_check for a RetryLoop.
117
118     Implementation details:
119     * Any 2xx result returns True.
120     * A select few status codes, or any malformed responses, return None.
121       422 Unprocessable Entity is in this category.  This may not meet the
122       letter of the HTTP specification, but the Arvados API server will
123       use it for various server-side problems like database connection
124       errors.
125     * Everything else returns False.  Note that this includes 1xx and
126       3xx status codes.  They don't indicate success, and you can't
127       retry those requests verbatim.
128     """
129     try:
130         status = int(result[0].status)
131     except Exception:
132         return None
133     if status in _HTTP_SUCCESSES:
134         return True
135     elif status in _HTTP_CAN_RETRY:
136         return None
137     elif 100 <= status < 600:
138         return False
139     else:
140         return None  # Get well soon, server.