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