Coverage for src / toolbox_python / retry.py: 100%

75 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-02 22:56 +0000

1# ============================================================================ # 

2# # 

3# Title : Retry # 

4# Purpose : Automatically retry a given function when a specific # 

5# `Exception` is thrown. # 

6# # 

7# ============================================================================ # 

8 

9 

10# ---------------------------------------------------------------------------- # 

11# # 

12# Overview #### 

13# # 

14# ---------------------------------------------------------------------------- # 

15 

16 

17# ---------------------------------------------------------------------------- # 

18# Description #### 

19# ---------------------------------------------------------------------------- # 

20 

21 

22""" 

23!!! note "Summary" 

24 The `retry` module is for enabling automatic retrying of a given function when a specific `Exception` is thrown. 

25""" 

26 

27 

28# ---------------------------------------------------------------------------- # 

29# # 

30# Setup #### 

31# # 

32# ---------------------------------------------------------------------------- # 

33 

34 

35# ---------------------------------------------------------------------------- # 

36# Imports #### 

37# ---------------------------------------------------------------------------- # 

38 

39 

40# ## Python StdLib Imports ---- 

41import inspect 

42import logging 

43from builtins import Exception 

44from functools import wraps 

45from logging import Logger 

46from time import sleep 

47from types import ModuleType 

48from typing import Any, Callable, Literal, NoReturn, Optional, TypeVar, Union, overload 

49 

50# ## Python Third Party Imports ---- 

51from typeguard import typechecked 

52 

53# ## Local First Party Imports ---- 

54from toolbox_python.checkers import assert_is_valid 

55from toolbox_python.classes import get_full_class_name 

56from toolbox_python.output import print_or_log_output 

57 

58 

59# ---------------------------------------------------------------------------- # 

60# Exports #### 

61# ---------------------------------------------------------------------------- # 

62 

63 

64__all__: list[str] = ["retry"] 

65 

66 

67# ---------------------------------------------------------------------------- # 

68# Types #### 

69# ---------------------------------------------------------------------------- # 

70 

71 

72_exceptions = Union[ 

73 type[Exception], 

74 list[type[Exception]], 

75 tuple[type[Exception], ...], 

76] 

77""" 

78!!! note "Summary" 

79 A type alias for a single or collection of `Exception` types. 

80""" 

81 

82 

83R = TypeVar("R") 

84""" 

85!!! note "Summary" 

86 A generic type variable to represent the return type of a function. 

87""" 

88 

89 

90# ---------------------------------------------------------------------------- # 

91# # 

92# Classes #### 

93# # 

94# ---------------------------------------------------------------------------- # 

95 

96 

97class _Retry: 

98 """ 

99 !!! note "Summary" 

100 A helper class to handle the retry logic for the `retry` decorator. 

101 

102 ???+ abstract "Details" 

103 This class is not intended to be used directly. Instead, it is used internally by the `retry` decorator to manage the retry logic. 

104 

105 Methods: 

106 run(): Run the retry loop for the given function. 

107 """ 

108 

109 def __init__( 

110 self, 

111 exceptions: _exceptions, 

112 tries: int, 

113 delay: int, 

114 print_or_log: Literal["print", "log"], 

115 log: Optional[Logger], 

116 ) -> None: 

117 """ 

118 !!! note "Summary" 

119 Initialize the `_Retry` class with the given parameters. 

120 

121 Params: 

122 exceptions (_exceptions): 

123 A given single or collection of expected exceptions for which to catch and retry for. 

124 tries (int): 

125 The number of retries to attempt. 

126 delay (int): 

127 The number of seconds to delay between each retry. 

128 print_or_log (Literal["print", "log"]): 

129 Whether or not the messages should be written to the terminal in a `#!py print()` statement, or to a log file in a `#!py log()` statement. 

130 log (Optional[Logger]): 

131 An optional logger instance to use when `print_or_log` is set to `"log"`. 

132 """ 

133 self.exceptions: tuple[type[Exception], ...] = ( 

134 tuple(exceptions) if isinstance(exceptions, (list, tuple)) else (exceptions,) 

135 ) 

136 self.tries: int = tries 

137 self.delay: int = delay 

138 self.print_or_log: Literal["print", "log"] = print_or_log 

139 self.log: Optional[Logger] = log 

140 

141 def run(self, func: Callable[..., R], *args: Any, **kwargs: Any) -> R: 

142 """ 

143 !!! note "Summary" 

144 Run the retry loop for the given function. 

145 """ 

146 for i in range(1, self.tries + 1): 

147 try: 

148 results = func(*args, **kwargs) 

