Skip to content

Commit

Permalink
Generalize retrying callers for sharing retry config w/o exc config (#57
Browse files Browse the repository at this point in the history
)

* 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
  • Loading branch information
hynek committed Jan 29, 2024
1 parent 909100f commit 542ea48
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 44 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/

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


## [24.1.0](https://github.com/hynek/stamina/compare/23.3.0...24.1.0) - 2024-01-03
Expand Down
28 changes: 23 additions & 5 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,39 @@
.. autoclass:: Attempt
:members: num
.. autoclass:: RetryingCaller
:members: on, __call__
For example::
def do_something_with_url(url, some_kw):
resp = httpx.get(url)
resp.raise_for_status()
resp = httpx.get(url).raise_for_status()
...
rc = stamina.RetryingCaller(on=httpx.HTTPError)
rc = stamina.RetryingCaller(attempts=5)
rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
rc(httpx.HTTPError, do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
Runs ``do_something_with_url(f"https://httpbin.org/status/404", some_kw=42)`` and retries on ``httpx.HTTPError``.
# Equivalent:
bound_rc = rc.on(httpx.HTTPError)
bound_rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
Both calls to ``rc`` and ``bound_rc`` run
.. code-block:: python
do_something_with_url(f"https://httpbin.org/status/404", some_kw=42)
and retry on ``httpx.HTTPError``.
.. autoclass:: BoundRetryingCaller
:members: __call__
.. autoclass:: AsyncRetryingCaller
:members: on, __call__
.. autoclass:: BoundAsyncRetryingCaller
:members: __call__
```


Expand Down
8 changes: 7 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@

exclude_patterns = ["_build"]

nitpick_ignore = [("py:class", "httpx.HTTPError")]
nitpick_ignore = [
("py:class", "httpx.HTTPError"),
# ParamSpec is not well-supported.
("py:obj", "typing.~P"),
("py:class", "~P"),
("py:class", "stamina._core.T"),
]

# If true, '()' will be appended to :func: etc. cross-reference text.
add_function_parentheses = True
Expand Down
23 changes: 19 additions & 4 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,35 @@ for attempt in stamina.retry_context(on=httpx.HTTPError):
resp.raise_for_status()
```

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`:

## Retry One Function or Method Call

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`:

```python
def do_something_with_url(url, some_kw):
resp = httpx.get(url)
resp.raise_for_status()
...

rc = stamina.RetryingCaller(on=httpx.HTTPError)
rc = stamina.RetryingCaller(attempts=5)

rc(httpx.HTTPError, do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)

# You can also create a caller with a pre-bound exception type:
bound_rc = rc.on(httpx.HTTPError)

rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
bound_rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42)
```

Both `rc` and `bound_rc` run:

```python
do_something_with_url(f"https://httpbin.org/status/404", some_kw=42)
```

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


## Async
Expand Down
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,11 @@ source = ["src", ".nox/tests*/**/site-packages"]
[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
exclude_also = [
'raise SystemError\("unreachable"\)',
# Typing-related
"if TYPE_CHECKING:",
": \\.\\.\\.$",
': \.\.\.$',
]


Expand Down
14 changes: 9 additions & 5 deletions src/stamina/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,25 @@
from ._core import (
AsyncRetryingCaller,
Attempt,
BoundAsyncRetryingCaller,
BoundRetryingCaller,
RetryingCaller,
retry,
retry_context,
)


__all__ = [
"AsyncRetryingCaller",
"Attempt",
"retry",
"retry_context",
"is_active",
"set_active",
"BoundAsyncRetryingCaller",
"BoundRetryingCaller",
"instrumentation",
"is_active",
"retry_context",
"retry",
"RetryingCaller",
"AsyncRetryingCaller",
"set_active",
]


Expand Down
158 changes: 143 additions & 15 deletions src/stamina/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ def __exit__(


class RetryKWs(TypedDict):
on: type[Exception] | tuple[type[Exception], ...]
attempts: int | None
timeout: float | dt.timedelta | None
wait_initial: float | dt.timedelta
Expand All @@ -148,7 +147,6 @@ class BaseRetryingCaller:

def __init__(
self,
on: type[Exception] | tuple[type[Exception], ...],
attempts: int | None = 10,
timeout: float | dt.timedelta | None = 45.0,
wait_initial: float | dt.timedelta = 0.1,
Expand All @@ -157,7 +155,6 @@ def __init__(
wait_exp_base: float = 2.0,
):
self._context_kws = {
"on": on,
"attempts": attempts,
"timeout": timeout,
"wait_initial": wait_initial,
Expand All @@ -167,34 +164,105 @@ def __init__(
}

def __repr__(self) -> str:
on = guess_name(self._context_kws["on"])
kws = ", ".join(
f"{k}={self._context_kws[k]!r}" # type: ignore[literal-required]
for k in sorted(self._context_kws)
if k != "on"
)
return f"<{self.__class__.__name__}(on={on}, {kws})>"
return f"<{self.__class__.__name__}({kws})>"


class RetryingCaller(BaseRetryingCaller):
"""
Call your callables with retries.
Arguments have the same meaning as for :func:`stamina.retry`.
Tip:
Instances of ``RetryingCaller`` may be reused because they create a new
:func:`retry_context` iterator on each call.
Instances of ``RetryingCaller`` may be reused because they internally
create a new :func:`retry_context` iterator on each call.
.. versionadded:: 24.2.0
"""

def __call__(
self, func: Callable[P, T], /, *args: P.args, **kw: P.kwargs
self,
on: type[Exception] | tuple[type[Exception], ...],
callable_: Callable[P, T],
/,
*args: P.args,
**kwargs: P.kwargs,
) -> T:
for attempt in retry_context(**self._context_kws):
r"""
Call ``callable_(*args, **kw)`` with retries if *on* is raised.
Args:
on: Exception(s) to retry on.
callable\_: Callable to call.
args: Positional arguments to pass to *callable_*.
kw: Keyword arguments to pass to *callable_*.
"""
for attempt in retry_context(on, **self._context_kws):
with attempt:
return func(*args, **kw)
return callable_(*args, **kwargs)

raise SystemError("unreachable") # noqa: EM101

def on(
self, on: type[Exception] | tuple[type[Exception], ...], /
) -> BoundRetryingCaller:
"""
Create a new instance of :class:`BoundRetryingCaller` with the same
parameters, but bound to a specific exception type.
raise SystemError("unreachable") # pragma: no cover # noqa: EM101
.. versionadded:: 24.2.0
"""
# This should be a `functools.partial`, but unfortunately it's
# impossible to provide a nicely typed API with it, so we use a
# separate class.
return BoundRetryingCaller(self, on)


class BoundRetryingCaller:
"""
Same as :class:`RetryingCaller`, but pre-bound to a specific exception
type.
Caution:
Returned by :meth:`RetryingCaller.on` -- do not instantiate directly.
.. versionadded:: 24.2.0
"""

__slots__ = ("_caller", "_on")

_caller: RetryingCaller
_on: type[Exception] | tuple[type[Exception], ...]

def __init__(
self,
caller: RetryingCaller,
on: type[Exception] | tuple[type[Exception], ...],
):
self._caller = caller
self._on = on

def __repr__(self) -> str:
return (
f"<BoundRetryingCaller({guess_name(self._on)}, {self._caller!r})>"
)

def __call__(
self, callable_: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs
) -> T:
"""
Same as :func:`RetryingCaller.__call__`, except retry on the exception
that is bound to this instance.
"""
return self._caller(self._on, callable_, *args, **kwargs)


class AsyncRetryingCaller(BaseRetryingCaller):
Expand All @@ -205,13 +273,73 @@ class AsyncRetryingCaller(BaseRetryingCaller):
"""

async def __call__(
self, func: Callable[P, Awaitable[T]], /, *args: P.args, **kw: P.kwargs
self,
on: type[Exception] | tuple[type[Exception], ...],
callable_: Callable[P, Awaitable[T]],
/,
*args: P.args,
**kwargs: P.kwargs,
) -> T:
async for attempt in retry_context(**self._context_kws):
"""
Same as :meth:`RetryingCaller.__call__`, but *callable_* is awaited.
"""
async for attempt in retry_context(on, **self._context_kws):
with attempt:
return await func(*args, **kw)
return await callable_(*args, **kwargs)

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

def on(
self, on: type[Exception] | tuple[type[Exception], ...], /
) -> BoundAsyncRetryingCaller:
"""
Create a new instance of :class:`BoundAsyncRetryingCaller` with the
same parameters, but bound to a specific exception type.
.. versionadded:: 24.2.0
"""
return BoundAsyncRetryingCaller(self, on)


class BoundAsyncRetryingCaller:
"""
Same as :class:`BoundRetryingCaller`, but for async callables.
Caution:
Returned by :meth:`AsyncRetryingCaller.on` -- do not instantiate
directly.
.. versionadded:: 24.2.0
"""

__slots__ = ("_caller", "_on")

_caller: AsyncRetryingCaller
_on: type[Exception] | tuple[type[Exception], ...]

def __init__(
self,
caller: AsyncRetryingCaller,
on: type[Exception] | tuple[type[Exception], ...],
):
self._caller = caller
self._on = on

def __repr__(self) -> str:
return f"<BoundAsyncRetryingCaller({guess_name(self._on)}, {self._caller!r})>"

async def __call__(
self,
callable_: Callable[P, Awaitable[T]],
/,
*args: P.args,
**kwargs: P.kwargs,
) -> T:
"""
Same as :func:`AsyncRetryingCaller.__call__`, except retry on the
exception that is bound to this instance.
"""
return await self._caller(self._on, callable_, *args, **kwargs)


_STOP_NO_RETRY = _t.stop_after_attempt(1)
Expand Down

0 comments on commit 542ea48

Please sign in to comment.