Skip to content

Commit 8fc5a86

Browse files
authored
Add retry_context (#12)
* Add retry_context * Add changelog entry * Make context retries respect config * Cache no-op stop * Add narrative docs * Fix typo
1 parent b28ea04 commit 8fc5a86

File tree

8 files changed

+370
-96
lines changed

8 files changed

+370
-96
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ The **third number** is for emergencies when we need to start branches for older
1919
[#9](https://github.com/hynek/stamina/pull/9)
2020
- Async support.
2121
[#10](https://github.com/hynek/stamina/pull/10)
22+
- Retries of arbitrary blocks using (async) `for` loops and context managers.
23+
[#12](https://github.com/hynek/stamina/pull/12)
2224

2325

2426
## [22.2.0](https://github.com/hynek/stamina/compare/22.1.0...22.2.0) - 2022-10-06

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ In practice, only a few knobs are needed (repeatedly!), though.
2525

2626
## Usage
2727

28-
The API consists mainly of the `stamina.retry()` decorator:
28+
The API consists mainly of the `stamina.retry()` decorator for retrying functions and methods, and the `stamina.retry_context()` iterator / context manager combo for retrying arbitrary code blocks:
2929

3030
<!-- example-start -->
3131
```python
@@ -42,6 +42,11 @@ def do_it(code: int) -> httpx.Response:
4242

4343
# reveal_type(do_it)
4444
# note: Revealed type is "def (code: builtins.int) -> httpx._models.Response"
45+
46+
for attempt in stamina.retry_context(on=httpx.HTTPError, attempts=3):
47+
with attempt:
48+
resp = httpx.get(f"https://httpbin.org/status/{code}")
49+
resp.raise_for_status()
4550
```
4651

4752
Async works the same way:
@@ -57,10 +62,16 @@ async def do_it_async(code: int) -> httpx.Response:
5762

5863
# reveal_type(do_it_async)
5964
# note: Revealed type is "def (code: builtins.int) -> typing.Coroutine[Any, Any, httpx._models.Response]"
65+
66+
async for attempt in stamina.retry_context(on=httpx.HTTPError, attempts=3):
67+
with attempt:
68+
async with httpx.AsyncClient() as client:
69+
resp = await client.get(f"https://httpbin.org/status/{code}")
70+
resp.raise_for_status()
6071
```
6172
<!-- example-end -->
6273

63-
The decorator takes the following arguments (**all time-based arguments are floats of seconds**):
74+
Both `retry()` and `retry_context()` take the following arguments (**all time-based arguments are floats of seconds**):
6475

6576
**on**: An Exception or a tuple of Exceptions on which the decorated callable will be retried.
6677
There is no default – you _must_ pass this explicitly.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55

66
[tool.supported-pythons]
7-
# These are canonical truth that is then used by Cog to generate metadata & CI
7+
# This is the canonical truth that is then used by Cog to generate metadata & CI
88
# config.
99
min = "3.8"
1010
max = "3.12"

src/stamina/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
# SPDX-License-Identifier: MIT
44

55
from ._config import is_active, set_active
6-
from ._core import retry
6+
from ._core import retry, retry_context
77
from ._instrumentation import RETRY_COUNTER
88

99

10-
__all__ = ["retry", "is_active", "set_active", "RETRY_COUNTER"]
10+
__all__ = [
11+
"retry",
12+
"retry_context",
13+
"is_active",
14+
"set_active",
15+
"RETRY_COUNTER",
16+
]

src/stamina/_core.py

Lines changed: 174 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import sys
88

99
from collections.abc import Callable
10+
from dataclasses import dataclass, replace
1011
from functools import wraps
1112
from inspect import iscoroutinefunction
1213
from typing import Iterable, TypeVar
@@ -32,6 +33,164 @@
3233
P = ParamSpec("P")
3334

3435

36+
def retry_context(
37+
on: type[Exception] | tuple[type[Exception], ...],
38+
attempts: int | None = 10,
39+
timeout: float | int | None = 45.0,
40+
wait_initial: float | int = 0.1,
41+
wait_max: float | int = 5.0,
42+
wait_jitter: float | int = 1.0,
43+
wait_exp_base: float | int = 2.0,
44+
) -> _RetryContextIterator:
45+
"""
46+
Iterator that yields context managers that can be used to retry code
47+
blocks.
48+
49+
Arguments have the same meaning as for :func:`retry`.
50+
51+
.. versionadded:: 23.1.0
52+
"""
53+
54+
return _RetryContextIterator.from_params(
55+
on=on,
56+
attempts=attempts,
57+
timeout=timeout,
58+
wait_initial=wait_initial,
59+
wait_max=wait_max,
60+
wait_jitter=wait_jitter,
61+
wait_exp_base=wait_exp_base,
62+
name="<context block>",
63+
args=(),
64+
kw={},
65+
)
66+
67+
68+
@dataclass
69+
class _RetryContextIterator:
70+
__slots__ = ("_tenacity_kw", "_name", "_args", "_kw")
71+
_tenacity_kw: dict[str, object]
72+
_name: str
73+
_args: tuple[object, ...]
74+
_kw: dict[str, object]
75+
76+
@classmethod
77+
def from_params(
78+
cls,
79+
on: type[Exception] | tuple[type[Exception], ...],
80+
attempts: int | None,
81+
timeout: float | int | None,
82+
wait_initial: float | int,
83+
wait_max: float | int,
84+
wait_jitter: float | int,
85+
wait_exp_base: float | int,
86+
name: str,
87+
args: tuple[object, ...],
88+
kw: dict[str, object],
89+
) -> _RetryContextIterator:
90+
return cls(
91+
_name=name,
92+
_args=args,
93+
_kw=kw,
94+
_tenacity_kw={
95+
"retry": _t.retry_if_exception_type(on),
96+
"wait": _t.wait_exponential_jitter(
97+
initial=wait_initial,
98+
max=wait_max,
99+
exp_base=wait_exp_base,
100+
jitter=wait_jitter,
101+
),
102+
"stop": _make_stop(attempts=attempts, timeout=timeout),
103+
"reraise": True,
104+
},
105+
)
106+
107+
_STOP_NO_RETRY = _t.stop_after_attempt(1)
108+
109+
def __iter__(self) -> _t.Retrying:
110+
if not _CONFIG.is_active:
111+
return _t.Retrying(
112+
reraise=True, stop=self._STOP_NO_RETRY
113+
).__iter__()
114+
115+
return _t.Retrying(
116+
before_sleep=_make_before_sleep(
117+
self._name, _CONFIG.on_retry, self._args, self._kw
118+
)
119+
if _CONFIG.on_retry
120+
else None,
121+
**self._tenacity_kw,
122+
).__iter__()
123+
124+
def __aiter__(self) -> _t.AsyncRetrying:
125+
if not _CONFIG.is_active:
126+
return _t.AsyncRetrying(
127+
reraise=True, stop=self._STOP_NO_RETRY
128+
).__aiter__()
129+
130+
return _t.AsyncRetrying(
131+
before_sleep=_make_before_sleep(
132+
self._name, _CONFIG.on_retry, self._args, self._kw
133+
)
134+
if _CONFIG.on_retry
135+
else None,
136+
**self._tenacity_kw,
137+
).__aiter__()
138+
139+
def with_name(
140+
self, name: str, args: tuple[object, ...], kw: dict[str, object]
141+
) -> _RetryContextIterator:
142+
"""
143+
Recreate ourselves with a new name and arguments.
144+
"""
145+
return replace(self, _name=name, _args=args, _kw=kw)
146+
147+
148+
def _make_before_sleep(
149+
name: str, on_retry: Iterable[RetryHook], args: object, kw: object
150+
) -> Callable[[_t.RetryCallState], None]:
151+
"""
152+
Create a `before_sleep` callback function that runs our `RetryHook`s with
153+
the necessary arguments.
154+
"""
155+
156+
def before_sleep(rcs: _t.RetryCallState) -> None:
157+
attempt = rcs.attempt_number
158+
exc = rcs.outcome.exception()
159+
backoff = rcs.idle_for
160+
161+
for hook in on_retry:
162+
hook(
163+
attempt=attempt,
164+
backoff=backoff,
165+
exc=exc,
166+
name=name,
167+
args=args,
168+
kwargs=kw,
169+
)
170+
171+
return before_sleep
172+
173+
174+
def _make_stop(*, attempts: int | None, timeout: float | None) -> _t.stop_base:
175+
"""
176+
Combine *attempts* and *timeout* into one stop condition.
177+
"""
178+
stops = []
179+
180+
if attempts:
181+
stops.append(_t.stop_after_attempt(attempts))
182+
if timeout:
183+
stops.append(_t.stop_after_delay(timeout))
184+
185+
if len(stops) > 1:
186+
return _t.stop_any(*stops)
187+
188+
if not stops:
189+
return _t.stop_never
190+
191+
return stops[0]
192+
193+
35194
def retry(
36195
*,
37196
on: type[Exception] | tuple[type[Exception], ...],
@@ -62,14 +221,18 @@ def do_it(code: int) -> httpx.Response:
62221
63222
return resp
64223
"""
65-
retry_ = _t.retry_if_exception_type(on)
66-
wait = _t.wait_exponential_jitter(
67-
initial=wait_initial,
68-
max=wait_max,
69-
exp_base=wait_exp_base,
70-
jitter=wait_jitter,
224+
retry_ctx = _RetryContextIterator.from_params(
225+
on=on,
226+
attempts=attempts,
227+
timeout=timeout,
228+
wait_initial=wait_initial,
229+
wait_max=wait_max,
230+
wait_jitter=wait_jitter,
231+
wait_exp_base=wait_exp_base,
232+
name="<unknown>",
233+
args=(),
234+
kw={},
71235
)
72-
stop = _make_stop(attempts=attempts, timeout=timeout)
73236

74237
def retry_decorator(wrapped: Callable[P, T]) -> Callable[P, T]:
75238
name = guess_name(wrapped)
@@ -78,19 +241,8 @@ def retry_decorator(wrapped: Callable[P, T]) -> Callable[P, T]:
78241

79242
@wraps(wrapped)
80243
def sync_inner(*args: P.args, **kw: P.kwargs) -> T: # type: ignore[return]
81-
if not _CONFIG.is_active:
82-
return wrapped(*args, **kw)
83-
84-
for attempt in _t.Retrying( # noqa: RET503
85-
retry=retry_,
86-
wait=wait,
87-
stop=stop,
88-
reraise=True,
89-
before_sleep=_make_before_sleep(
90-
name, _CONFIG.on_retry, args, kw
91-
)
92-
if _CONFIG.on_retry
93-
else None,
244+
for attempt in retry_ctx.with_name( # noqa: RET503
245+
name, args, kw
94246
):
95247
with attempt:
96248
return wrapped(*args, **kw)
@@ -99,69 +251,12 @@ def sync_inner(*args: P.args, **kw: P.kwargs) -> T: # type: ignore[return]
99251

100252
@wraps(wrapped)
101253
async def async_inner(*args: P.args, **kw: P.kwargs) -> T: # type: ignore[return]
102-
if not _CONFIG.is_active:
103-
return await wrapped(*args, **kw) # type: ignore[no-any-return,misc]
104-
105-
async for attempt in _t.AsyncRetrying( # noqa: RET503
106-
retry=retry_,
107-
wait=wait,
108-
stop=stop,
109-
reraise=True,
110-
before_sleep=_make_before_sleep(
111-
name, _CONFIG.on_retry, args, kw
112-
)
113-
if _CONFIG.on_retry
114-
else None,
254+
async for attempt in retry_ctx.with_name( # noqa: RET503
255+
name, args, kw
115256
):
116257
with attempt:
117258
return await wrapped(*args, **kw) # type: ignore[misc,no-any-return]
118259

119260
return async_inner # type: ignore[return-value]
120261

121262
return retry_decorator
122-
123-
124-
def _make_stop(*, attempts: int | None, timeout: float | None) -> _t.stop_base:
125-
"""
126-
Combine *attempts* and *timeout* into one stop condition.
127-
"""
128-
stops = []
129-
130-
if attempts:
131-
stops.append(_t.stop_after_attempt(attempts))
132-
if timeout:
133-
stops.append(_t.stop_after_delay(timeout))
134-
135-
if len(stops) > 1:
136-
return _t.stop_any(*stops)
137-
138-
if not stops:
139-
return _t.stop_never
140-
141-
return stops[0]
142-
143-
144-
def _make_before_sleep(
145-
name: str, on_retry: Iterable[RetryHook], args: object, kw: object
146-
) -> Callable[[_t.RetryCallState], None]:
147-
"""
148-
Create a `before_sleep` callback function that runs our `RetryHook`s with
149-
the necessary arguments.
150-
"""
151-
152-
def before_sleep(rcs: _t.RetryCallState) -> None:
153-
attempt = rcs.attempt_number
154-
exc = rcs.outcome.exception()
155-
backoff = rcs.idle_for
156-
157-
for hook in on_retry:
158-
hook(
159-
attempt=attempt,
160-
backoff=backoff,
161-
exc=exc,
162-
name=name,
163-
args=args,
164-
kwargs=kw,
165-
)
166-
167-
return before_sleep

0 commit comments

Comments
 (0)