149 self._handle_success(i) 

150 return results 

151 except self.exceptions as e: 

152 self._handle_expected_error(i, e) 

153 except Exception as exc: 

154 self._handle_unexpected_error(i, exc) 

155 self._handle_final_failure() 

156 

157 def _handle_success(self, i: int) -> None: 

158 message: str = f"Successfully executed at iteration {i}." 

159 print_or_log_output( 

160 message=message, 

161 print_or_log=self.print_or_log, 

162 log=self.log, 

163 log_level="info", 

164 ) 

165 

166 def _handle_expected_error(self, i: int, e: Exception) -> None: 

167 message = ( 

168 f"Caught an expected error at iteration {i}: " 

169 f"`{get_full_class_name(e)}`. " 

170 f"Retrying in {self.delay} seconds..." 

171 ) 

172 print_or_log_output( 

173 message=message, 

174 print_or_log=self.print_or_log, 

175 log=self.log, 

176 log_level="warning", 

177 ) 

178 sleep(self.delay) 

179 

180 def _handle_unexpected_error(self, i: int, exc: Exception) -> None: 

181 excs = self.exceptions if isinstance(self.exceptions, (list, tuple)) else (self.exceptions,) 

182 exc_names: list[str] = [e.__name__ for e in excs] 

183 if any(name in f"{exc}" for name in exc_names): 

184 caught_errors: list[str] = [name for name in exc_names if name in f"{exc}"] 

185 message: str = ( 

186 f"Caught an unexpected, known error at iteration {i}: " 

187 f"`{get_full_class_name(exc)}`.\n" 

188 f"Who's message contains reference to underlying exception(s): {caught_errors}.\n" 

189 f"Retrying in {self.delay} seconds..." 

190 ) 

191 print_or_log_output( 

192 message=message, 

193 print_or_log=self.print_or_log, 

194 log=self.log, 

195 log_level="warning", 

196 ) 

197 sleep(self.delay) 

198 else: 

199 message = f"Caught an unexpected error at iteration {i}: `{get_full_class_name(exc)}`." 

200 print_or_log_output( 

201 message=message, 

202 print_or_log=self.print_or_log, 

203 log=self.log, 

204 log_level="error", 

205 ) 

206 raise RuntimeError(message) from exc 

207 

208 def _handle_final_failure(self) -> NoReturn: 

209 message: str = f"Still could not write after {self.tries} iterations. Please check." 

210 print_or_log_output( 

211 message=message, 

212 print_or_log=self.print_or_log, 

213 log=self.log, 

214 log_level="error", 

215 ) 

216 raise RuntimeError(message) 

217 

218 

219# ---------------------------------------------------------------------------- # 

220# # 

221# Functions #### 

222# # 

223# ---------------------------------------------------------------------------- # 

224 

225 

226@overload 

227@typechecked 

228def retry( 

229 exceptions: _exceptions = Exception, 

230 tries: int = 1, 

231 delay: int = 0, 

232 print_or_log: Literal["print"] = "print", 

233) -> Callable[[Callable[..., R]], Callable[..., R]]: ... 

234@overload 

235@typechecked 

236def retry( 

237 exceptions: _exceptions = Exception, 

238 tries: int = 1, 

239 delay: int = 0, 

240 print_or_log: Literal["log"] = "log", 

241) -> Callable[[Callable[..., R]], Callable[..., R]]: ... 

242@typechecked 

243def retry( 

244 exceptions: _exceptions = Exception, 

245 tries: int = 1, 

246 delay: int = 0, 

247 print_or_log: Literal["print", "log"] = "print", 

248) -> Callable[[Callable[..., R]], Callable[..., R]]: 

