Merge branch '8488-cwl-crunchrunner-collection' closes #8488
[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         """Construct a new RetryLoop.
36
37         Arguments:
38         * num_retries: The maximum number of times to retry the loop if it
39           doesn't succeed.  This means the loop could run at most 1+N times.
40         * success_check: This is a function that will be called each
41           time the loop saves a result.  The function should return
42           True if the result indicates loop success, False if it
43           represents a permanent failure state, and None if the loop
44           should continue.  If no function is provided, the loop will
45           end as soon as it records any result.
46         * backoff_start: The number of seconds that must pass before the
47           loop's second iteration.  Default 0, which disables all waiting.
48         * backoff_growth: The wait time multiplier after each iteration.
49           Default 2 (i.e., double the wait time each time).
50         * save_results: Specify a number to save the last N results
51           that the loop recorded.  These records are available through
52           the results attribute, oldest first.  Default 1.
53         """
54         self.tries_left = num_retries + 1
55         self.check_result = success_check
56         self.backoff_wait = backoff_start
57         self.backoff_growth = backoff_growth
58         self.next_start_time = 0
59         self.results = deque(maxlen=save_results)
60         self._running = None
61         self._success = None
62
63     def __iter__(self):
64         return self
65
66     def running(self):
67         return self._running and (self._success is None)
68
69     def next(self):
70         if self._running is None:
71             self._running = True
72         if (self.tries_left < 1) or not self.running():
73             self._running = False
74             raise StopIteration
75         else:
76             wait_time = max(0, self.next_start_time - time.time())
77             time.sleep(wait_time)
78             self.backoff_wait *= self.backoff_growth
79         self.next_start_time = time.time() + self.backoff_wait
80         self.tries_left -= 1
81         return self.tries_left
82
83     def save_result(self, result):
84         """Record a loop result.
85
86         Save the given result, and end the loop if it indicates
87         success or permanent failure.  See __init__'s documentation
88         about success_check to learn how to make that indication.
89         """
90         if not self.running():
91             raise arvados.errors.AssertionError(
92                 "recorded a loop result after the loop finished")
93         self.results.append(result)
94         self._success = self.check_result(result)
95
96     def success(self):
97         """Return the loop's end state.
98
99         Returns True if the loop obtained a successful result, False if it
100         encountered permanent failure, or else None.
101         """
102         return self._success
103
104     def last_result(self):
105         """Return the most recent result the loop recorded."""
106         try:
107             return self.results[-1]
108         except IndexError:
109             raise arvados.errors.AssertionError(
110                 "queried loop results before any were recorded")
111
112
113 def check_http_response_success(status_code):
114     """Convert an HTTP status code to a loop control flag.
115
116     Pass this method a numeric HTTP status code.  It returns True if
117     the code indicates success, None if it indicates temporary
118     failure, and False otherwise.  You can use this as the
119     success_check for a RetryLoop.
120
121     Implementation details:
122     * Any 2xx result returns True.
123     * A select few status codes, or any malformed responses, return None.
124       422 Unprocessable Entity is in this category.  This may not meet the
125       letter of the HTTP specification, but the Arvados API server will
126       use it for various server-side problems like database connection
127       errors.
128     * Everything else returns False.  Note that this includes 1xx and
129       3xx status codes.  They don't indicate success, and you can't
130       retry those requests verbatim.
131     """
132     if status_code in _HTTP_SUCCESSES:
133         return True
134     elif status_code in _HTTP_CAN_RETRY:
135         return None
136     elif 100 <= status_code < 600:
137         return False
138     else:
139         return None  # Get well soon, server.
140
141 def retry_method(orig_func):
142     """Provide a default value for a method's num_retries argument.
143
144     This is a decorator for instance and class methods that accept a
145     num_retries argument, with a None default.  When the method is called
146     without a value for num_retries, it will be set from the underlying
147     instance or class' num_retries attribute.
148     """
149     @functools.wraps(orig_func)
150     def num_retries_setter(self, *args, **kwargs):
151         if kwargs.get('num_retries') is None:
152             kwargs['num_retries'] = self.num_retries
153         return orig_func(self, *args, **kwargs)
154     return num_retries_setter