pyrelukko 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pyrelukko might be problematic. Click here for more details.
- pyrelukko-0.1.0/.pylintrc.toml +4 -0
- pyrelukko-0.1.0/LICENSE +21 -0
- pyrelukko-0.1.0/PKG-INFO +20 -0
- pyrelukko-0.1.0/README.md +3 -0
- pyrelukko-0.1.0/pyproject.toml +31 -0
- pyrelukko-0.1.0/src/pyrelukko/__init__.py +4 -0
- pyrelukko-0.1.0/src/pyrelukko/pyrelukko.py +332 -0
- pyrelukko-0.1.0/src/pyrelukko/retry.py +45 -0
- pyrelukko-0.1.0/src/pyrelukko/version.py +2 -0
pyrelukko-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Reto Zingg <g.d0b3rm4n@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyrelukko-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pyrelukko
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Relukko client.
|
|
5
|
+
Author-email: Reto Zingg <g.d0b3rm4n@gmail.com>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Dist: requests >=2.32.3
|
|
14
|
+
Project-URL: Homepage, https://gitlab.com/relukko/pyrelukko
|
|
15
|
+
Project-URL: Issues, https://gitlab.com/relukko/pyrelukko/-/issues
|
|
16
|
+
|
|
17
|
+
# PyRelukko
|
|
18
|
+
Python library to access a
|
|
19
|
+
[Relukko back-end](https://gitlab.com/relukko/relukko).
|
|
20
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyrelukko"
|
|
3
|
+
authors = [
|
|
4
|
+
{name = "Reto Zingg", email = "g.d0b3rm4n@gmail.com"},
|
|
5
|
+
]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
|
|
8
|
+
# https://pypi.org/pypi?%3Aaction=list_classifiers
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
14
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.12"
|
|
17
|
+
dynamic = ["version", "description"]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"requests >=2.32.3",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.flit.sdist]
|
|
23
|
+
exclude = [".gitignore", "demo.py"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://gitlab.com/relukko/pyrelukko"
|
|
27
|
+
Issues = "https://gitlab.com/relukko/pyrelukko/-/issues"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["flit_core >=3.2,<4", "semantic-version >= 2.10"]
|
|
31
|
+
build-backend = "flit_core.buildapi"
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TBD
|
|
3
|
+
"""
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
import ssl
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Dict, List, Union
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib3.util import Url, parse_url
|
|
16
|
+
from urllib3.util.retry import Retry
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
from websockets import ConnectionClosed as WsConnectionClosed
|
|
20
|
+
|
|
21
|
+
from websockets.asyncio.client import connect as ws_connect
|
|
22
|
+
|
|
23
|
+
from .retry import retry
|
|
24
|
+
|
|
25
|
+
SSL_KWARGS = [
|
|
26
|
+
'check_hostname',
|
|
27
|
+
'hostname_checks_common_name',
|
|
28
|
+
'verify_mode',
|
|
29
|
+
'verify_flags',
|
|
30
|
+
'options',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RelukkoDoRetry(Exception):
|
|
37
|
+
"""
|
|
38
|
+
Exception thrown on some errors which we still want to retry
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# pylint: disable=too-many-instance-attributes
|
|
42
|
+
class RelukkoClient:
|
|
43
|
+
"""
|
|
44
|
+
TBD
|
|
45
|
+
"""
|
|
46
|
+
def __init__(
|
|
47
|
+
self, base_url: Union[Url, str], api_key:str, **kwargs) -> None:
|
|
48
|
+
"""
|
|
49
|
+
TBD
|
|
50
|
+
"""
|
|
51
|
+
self.session = requests.Session()
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
self._setup_session(api_key, **kwargs)
|
|
54
|
+
self._setup_http_adapters_retry(**kwargs)
|
|
55
|
+
|
|
56
|
+
self.base_url = self._setup_base_url(base_url)
|
|
57
|
+
self.ws_url = self._setup_ws_url(str(self.base_url))
|
|
58
|
+
self.ssl_ctx = None
|
|
59
|
+
self._setup_ssl_ctx(**kwargs)
|
|
60
|
+
|
|
61
|
+
# event for websocket thread to signal it got a message
|
|
62
|
+
self.message_received = threading.Event()
|
|
63
|
+
# As long as it's set the websocket thread runs
|
|
64
|
+
self.ws_running = threading.Event()
|
|
65
|
+
self.ws_listener: threading.Thread = None
|
|
66
|
+
|
|
67
|
+
def reconfigure_relukko(
|
|
68
|
+
self, base_url: Union[Url, str]=None, api_key: str=None, **kwargs):
|
|
69
|
+
"""
|
|
70
|
+
TBD
|
|
71
|
+
"""
|
|
72
|
+
self.api_key = api_key or self.api_key
|
|
73
|
+
self._setup_session(self.api_key, **kwargs)
|
|
74
|
+
self._setup_http_adapters_retry(**kwargs)
|
|
75
|
+
self.base_url = self._setup_base_url(base_url or self.base_url)
|
|
76
|
+
self.ws_url = self._setup_ws_url(str(self.base_url))
|
|
77
|
+
self._setup_ssl_ctx(**kwargs)
|
|
78
|
+
|
|
79
|
+
def _setup_http_adapters_retry(self, **kwargs):
|
|
80
|
+
for _, http_adapter in self.session.adapters.items():
|
|
81
|
+
http_retry: Retry = http_adapter.max_retries
|
|
82
|
+
for key, value in kwargs.items():
|
|
83
|
+
if hasattr(http_retry, key):
|
|
84
|
+
setattr(http_retry, key, value)
|
|
85
|
+
http_adapter.max_retries = http_retry
|
|
86
|
+
|
|
87
|
+
def _setup_session(self, api_key: str, **kwargs):
|
|
88
|
+
self.session.headers = {'X-api-Key': api_key}
|
|
89
|
+
for key, value in kwargs.items():
|
|
90
|
+
if hasattr(self.session, key):
|
|
91
|
+
setattr(self.session, key, value)
|
|
92
|
+
|
|
93
|
+
def _setup_ssl_ctx(self, **kwargs) -> Union[ssl.SSLContext, None]:
|
|
94
|
+
if self.ws_url.scheme == "wss":
|
|
95
|
+
if self.ssl_ctx is None:
|
|
96
|
+
self.ssl_ctx = ssl.create_default_context(
|
|
97
|
+
ssl.Purpose.SERVER_AUTH)
|
|
98
|
+
for kwarg in SSL_KWARGS:
|
|
99
|
+
setattr(
|
|
100
|
+
self.ssl_ctx,
|
|
101
|
+
kwarg,
|
|
102
|
+
kwargs.get(kwarg, getattr(self.ssl_ctx, kwarg)))
|
|
103
|
+
|
|
104
|
+
# Try to behave like requests library and take *_CA_BUNDLE env vars
|
|
105
|
+
# into account.
|
|
106
|
+
ca_bundle = (
|
|
107
|
+
os.environ.get("REQUESTS_CA_BUNDLE")
|
|
108
|
+
or os.environ.get("CURL_CA_BUNDLE"))
|
|
109
|
+
|
|
110
|
+
ca_bundle_file = None
|
|
111
|
+
ca_bundle_path = None
|
|
112
|
+
if ca_bundle is not None:
|
|
113
|
+
ca_bundle = Path(ca_bundle)
|
|
114
|
+
ca_bundle_file = ca_bundle if ca_bundle.is_file() else None
|
|
115
|
+
ca_bundle_path = ca_bundle if ca_bundle.is_dir() else None
|
|
116
|
+
|
|
117
|
+
# values from kwargs take precedence env vars
|
|
118
|
+
ca_file = kwargs.get('cafile', ca_bundle_file)
|
|
119
|
+
ca_path = kwargs.get('capath', ca_bundle_path)
|
|
120
|
+
ca_data = kwargs.get('cadata')
|
|
121
|
+
|
|
122
|
+
if ca_file or ca_path or ca_data:
|
|
123
|
+
self.ssl_ctx.load_verify_locations(
|
|
124
|
+
cafile=ca_file, capath=ca_path, cadata=ca_data)
|
|
125
|
+
else:
|
|
126
|
+
self.ssl_ctx = None
|
|
127
|
+
|
|
128
|
+
def _setup_ws_url(self, ws_url: str) -> Url:
|
|
129
|
+
url = ws_url.replace("http", "ws", 1)
|
|
130
|
+
return parse_url(f"{url}/deletions")
|
|
131
|
+
|
|
132
|
+
def _setup_base_url(self, base_url: Union[Url, str]) -> Url:
|
|
133
|
+
if isinstance(base_url, str):
|
|
134
|
+
base_url = parse_url(base_url)
|
|
135
|
+
if not isinstance(base_url, Url):
|
|
136
|
+
raise ValueError("must be URL or string!")
|
|
137
|
+
|
|
138
|
+
return base_url
|
|
139
|
+
|
|
140
|
+
async def _websocket_listener(self):
|
|
141
|
+
"""
|
|
142
|
+
The WebSocket thread, which waits for messages from Relukko and
|
|
143
|
+
notifies the HTTP thread in case deletions happend, so the HTTP
|
|
144
|
+
can retry to get the lock. Does not verify the wanted lock got
|
|
145
|
+
deleted yet.
|
|
146
|
+
"""
|
|
147
|
+
additional_headers = { "X-API-KEY": self.api_key }
|
|
148
|
+
async with ws_connect(
|
|
149
|
+
str(self.ws_url),
|
|
150
|
+
additional_headers=additional_headers,
|
|
151
|
+
ssl=self.ssl_ctx,
|
|
152
|
+
logger=logger,
|
|
153
|
+
) as websocket:
|
|
154
|
+
while self.ws_running.is_set():
|
|
155
|
+
try:
|
|
156
|
+
ws_message = await asyncio.wait_for(websocket.recv(), timeout=2)
|
|
157
|
+
if ws_message:
|
|
158
|
+
logger.debug("Received message: '%s'", ws_message)
|
|
159
|
+
msg: Dict = json.loads(ws_message)
|
|
160
|
+
if msg.get('deletions'):
|
|
161
|
+
# Signal the HTTP thread to wake up
|
|
162
|
+
self.message_received.set()
|
|
163
|
+
except TimeoutError:
|
|
164
|
+
# no messages, try in a moment again...
|
|
165
|
+
time.sleep(0.5)
|
|
166
|
+
except WsConnectionClosed:
|
|
167
|
+
logger.error("Lost WS connection!")
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
def _acquire_relukko(
|
|
171
|
+
self, url: Union[Url, str], max_run_time: int,
|
|
172
|
+
payload: Dict, _thread_store: List):
|
|
173
|
+
"""
|
|
174
|
+
The HTTP thread which tries to create the Relukko lock.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
start_time = time.time()
|
|
178
|
+
|
|
179
|
+
while True:
|
|
180
|
+
elapsed_time = time.time() - start_time
|
|
181
|
+
if elapsed_time > max_run_time:
|
|
182
|
+
self.ws_running.clear()
|
|
183
|
+
_thread_store.insert(0, None)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
res = self._make_request(
|
|
187
|
+
url=url, method="POST", payload=payload)
|
|
188
|
+
if res is None:
|
|
189
|
+
# Conflict 409
|
|
190
|
+
self.message_received.wait(timeout=30)
|
|
191
|
+
self.message_received.clear()
|
|
192
|
+
else:
|
|
193
|
+
_thread_store.insert(0, res)
|
|
194
|
+
self.ws_running.clear()
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
def _check_response(self, response: requests.Response):
|
|
198
|
+
match response.status_code:
|
|
199
|
+
case 200 | 201 | 404:
|
|
200
|
+
return response.json()
|
|
201
|
+
case 400 | 403:
|
|
202
|
+
err = response.json()
|
|
203
|
+
logger.warning(err.get('status'), err.get('message'))
|
|
204
|
+
response.raise_for_status()
|
|
205
|
+
case 409:
|
|
206
|
+
err = response.json()
|
|
207
|
+
logger.info(err.get('status'), err.get('message'))
|
|
208
|
+
return None
|
|
209
|
+
case 500 | 502 | 503 | 504:
|
|
210
|
+
logger.warning(response.status_code, response.text)
|
|
211
|
+
raise RelukkoDoRetry()
|
|
212
|
+
case _:
|
|
213
|
+
logger.warning(response.status_code, response.text)
|
|
214
|
+
raise RuntimeError()
|
|
215
|
+
|
|
216
|
+
def _make_request(
|
|
217
|
+
self,
|
|
218
|
+
url: str,
|
|
219
|
+
method: str,
|
|
220
|
+
payload: Dict=None) -> requests.Response:
|
|
221
|
+
|
|
222
|
+
excpetions = (
|
|
223
|
+
requests.ConnectionError,
|
|
224
|
+
RelukkoDoRetry,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@retry(logger, exceptions=excpetions, delay=10)
|
|
228
|
+
def _do_request():
|
|
229
|
+
response = self.session.request(
|
|
230
|
+
method=method,
|
|
231
|
+
url=url,
|
|
232
|
+
json=payload,
|
|
233
|
+
)
|
|
234
|
+
return self._check_response(response)
|
|
235
|
+
|
|
236
|
+
return _do_request()
|
|
237
|
+
|
|
238
|
+
def acquire_relukko(self, lock_name, creator, max_run_time):
|
|
239
|
+
"""
|
|
240
|
+
TBD
|
|
241
|
+
"""
|
|
242
|
+
payload = {
|
|
243
|
+
"lock_name": lock_name,
|
|
244
|
+
"creator": creator,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
url = f"{self.base_url}/v1/locks/"
|
|
248
|
+
|
|
249
|
+
self.ws_running.set()
|
|
250
|
+
self.ws_listener = threading.Thread(
|
|
251
|
+
target=asyncio.run, args=(self._websocket_listener(),))
|
|
252
|
+
self.ws_listener.start()
|
|
253
|
+
|
|
254
|
+
thread_store = []
|
|
255
|
+
http_thread = threading.Thread(
|
|
256
|
+
target=self._acquire_relukko, args=(url, max_run_time, payload, thread_store))
|
|
257
|
+
http_thread.start()
|
|
258
|
+
http_thread.join()
|
|
259
|
+
self.ws_listener.join()
|
|
260
|
+
|
|
261
|
+
return thread_store[0]
|
|
262
|
+
|
|
263
|
+
def get_lock(self, lock_id: str) -> Dict:
|
|
264
|
+
"""
|
|
265
|
+
TBD
|
|
266
|
+
"""
|
|
267
|
+
url = f"{self.base_url}/v1/locks/{lock_id}"
|
|
268
|
+
return self._make_request(url, "GET")
|
|
269
|
+
|
|
270
|
+
def get_locks(self) -> List:
|
|
271
|
+
"""
|
|
272
|
+
TBD
|
|
273
|
+
"""
|
|
274
|
+
url = f"{self.base_url}/v1/locks/"
|
|
275
|
+
return self._make_request(url, "GET")
|
|
276
|
+
|
|
277
|
+
def update_relukko(self, lock_id: str, creator: str, expires_at: datetime):
|
|
278
|
+
"""
|
|
279
|
+
TBD
|
|
280
|
+
"""
|
|
281
|
+
if isinstance(expires_at, datetime):
|
|
282
|
+
expires_at = expires_at.isoformat()
|
|
283
|
+
else:
|
|
284
|
+
raise ValueError("has to be datetime!")
|
|
285
|
+
|
|
286
|
+
payload = {
|
|
287
|
+
"creator": creator,
|
|
288
|
+
"expires_at": expires_at,
|
|
289
|
+
}
|
|
290
|
+
url = f"{self.base_url}/v1/locks/{lock_id}"
|
|
291
|
+
return self._make_request(url, "POST", payload)
|
|
292
|
+
|
|
293
|
+
def delete_relukko(self, lock_id: str):
|
|
294
|
+
"""
|
|
295
|
+
TBD
|
|
296
|
+
"""
|
|
297
|
+
url = f"{self.base_url}/v1/locks/{lock_id}"
|
|
298
|
+
return self._make_request(url, "DELETE")
|
|
299
|
+
|
|
300
|
+
def keep_relukko_alive(self, lock_id: str):
|
|
301
|
+
"""
|
|
302
|
+
TBD
|
|
303
|
+
"""
|
|
304
|
+
url = f"{self.base_url}/v1/locks/{lock_id}/keep_alive"
|
|
305
|
+
return self._make_request(url, "GET")
|
|
306
|
+
|
|
307
|
+
def keep_relukko_alive_put(self, lock_id: str, seconds: int):
|
|
308
|
+
"""
|
|
309
|
+
TBD
|
|
310
|
+
"""
|
|
311
|
+
url = f"{self.base_url}/v1/locks/{lock_id}/keep_alive"
|
|
312
|
+
payload = {
|
|
313
|
+
"seconds": seconds
|
|
314
|
+
}
|
|
315
|
+
return self._make_request(url, "PUT", payload)
|
|
316
|
+
|
|
317
|
+
def add_to_expires_at_time(self, lock_id: str):
|
|
318
|
+
"""
|
|
319
|
+
TBD
|
|
320
|
+
"""
|
|
321
|
+
url = f"{self.base_url}/v1/locks/{lock_id}/add_to_expire_at"
|
|
322
|
+
return self._make_request(url, "GET")
|
|
323
|
+
|
|
324
|
+
def add_to_expires_at_time_put(self, lock_id: str, seconds: int):
|
|
325
|
+
"""
|
|
326
|
+
TBD
|
|
327
|
+
"""
|
|
328
|
+
url = f"{self.base_url}/v1/locks/{lock_id}/add_to_expire_at"
|
|
329
|
+
payload = {
|
|
330
|
+
"seconds": seconds
|
|
331
|
+
}
|
|
332
|
+
return self._make_request(url, "PUT", payload)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TBD
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
from functools import wraps
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
9
|
+
def retry(logger, exceptions, tries=4, delay=5,
|
|
10
|
+
backoff=2.0, max_delay=None):
|
|
11
|
+
"""
|
|
12
|
+
Retry calling the decorated function using an exponential backoff.
|
|
13
|
+
https://www.calazan.com/retry-decorator-for-python-3/
|
|
14
|
+
|
|
15
|
+
:param exceptions: The exception to check. may be a tuple of
|
|
16
|
+
exceptions to check.
|
|
17
|
+
:param tries: Number of times to try (not retry) before giving up.
|
|
18
|
+
:param delay: Initial delay between retries in seconds.
|
|
19
|
+
:param backoff: Backoff multiplier (e.g. value of 2 will double the delay
|
|
20
|
+
each retry).
|
|
21
|
+
:param max_delay: maximum value for delay
|
|
22
|
+
"""
|
|
23
|
+
def deco_retry(f):
|
|
24
|
+
|
|
25
|
+
@wraps(f)
|
|
26
|
+
def f_retry(*args, **kwargs):
|
|
27
|
+
remaining_tries, retry_delay = tries, delay
|
|
28
|
+
while remaining_tries > 1:
|
|
29
|
+
try:
|
|
30
|
+
return f(*args, **kwargs)
|
|
31
|
+
except exceptions:
|
|
32
|
+
remaining_tries -= 1
|
|
33
|
+
logger.warning('(%i/%i): Retrying in %i seconds...',
|
|
34
|
+
tries - remaining_tries,
|
|
35
|
+
tries,
|
|
36
|
+
retry_delay,
|
|
37
|
+
)
|
|
38
|
+
time.sleep(retry_delay)
|
|
39
|
+
if max_delay is not None:
|
|
40
|
+
retry_delay = min(retry_delay*backoff, max_delay)
|
|
41
|
+
else:
|
|
42
|
+
retry_delay *= backoff
|
|
43
|
+
return f(*args, **kwargs)
|
|
44
|
+
return f_retry # true decorator
|
|
45
|
+
return deco_retry
|