249 """ 

250 !!! note "Summary" 

251 Retry a given function a number of times. Catching any known exceptions when they are given. And returning any output to either a terminal or a log file. 

252 

253 !!! deprecation "Deprecated" 

254 This function is deprecated. Please use the [`retry()`][func] decorator from the [`stamina`][docs] package instead.<br> 

255 For more info, see: [Docs][docs], [GitHub][github], [PyPi][pypi]. 

256 [func]: https://stamina.hynek.me/en/stable/api.html#stamina.retry 

257 [docs]: https://stamina.hynek.me/en/stable/index.html 

258 [github]: https://github.com/hynek/stamina/ 

259 [pypi]: https://pypi.org/project/stamina/ 

260 

261 ???+ abstract "Details" 

262 This function should always be implemented as a decorator.<br> 

263 It is written based on the premise that a certain process may fail and return a given message, but that is known and expected, and you just want to wait a second or so then retry again.<br> 

264 Typically, this is seen in async processes, or when writing data to a `delta` table when there may be concurrent read/writes occurring at the same time. In these instances, you will know the error message and can re-try again a certain number of times. 

265 

266 Params: 

267 exceptions (_exceptions, optional): 

268 A given single or collection of expected exceptions for which to catch and retry for.<br> 

269 Defaults to `#!py Exception`. 

270 tries (int, optional): 

271 The number of retries to attempt. If the underlying process is still failing after this number of attempts, then throw a hard error and alert the user.<br> 

272 Defaults to `#!py 1`. 

273 delay (int, optional): 

274 The number of seconds to delay between each retry.<br> 

275 Defaults to `#!py 0`. 

276 print_or_log (Literal["print", "log"], optional): 

277 Whether or not the messages should be written to the terminal in a `#!py print()` statement, or to a log file in a `#!py log()` statement.<br> 

278 Defaults to `#!py "print"`. 

279 

280 Raises: 

281 (TypeCheckError): 

282 If any of the inputs parsed to the parameters of this function are not the correct type. Uses the [`@typeguard.typechecked`](https://typeguard.readthedocs.io/en/stable/api.html#typeguard.typechecked) decorator. 

283 (ValueError): 

284 If either `tries` or `delay` are less than `#!py 0` 

285 (RuntimeError): 

286 If _either_ an unexpected `#!py Exception` was thrown, which was not declared in the `exceptions` collection, _or_ if the `func` was still not able to be executed after `tries` number of iterations. 

287 

288 Returns: 

289 result (Optional[Any]): 

290 The result from the underlying function, if any. 

291 

292 ???+ example "Examples" 

293 

294 ```pycon {.py .python linenums="1" title="Imports"} 

295 >>> from toolbox_python.retry import retry 

296 ``` 

297 

298 ```{.py .python linenums="1" title="Example 1: No error"} 

299 >>> @retry(tries=5, delay=1, print_or_log="print") 

300 >>> def simple_func(var1: str = "this") -> str: 

301 ... return var1 

302 ... 

303 >>> simple_func() 

304 ``` 

305 <div class="result" markdown> 

306 ```{.sh .shell title="Terminal"} 

307 # No error 

308 ``` 

309 </div> 

310 

311 ```{.py .python linenums="1" title="Example 2: Expected error"} 

312 >>> @retry(exceptions=TypeError, tries=5, delay=1, print_or_log="print") 

313 >>> def failing_func(var1: str = "that") -> None: 

314 ... raise ValueError("Incorrect value") 

315 ... 

316 >>> failing_func() 

317 ``` 

318 <div class="result" markdown> 

319 ```{.sh .shell title="Terminal"} 

320 Caught an expected error at iteration 1: `ValueError`. Retrying in 1 seconds... 

321 Caught an expected error at iteration 2: `ValueError`. Retrying in 1 seconds... 

322 Caught an expected error at iteration 3: `ValueError`. Retrying in 1 seconds... 

323 Caught an expected error at iteration 4: `ValueError`. Retrying in 1 seconds... 

324 Caught an expected error at iteration 5: `ValueError`. Retrying in 1 seconds... 

325 RuntimeError: Still could not write after 5 iterations. Please check. 

326 ``` 

327 </div> 

328 

329 ??? success "Credit" 

330 Inspiration from: 

331 

332 - https://pypi.org/project/retry/ 

333 - https://stackoverflow.com/questions/21786382/pythonic-way-of-retry-running-a-function#answer-21788594 

334 """ 

335 

336 assert_is_valid(tries, ">=", 0) 

337 assert_is_valid(delay, ">=", 0) 

338 

339 exceptions = tuple(exceptions) if isinstance(exceptions, (list, tuple)) else (exceptions,) 

340 

341 log: Optional[Logger] = None 

342 

343 if print_or_log == "log": 

344 stk: inspect.FrameInfo = inspect.stack()[2] 

345 mod: Union[ModuleType, None] = inspect.getmodule(stk[0]) 

346 if mod is not None: 

347 log: Optional[Logger] = logging.getLogger(mod.__name__) 

348 

349 def decorator(func: Callable[..., R]) -> Callable[..., R]: 

350 @wraps(func) 

351 def wrapper(*args: Any, **kwargs: Any) -> R: 

352 retry_handler = _Retry( 

353 exceptions=exceptions, 

354 tries=tries, 

355 delay=delay, 

356 print_or_log=print_or_log, 

357 log=log, 

358 ) 

359 return retry_handler.run(func, *args, **kwargs) 

360 

361 return wrapper 

362 

363 return decorator