Skip to content

Commit 7a4a3e7

Browse files
committed
Generalize retrying callers for sharing retry config w/o exc config
Ref #56 #45
1 parent 909100f commit 7a4a3e7

File tree

7 files changed

+233
-36
lines changed

7 files changed

+233
-36
lines changed

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()
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/tutorial.md

+17-3
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ for attempt in stamina.retry_context(on=httpx.HTTPError):
4646
resp.raise_for_status()
4747
```
4848

49+
50+
## Retry One Function or Method Call
51+
4952
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`:
5053

5154
```python
@@ -54,12 +57,23 @@ def do_something_with_url(url, some_kw):
5457
resp.raise_for_status()
5558
...
5659

57-
rc = stamina.RetryingCaller(on=httpx.HTTPError)
60+
rc = stamina.RetryingCaller()
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`.
6377

6478

6579
## Async

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

+136-13
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,35 +164,101 @@ 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
183179
Tip:
184-
Instances of ``RetryingCaller`` may be reused because they create a new
185-
:func:`retry_context` iterator on each call.
180+
Instances of ``RetryingCaller`` may be reused because they internally
181+
create a new :func:`retry_context` iterator on each call.
186182
187183
.. versionadded:: 24.2.0
188184
"""
189185

190186
def __call__(
191-
self, func: Callable[P, T], /, *args: P.args, **kw: P.kwargs
187+
self,
188+
on: type[Exception] | tuple[type[Exception], ...],
189+
callable_: Callable[P, T],
190+
/,
191+
*args: P.args,
192+
**kw: P.kwargs,
192193
) -> T:
193-
for attempt in retry_context(**self._context_kws):
194+
r"""
195+
Call ``callable_(*args, **kw)`` with retries if *on* is raised.
196+
197+
Args:
198+
on: Exception(s) to retry on.
199+
200+
callable\_: Callable to call.
201+
202+
args: Positional arguments to pass to *callable_*.
203+
204+
kw: Keyword arguments to pass to *callable_*.
205+
"""
206+
for attempt in retry_context(on, **self._context_kws):
194207
with attempt:
195-
return func(*args, **kw)
208+
return callable_(*args, **kw)
196209

197210
raise SystemError("unreachable") # pragma: no cover # noqa: EM101
198211

212+
def on(
213+
self, on: type[Exception] | tuple[type[Exception], ...], /
214+
) -> BoundRetryingCaller:
215+
"""
216+
Create a new instance of :class:`BoundRetryingCaller` with the same
217+
parameters, but bound to a specific exception type.
218+
219+
.. versionadded:: 24.2.0
220+
"""
221+
return BoundRetryingCaller(self, on)
222+
223+
224+
class BoundRetryingCaller:
225+
"""
226+
Same as :class:`RetryingCaller`, but pre-bound to a specific exception
227+
type.
228+
229+
Caution:
230+
Returned by :meth:`RetryingCaller.on` -- do not instantiate directly.
231+
232+
.. versionadded:: 24.2.0
233+
"""
234+
235+
__slots__ = ("_caller", "_on")
236+
237+
_caller: RetryingCaller
238+
_on: type[Exception] | tuple[type[Exception], ...]
239+
240+
def __init__(
241+
self,
242+
caller: RetryingCaller,
243+
on: type[Exception] | tuple[type[Exception], ...],
244+
):
245+
self._caller = caller
246+
self._on = on
247+
248+
def __repr__(self) -> str:
249+
return (
250+
f"<BoundRetryingCaller({guess_name(self._on)}, {self._caller!r})>"
251+
)
252+
253+
def __call__(
254+
self, callable_: Callable[P, T], /, *args: P.args, **kw: P.kwargs
255+
) -> T:
256+
"""
257+
Same as :func:`RetryingCaller.__call__`, except retry on the exception
258+
that is bound to this instance.
259+
"""
260+
return self._caller(self._on, callable_, *args, **kw)
261+
199262

200263
class AsyncRetryingCaller(BaseRetryingCaller):
201264
"""
@@ -205,14 +268,74 @@ class AsyncRetryingCaller(BaseRetryingCaller):
205268
"""
206269

207270
async def __call__(
208-
self, func: Callable[P, Awaitable[T]], /, *args: P.args, **kw: P.kwargs
271+
self,
272+
on: type[Exception] | tuple[type[Exception], ...],
273+
callable_: Callable[P, Awaitable[T]],
274+
/,
275+
*args: P.args,
276+
**kw: P.kwargs,
209277
) -> T:
210-
async for attempt in retry_context(**self._context_kws):
278+
"""
279+
Same as :meth:`RetryingCaller.__call__`, but *callable_* is awaited.
280+
"""
281+
async for attempt in retry_context(on, **self._context_kws):
211282
with attempt:
212-
return await func(*args, **kw)
283+
return await callable_(*args, **kw)
213284

214285
raise SystemError("unreachable") # pragma: no cover # noqa: EM101
215286

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

217340
_STOP_NO_RETRY = _t.stop_after_attempt(1)
218341

tests/test_async.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,34 @@ async def f(*args, **kw):
226226

227227
return args, kw
228228

229-
arc = stamina.AsyncRetryingCaller(on=ValueError)
229+
arc = stamina.AsyncRetryingCaller().on(ValueError)
230230

231231
args, kw = await arc(f, 42, foo="bar")
232232

233233
assert 1 == i
234234
assert (42,) == args
235235
assert {"foo": "bar"} == kw
236+
237+
def test_repr(self):
238+
"""
239+
repr() is useful
240+
"""
241+
arc = stamina.AsyncRetryingCaller(
242+
attempts=42,
243+
timeout=13.0,
244+
wait_initial=23,
245+
wait_max=123,
246+
wait_jitter=0.42,
247+
wait_exp_base=666,
248+
)
249+
250+
r = repr(arc)
251+
252+
assert (
253+
"<AsyncRetryingCaller(attempts=42, timeout=13.0, "
254+
"wait_exp_base=666, wait_initial=23, wait_jitter=0.42, "
255+
"wait_max=123)>"
256+
) == r
257+
assert f"<BoundAsyncRetryingCaller(ValueError, {r})>" == repr(
258+
arc.on(ValueError)
259+
)

tests/test_sync.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,9 @@ def f(*args, **kw):
189189

190190
return args, kw
191191

192-
rc = stamina.RetryingCaller(on=ValueError)
192+
bound_rc = stamina.RetryingCaller().on(ValueError)
193193

194-
args, kw = rc(f, 42, foo="bar")
194+
args, kw = bound_rc(f, 42, foo="bar")
195195

196196
assert 1 == i
197197
assert (42,) == args
@@ -202,7 +202,6 @@ def test_repr(self):
202202
repr() is useful.
203203
"""
204204
rc = stamina.RetryingCaller(
205-
on=ValueError,
206205
attempts=42,
207206
timeout=13.0,
208207
wait_initial=23,
@@ -211,8 +210,13 @@ def test_repr(self):
211210
wait_exp_base=666,
212211
)
213212

213+
r = repr(rc)
214+
214215
assert (
215-
"<RetryingCaller(on=ValueError, attempts=42, timeout=13.0, "
216+
"<RetryingCaller(attempts=42, timeout=13.0, "
216217
"wait_exp_base=666, wait_initial=23, wait_jitter=0.42, "
217218
"wait_max=123)>"
218-
) == repr(rc)
219+
) == r
220+
assert f"<BoundRetryingCaller(ValueError, {r})>" == repr(
221+
rc.on(ValueError)
222+
)

0 commit comments

Comments
 (0)