Skip to content

Commit 542ea48

Browse files
authored
Generalize retrying callers for sharing retry config w/o exc config (#57)
* Generalize retrying callers for sharing retry config w/o exc config Ref #56 #45 * docs * Use official name in public APIs * Docs * Fix docs build * Stress * words * Add explanation * Use exclude_also
1 parent 909100f commit 542ea48

File tree

10 files changed

+253
-44
lines changed

10 files changed

+253
-44
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
1919

2020
- `stamina.RetryingCaller` and `stamina.AsyncRetryingCaller` that allow even easier retries of single callables.
2121
[#56](https://github.com/hynek/stamina/pull/56)
22+
[#57](https://github.com/hynek/stamina/pull/57)
2223

2324

2425
## [24.1.0](https://github.com/hynek/stamina/compare/23.3.0...24.1.0) - 2024-01-03

docs/api.md

+23-5
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,39 @@
88
.. autoclass:: Attempt
99
:members: num
1010
.. autoclass:: RetryingCaller
11+
:members: on, __call__
1112
1213
For example::
1314
1415
def do_something_with_url(url, some_kw):
15-
resp = httpx.get(url)
16-
resp.raise_for_status()
16+
resp = httpx.get(url).raise_for_status()
1717
...
1818
19-
rc = stamina.RetryingCaller(on=httpx.HTTPError)
19+
rc = stamina.RetryingCaller(attempts=5)
2020
21-
rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
21+
rc(httpx.HTTPError, do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
2222
23-
Runs ``do_something_with_url(f"https://httpbin.org/status/404", some_kw=42)`` and retries on ``httpx.HTTPError``.
23+
# Equivalent:
24+
bound_rc = rc.on(httpx.HTTPError)
25+
26+
bound_rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
27+
28+
Both calls to ``rc`` and ``bound_rc`` run
29+
30+
.. code-block:: python
31+
32+
do_something_with_url(f"https://httpbin.org/status/404", some_kw=42)
33+
34+
and retry on ``httpx.HTTPError``.
35+
36+
.. autoclass:: BoundRetryingCaller
37+
:members: __call__
2438
2539
.. autoclass:: AsyncRetryingCaller
40+
:members: on, __call__
41+
42+
.. autoclass:: BoundAsyncRetryingCaller
43+
:members: __call__
2644
```
2745

2846

docs/conf.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@
5454

5555
exclude_patterns = ["_build"]
5656

57-
nitpick_ignore = [("py:class", "httpx.HTTPError")]
57+
nitpick_ignore = [
58+
("py:class", "httpx.HTTPError"),
59+
# ParamSpec is not well-supported.
60+
("py:obj", "typing.~P"),
61+
("py:class", "~P"),
62+
("py:class", "stamina._core.T"),
63+
]
5864

5965
# If true, '()' will be appended to :func: etc. cross-reference text.
6066
add_function_parentheses = True

docs/tutorial.md

+19-4
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,35 @@ for attempt in stamina.retry_context(on=httpx.HTTPError):
4646
resp.raise_for_status()
4747
```
4848

49-
If you want to retry just one function call, *stamina* comes with an even easier way in the shape of {class}`stamina.RetryingCaller` and {class}`stamina.AsyncRetryingCaller`:
49+
50+
## Retry One Function or Method Call
51+
52+
If you want to retry just one function or method call, *stamina* comes with an even easier way in the shape of {class}`stamina.RetryingCaller` and {class}`stamina.AsyncRetryingCaller`:
5053

5154
```python
5255
def do_something_with_url(url, some_kw):
5356
resp = httpx.get(url)
5457
resp.raise_for_status()
5558
...
5659

57-
rc = stamina.RetryingCaller(on=httpx.HTTPError)
60+
rc = stamina.RetryingCaller(attempts=5)
61+
62+
rc(httpx.HTTPError, do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
63+
64+
# You can also create a caller with a pre-bound exception type:
65+
bound_rc = rc.on(httpx.HTTPError)
5866

59-
rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
67+
bound_rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
68+
```
69+
70+
Both `rc` and `bound_rc` run:
71+
72+
```python
73+
do_something_with_url(f"https://httpbin.org/status/404", some_kw=42)
6074
```
6175

62-
The last line calls `do_something_with_url(f"https://httpbin.org/status/404", some_kw=42)` and retries on `httpx.HTTPError`.
76+
and retry on `httpx.HTTPError` and as before, the type hints are preserved.
77+
It's up to you whether you want to share only the retry configuration or the exception type to retry on, too.
6378

6479

6580
## Async

pyproject.toml

+3-4
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,11 @@ source = ["src", ".nox/tests*/**/site-packages"]
116116
[tool.coverage.report]
117117
show_missing = true
118118
skip_covered = true
119-
exclude_lines = [
120-
"no cov",
121-
"if __name__ == .__main__.:",
119+
exclude_also = [
120+
'raise SystemError\("unreachable"\)',
122121
# Typing-related
123122
"if TYPE_CHECKING:",
124-
": \\.\\.\\.$",
123+
': \.\.\.$',
125124
]
126125

127126

src/stamina/__init__.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,25 @@
77
from ._core import (
88
AsyncRetryingCaller,
99
Attempt,
10+
BoundAsyncRetryingCaller,
11+
BoundRetryingCaller,
1012
RetryingCaller,
1113
retry,
1214
retry_context,
1315
)
1416

1517

1618
__all__ = [
19+
"AsyncRetryingCaller",
1720
"Attempt",
18-
"retry",
19-
"retry_context",
20-
"is_active",
21-
"set_active",
21+
"BoundAsyncRetryingCaller",
22+
"BoundRetryingCaller",
2223
"instrumentation",
24+
"is_active",
25+
"retry_context",
26+
"retry",
2327
"RetryingCaller",
24-
"AsyncRetryingCaller",
28+
"set_active",
2529
]
2630

2731

src/stamina/_core.py

+143-15
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ def __exit__(
127127

128128

129129
class RetryKWs(TypedDict):
130-
on: type[Exception] | tuple[type[Exception], ...]
131130
attempts: int | None
132131
timeout: float | dt.timedelta | None
133132
wait_initial: float | dt.timedelta
@@ -148,7 +147,6 @@ class BaseRetryingCaller:
148147

149148
def __init__(
150149
self,
151-
on: type[Exception] | tuple[type[Exception], ...],
152150
attempts: int | None = 10,
153151
timeout: float | dt.timedelta | None = 45.0,
154152
wait_initial: float | dt.timedelta = 0.1,
@@ -157,7 +155,6 @@ def __init__(
157155
wait_exp_base: float = 2.0,
158156
):
159157
self._context_kws = {
160-
"on": on,
161158
"attempts": attempts,
162159
"timeout": timeout,
163160
"wait_initial": wait_initial,
@@ -167,34 +164,105 @@ def __init__(
167164
}
168165

169166
def __repr__(self) -> str:
170-
on = guess_name(self._context_kws["on"])
171167
kws = ", ".join(
172168
f"{k}={self._context_kws[k]!r}" # type: ignore[literal-required]
173169
for k in sorted(self._context_kws)
174170
if k != "on"
175171
)
176-
return f"<{self.__class__.__name__}(on={on}, {kws})>"
172+
return f"<{self.__class__.__name__}({kws})>"
177173

178174

179175
class RetryingCaller(BaseRetryingCaller):
180176
"""
181177
Call your callables with retries.
182178
179+
Arguments have the same meaning as for :func:`stamina.retry`.
180+
183181
Tip:
184-
Instances of ``RetryingCaller`` may be reused because they create a new
185-
:func:`retry_context` iterator on each call.
182+
Instances of ``RetryingCaller`` may be reused because they internally
183+
create a new :func:`retry_context` iterator on each call.
186184
187185
.. versionadded:: 24.2.0
188186
"""
189187

190188
def __call__(
191-
self, func: Callable[P, T], /, *args: P.args, **kw: P.kwargs
189+
self,
190+
on: type[Exception] | tuple[type[Exception], ...],
191+
callable_: Callable[P, T],
192+
/,
193+
*args: P.args,
194+
**kwargs: P.kwargs,
192195
) -> T:
193-
for attempt in retry_context(**self._context_kws):
196+
r"""
197+
Call ``callable_(*args, **kw)`` with retries if *on* is raised.
198+
199+
Args:
200+
on: Exception(s) to retry on.
201+
202+
callable\_: Callable to call.
203+
204+
args: Positional arguments to pass to *callable_*.
205+
206+
kw: Keyword arguments to pass to *callable_*.
207+
"""
208+
for attempt in retry_context(on, **self._context_kws):
194209
with attempt:
195-
return func(*args, **kw)
210+
return callable_(*args, **kwargs)
211+
212+
raise SystemError("unreachable") # noqa: EM101
213+
214+
def on(
215+
self, on: type[Exception] | tuple[type[Exception], ...], /
216+
) -> BoundRetryingCaller:
217+
"""
218+
Create a new instance of :class:`BoundRetryingCaller` with the same
219+
parameters, but bound to a specific exception type.
196220
197-
raise SystemError("unreachable") # pragma: no cover # noqa: EM101
221+
.. versionadded:: 24.2.0
222+
"""
223+
# This should be a `functools.partial`, but unfortunately it's
224+
# impossible to provide a nicely typed API with it, so we use a
225+
# separate class.
226+
return BoundRetryingCaller(self, on)
227+
228+
229+
class BoundRetryingCaller:
230+
"""
231+
Same as :class:`RetryingCaller`, but pre-bound to a specific exception
232+
type.
233+
234+
Caution:
235+
Returned by :meth:`RetryingCaller.on` -- do not instantiate directly.
236+
237+
.. versionadded:: 24.2.0
238+
"""
239+
240+
__slots__ = ("_caller", "_on")
241+
242+
_caller: RetryingCaller
243+
_on: type[Exception] | tuple[type[Exception], ...]
244+
245+
def __init__(
246+
self,
247+
caller: RetryingCaller,
248+
on: type[Exception] | tuple[type[Exception], ...],
249+
):
250+
self._caller = caller
251+
self._on = on
252+
253+
def __repr__(self) -> str:
254+
return (
255+
f"<BoundRetryingCaller({guess_name(self._on)}, {self._caller!r})>"
256+
)
257+
258+
def __call__(
259+
self, callable_: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs
260+
) -> T:
261+
"""
262+
Same as :func:`RetryingCaller.__call__`, except retry on the exception
263+
that is bound to this instance.
264+
"""
265+
return self._caller(self._on, callable_, *args, **kwargs)
198266

199267

200268
class AsyncRetryingCaller(BaseRetryingCaller):
@@ -205,13 +273,73 @@ class AsyncRetryingCaller(BaseRetryingCaller):
205273
"""
206274

207275
async def __call__(
208-
self, func: Callable[P, Awaitable[T]], /, *args: P.args, **kw: P.kwargs
276+
self,
277+
on: type[Exception] | tuple[type[Exception], ...],
278+
callable_: Callable[P, Awaitable[T]],
279+
/,
280+
*args: P.args,
281+
**kwargs: P.kwargs,
209282
) -> T:
210-
async for attempt in retry_context(**self._context_kws):
283+
"""
284+
Same as :meth:`RetryingCaller.__call__`, but *callable_* is awaited.
285+
"""
286+
async for attempt in retry_context(on, **self._context_kws):
211287
with attempt:
212-
return await func(*args, **kw)
288+
return await callable_(*args, **kwargs)
213289

214-
raise SystemError("unreachable") # pragma: no cover # noqa: EM101
290+
raise SystemError("unreachable") # noqa: EM101
291+
292+
def on(
293+
self, on: type[Exception] | tuple[type[Exception], ...], /
294+
) -> BoundAsyncRetryingCaller:
295+
"""
296+
Create a new instance of :class:`BoundAsyncRetryingCaller` with the
297+
same parameters, but bound to a specific exception type.
298+
299+
.. versionadded:: 24.2.0
300+
"""
301+
return BoundAsyncRetryingCaller(self, on)
302+
303+
304+
class BoundAsyncRetryingCaller:
305+
"""
306+
Same as :class:`BoundRetryingCaller`, but for async callables.
307+
308+
Caution:
309+
Returned by :meth:`AsyncRetryingCaller.on` -- do not instantiate
310+
directly.
311+
312+
.. versionadded:: 24.2.0
313+
"""
314+
315+
__slots__ = ("_caller", "_on")
316+
317+
_caller: AsyncRetryingCaller
318+
_on: type[Exception] | tuple[type[Exception], ...]
319+
320+
def __init__(
321+
self,
322+
caller: AsyncRetryingCaller,
323+
on: type[Exception] | tuple[type[Exception], ...],
324+
):
325+
self._caller = caller
326+
self._on = on
327+
328+
def __repr__(self) -> str:
329+
return f"<BoundAsyncRetryingCaller({guess_name(self._on)}, {self._caller!r})>"
330+
331+
async def __call__(
332+
self,
333+
callable_: Callable[P, Awaitable[T]],
334+
/,
335+
*args: P.args,
336+
**kwargs: P.kwargs,
337+
) -> T:
338+
"""
339+
Same as :func:`AsyncRetryingCaller.__call__`, except retry on the
340+
exception that is bound to this instance.
341+
"""
342+
return await self._caller(self._on, callable_, *args, **kwargs)
215343

216344

217345
_STOP_NO_RETRY = _t.stop_after_attempt(1)

0 commit comments

Comments
 (0)