20846: Merge branch '19213-ubuntu2204-support' into 20846-ubuntu2204
[arvados.git] / sdk / python / arvados / retry.py
1 """Utilities to retry operations.
2
3 The core of this module is `RetryLoop`, a utility class to retry operations
4 that might fail. It can distinguish between temporary and permanent failures;
5 provide exponential backoff; and save a series of results.
6
7 It also provides utility functions for common operations with `RetryLoop`:
8
9 * `check_http_response_success` can be used as a `RetryLoop` `success_check`
10   for HTTP response codes from the Arvados API server.
11 * `retry_method` can decorate methods to provide a default `num_retries`
12   keyword argument.
13 """
14 # Copyright (C) The Arvados Authors. All rights reserved.
15 #
16 # SPDX-License-Identifier: Apache-2.0
17
18 from builtins import range
19 from builtins import object
20 import functools
21 import inspect
22 import pycurl
23 import time
24
25 from collections import deque
26
27 import arvados.errors
28
29 _HTTP_SUCCESSES = set(range(200, 300))
30 _HTTP_CAN_RETRY = set([408, 409, 423, 500, 502, 503, 504])
31
32 class RetryLoop(object):
33     """Coordinate limited retries of code.
34
35     `RetryLoop` coordinates a loop that runs until it records a
36     successful result or tries too many times, whichever comes first.
37     Typical use looks like:
38
39         loop = RetryLoop(num_retries=2)
40         for tries_left in loop:
41             try:
42                 result = do_something()
43             except TemporaryError as error:
44                 log("error: {} ({} tries left)".format(error, tries_left))
45             else:
46                 loop.save_result(result)
47         if loop.success():
48             return loop.last_result()
49
50     Arguments:
51
52     * num_retries: int --- The maximum number of times to retry the loop if
53       it doesn't succeed.  This means the loop body could run at most
54       `num_retries + 1` times.
55
56     * success_check: Callable --- This is a function that will be called
57       each time the loop saves a result.  The function should return `True`
58       if the result indicates the code succeeded, `False` if it represents a
59       permanent failure, and `None` if it represents a temporary failure.
60       If no function is provided, the loop will end after any result is
61       saved.
62
63     * backoff_start: float --- The number of seconds that must pass before
64       the loop's second iteration.  Default 0, which disables all waiting.
65
66     * backoff_growth: float --- The wait time multiplier after each
67       iteration.  Default 2 (i.e., double the wait time each time).
68
69     * save_results: int --- Specify a number to store that many saved
70       results from the loop.  These are available through the `results`
71       attribute, oldest first.  Default 1.
72
73     * max_wait: float --- Maximum number of seconds to wait between
74       retries. Default 60.
75     """
76     def __init__(self, num_retries, success_check=lambda r: True,
77                  backoff_start=0, backoff_growth=2, save_results=1,
78                  max_wait=60):
79         self.tries_left = num_retries + 1
80         self.check_result = success_check
81         self.backoff_wait = backoff_start
82         self.backoff_growth = backoff_growth
83         self.max_wait = max_wait
84         self.next_start_time = 0
85         self.results = deque(maxlen=save_results)
86         self._attempts = 0
87         self._running = None
88         self._success = None
89
90     def __iter__(self):
91         """Return an iterator of retries."""
92         return self
93
94     def running(self):
95         """Return whether this loop is running.
96
97         Returns `None` if the loop has never run, `True` if it is still running,
98         or `False` if it has stopped—whether that's because it has saved a
99         successful result, a permanent failure, or has run out of retries.
100         """
101         return self._running and (self._success is None)
102
103     def __next__(self):
104         """Record a loop attempt.
105
106         If the loop is still running, decrements the number of tries left and
107         returns it. Otherwise, raises `StopIteration`.
108         """
109         if self._running is None:
110             self._running = True
111         if (self.tries_left < 1) or not self.running():
112             self._running = False
113             raise StopIteration
114         else:
115             wait_time = max(0, self.next_start_time - time.time())
116             time.sleep(wait_time)
117             self.backoff_wait *= self.backoff_growth
118             if self.backoff_wait > self.max_wait:
119                 self.backoff_wait = self.max_wait
120         self.next_start_time = time.time() + self.backoff_wait
121         self.tries_left -= 1
122         return self.tries_left
123
124     def save_result(self, result):
125         """Record a loop result.
126
127         Save the given result, and end the loop if it indicates
128         success or permanent failure. See documentation for the `__init__`
129         `success_check` argument to learn how that's indicated.
130
131         Raises `arvados.errors.AssertionError` if called after the loop has
132         already ended.
133
134         Arguments:
135
136         * result: Any --- The result from this loop attempt to check and
137         save.
138         """
139         if not self.running():
140             raise arvados.errors.AssertionError(
141                 "recorded a loop result after the loop finished")
142         self.results.append(result)
143         self._success = self.check_result(result)
144         self._attempts += 1
145
146     def success(self):
147         """Return the loop's end state.
148
149         Returns `True` if the loop recorded a successful result, `False` if it
150         recorded permanent failure, or else `None`.
151         """
152         return self._success
153
154     def last_result(self):
155         """Return the most recent result the loop saved.
156
157         Raises `arvados.errors.AssertionError` if called before any result has
158         been saved.
159         """
160         try:
161             return self.results[-1]
162         except IndexError:
163             raise arvados.errors.AssertionError(
164                 "queried loop results before any were recorded")
165
166     def attempts(self):
167         """Return the number of results that have been saved.
168
169         This count includes all kinds of results: success, permanent failure,
170         and temporary failure.
171         """
172         return self._attempts
173
174     def attempts_str(self):
175         """Return a human-friendly string counting saved results.
176
177         This method returns '1 attempt' or 'N attempts', where the number
178         in the string is the number of saved results.
179         """
180         if self._attempts == 1:
181             return '1 attempt'
182         else:
183             return '{} attempts'.format(self._attempts)
184
185
186 def check_http_response_success(status_code):
187     """Convert a numeric HTTP status code to a loop control flag.
188
189     This method takes a numeric HTTP status code and returns `True` if
190     the code indicates success, `None` if it indicates temporary
191     failure, and `False` otherwise.  You can use this as the
192     `success_check` for a `RetryLoop` that queries the Arvados API server.
193     Specifically:
194
195     * Any 2xx result returns `True`.
196
197     * A select few status codes, or any malformed responses, return `None`.
198
199     * Everything else returns `False`.  Note that this includes 1xx and
200       3xx status codes.  They don't indicate success, and you can't
201       retry those requests verbatim.
202
203     Arguments:
204
205     * status_code: int --- A numeric HTTP response code
206     """
207     if status_code in _HTTP_SUCCESSES:
208         return True
209     elif status_code in _HTTP_CAN_RETRY:
210         return None
211     elif 100 <= status_code < 600:
212         return False
213     else:
214         return None  # Get well soon, server.
215
216 def retry_method(orig_func):
217     """Provide a default value for a method's num_retries argument.
218
219     This is a decorator for instance and class methods that accept a
220     `num_retries` keyword argument, with a `None` default.  When the method
221     is called without a value for `num_retries`, this decorator will set it
222     from the `num_retries` attribute of the underlying instance or class.
223
224     Arguments:
225
226     * orig_func: Callable --- A class or instance method that accepts a
227     `num_retries` keyword argument
228     """
229     @functools.wraps(orig_func)
230     def num_retries_setter(self, *args, **kwargs):
231         if kwargs.get('num_retries') is None:
232             kwargs['num_retries'] = self.num_retries
233         return orig_func(self, *args, **kwargs)
234     return num_retries_setter