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