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