Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions backoff/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async def _call_handlers(


def retry_predicate(
target: Callable[P, T],
target: Callable[P, Coroutine[object, object, T]],
wait_gen: _WaitGenerator,
predicate: _Predicate[T],
*,
Expand All @@ -96,7 +96,7 @@ def retry_predicate(
on_backoff: Iterable[_Handler],
on_giveup: Iterable[_Handler],
wait_gen_kwargs: dict[str, Any],
) -> Callable[P, T]:
) -> Callable[P, Coroutine[object, object, T]]:
on_try = _ensure_coroutines(on_try)
on_success = _ensure_coroutines(on_success)
on_backoff = _ensure_coroutines(on_backoff)
Expand Down Expand Up @@ -158,7 +158,7 @@ async def retry(*args: P.args, **kwargs: P.kwargs) -> T:

return ret

return retry # type: ignore[return-value] # ty:ignore[invalid-return-type]
return retry


def _adapt_context_handlers(
Expand All @@ -181,7 +181,7 @@ async def adapted(details: ContextDetails) -> None:


def retry_exception(
target: Callable[P, T],
target: Callable[P, Coroutine[object, object, T]],
wait_gen: _WaitGenerator,
exception: _MaybeTuple[type[Exception]],
*,
Expand All @@ -195,7 +195,7 @@ def retry_exception(
on_giveup: Iterable[_Handler],
raise_on_giveup: bool,
wait_gen_kwargs: dict[str, Any],
) -> Callable[P, T]:
) -> Callable[P, Coroutine[object, object, T]]:
on_try = _ensure_coroutines(on_try)
on_success = _ensure_coroutines(on_success)
on_backoff = _ensure_coroutines(on_backoff)
Expand Down Expand Up @@ -228,11 +228,11 @@ async def retry(
wait_gen_kwargs=wait_gen_kwargs,
):
with attempt:
ret = await target(*args, **kwargs) # type: ignore[misc] # ty:ignore[invalid-await]
ret = await target(*args, **kwargs)

return ret

return retry # type: ignore[return-value] # ty:ignore[invalid-return-type]
return retry


async def _dispatch_handlers(
Expand Down
122 changes: 74 additions & 48 deletions backoff/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import inspect
import logging
import operator
from typing import TYPE_CHECKING, Any, Callable, TypeVar
from typing import TYPE_CHECKING, Any, Callable, cast

from backoff import _async, _sync
from backoff._common import (
Expand All @@ -18,11 +18,11 @@
from backoff._wait_gen import expo

if TYPE_CHECKING:
import sys
from collections.abc import AsyncGenerator, Generator, Iterable

from backoff._common import _Attempt
from backoff._typing import (
_CallableT,
_ContextHandler,
_Handler,
_Jitterer,
Expand All @@ -33,14 +33,6 @@
_WaitGenerator,
)

if sys.version_info >= (3, 10):
from typing import ParamSpec
else:
from typing_extensions import ParamSpec

T = TypeVar("T")
P = ParamSpec("P")


def on_predicate(
wait_gen: _WaitGenerator,
Expand All @@ -57,7 +49,7 @@ def on_predicate(
backoff_log_level: int = logging.INFO,
giveup_log_level: int = logging.ERROR,
**wait_gen_kwargs: Any,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
) -> Callable[[_CallableT], _CallableT]:
"""Returns decorator for backoff and retry triggered by predicate.

Args:
Expand Down Expand Up @@ -105,7 +97,7 @@ def on_predicate(
This is useful for runtime configuration.
"""

def decorate(target: Callable[P, T]) -> Callable[P, T]:
def decorate(target: _CallableT) -> _CallableT:
nonlocal logger, on_try, on_success, on_backoff, on_giveup

logger = _prepare_logger(logger)
Expand All @@ -125,22 +117,38 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]:
)

if inspect.iscoroutinefunction(target):
retry = _async.retry_predicate
else:
retry = _sync.retry_predicate

return retry(
target,
wait_gen,
predicate,
max_tries=max_tries,
max_time=max_time,
jitter=jitter,
on_try=on_try,
on_success=on_success,
on_backoff=on_backoff,
on_giveup=on_giveup,
wait_gen_kwargs=wait_gen_kwargs,
return cast(
"_CallableT",
_async.retry_predicate(
target,
wait_gen,
predicate,
max_tries=max_tries,
max_time=max_time,
jitter=jitter,
on_try=on_try,
on_success=on_success,
on_backoff=on_backoff,
on_giveup=on_giveup,
wait_gen_kwargs=wait_gen_kwargs,
),
)

return cast(
"_CallableT",
_sync.retry_predicate(
target,
wait_gen,
predicate,
max_tries=max_tries,
max_time=max_time,
jitter=jitter,
on_try=on_try,
on_success=on_success,
on_backoff=on_backoff,
on_giveup=on_giveup,
wait_gen_kwargs=wait_gen_kwargs,
),
)

# Return a function which decorates a target with a retry loop.
Expand All @@ -164,7 +172,7 @@ def on_exception(
backoff_log_level: int = logging.INFO,
giveup_log_level: int = logging.ERROR,
**wait_gen_kwargs: Any,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
) -> Callable[[_CallableT], _CallableT]:
"""Returns decorator for backoff and retry triggered by exception.

Args:
Expand Down Expand Up @@ -214,7 +222,7 @@ def on_exception(
This is useful for runtime configuration.
"""

def decorate(target: Callable[P, T]) -> Callable[P, T]:
def decorate(target: _CallableT) -> _CallableT:
nonlocal logger, on_try, on_success, on_backoff, on_giveup

logger = _prepare_logger(logger)
Expand All @@ -234,24 +242,42 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]:
)

if inspect.iscoroutinefunction(target):
retry = _async.retry_exception
else:
retry = _sync.retry_exception

return retry(
target,
wait_gen,
exception,
max_tries=max_tries,
max_time=max_time,
jitter=jitter,
giveup=giveup,
on_try=on_try,
on_success=on_success,
on_backoff=on_backoff,
on_giveup=on_giveup,
raise_on_giveup=raise_on_giveup,
wait_gen_kwargs=wait_gen_kwargs,
return cast(
"_CallableT",
_async.retry_exception(
target,
wait_gen,
exception,
max_tries=max_tries,
max_time=max_time,
jitter=jitter,
giveup=giveup,
on_try=on_try,
on_success=on_success,
on_backoff=on_backoff,
on_giveup=on_giveup,
raise_on_giveup=raise_on_giveup,
wait_gen_kwargs=wait_gen_kwargs,
),
)

return cast(
"_CallableT",
_sync.retry_exception(
target,
wait_gen,
exception,
max_tries=max_tries,
max_time=max_time,
jitter=jitter,
giveup=giveup,
on_try=on_try,
on_success=on_success,
on_backoff=on_backoff,
on_giveup=on_giveup,
raise_on_giveup=raise_on_giveup,
wait_gen_kwargs=wait_gen_kwargs,
),
)

# Return a function which decorates a target with a retry loop.
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ commands = [
"tests",
], extend = true },
],
[
"mypy",
"--strict",
"--disallow-any-decorated",
"--follow-imports=silent",
"tests/typing_decorators.py",
],
[
"ty",
"check",
Expand Down
54 changes: 54 additions & 0 deletions tests/typing_decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import asyncio
import sys

if sys.version_info >= (3, 11):
from typing import assert_type
else:
from typing_extensions import assert_type

import backoff


@backoff.on_exception(backoff.expo, ValueError)
def fetch_sync_with_exception(value: str, *, suffix: str) -> str:
return value + suffix


@backoff.on_predicate(backoff.expo)
def fetch_sync_with_predicate(value: str, *, suffix: str) -> str:
return value + suffix


@backoff.on_exception(backoff.expo, ValueError)
async def fetch_async_with_exception(value: str, *, suffix: str) -> str:
return value + suffix


@backoff.on_predicate(backoff.expo)
async def fetch_async_with_predicate(value: str, *, suffix: str) -> str:
return value + suffix


def consume_sync_decorated_functions() -> None:
exception_result = fetch_sync_with_exception("exception", suffix=" result")
predicate_result = fetch_sync_with_predicate("predicate", suffix=" result")
assert_type(exception_result, str)
assert_type(predicate_result, str)


async def consume_async_decorated_functions() -> None:
exception_result = await fetch_async_with_exception("exception", suffix=" result")
predicate_result = await fetch_async_with_predicate("predicate", suffix=" result")
assert_type(exception_result, str)
assert_type(predicate_result, str)

exception_task = asyncio.create_task(
fetch_async_with_exception("exception", suffix=" task")
)
predicate_task = asyncio.create_task(
fetch_async_with_predicate("predicate", suffix=" task")
)
assert_type(exception_task, asyncio.Task[str])
assert_type(predicate_task, asyncio.Task[str])
assert_type(await exception_task, str)
assert_type(await predicate_task, str)
17 changes: 17 additions & 0 deletions tests/typing_pyrefly.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually exercise something? i.e. does this fail somehow without the decorator typing changes? Otherwise, it's not clear to me what this tests.

@MeisQuietude MeisQuietude Aug 28, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers the sync side of the decorator typing change. Pyrefly infers str for these unannotated sync functions, and the decorated calls must preserve that inferred type instead of becoming coroutines. The strict mypy test cannot cover this case because it requires explicit return annotations.

it guards against an alternative overload-based implementation of this fix.

For this sync function:

@backoff.on_exception(backoff.expo, ValueError)
def load_value(value: str):
    return value

load_value("hello").upper()

The results are:

  • Callable[[_CallableT], _CallableT]: Pyrefly preserves the inferred sync return type, so this passes.
  • Async-first + generic-sync overloads: Pyrefly resolves the call as a coroutine and reports Coroutine has no attribute upper.

This test prevents fixing the async case by accidentally breaking unannotated sync functions.

--

But ofc if you're willing to remove it, just say and I do it. I appeciate your work :)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. I did consider exploring the @overload option, so it's good to have in case I forget that it won't work 😅.

Thanks!

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import backoff


@backoff.on_exception(backoff.expo, ValueError)
def fetch_with_exception(value: str):
return value


@backoff.on_predicate(backoff.expo)
def fetch_with_predicate(value: str):
return value


def consume_sync_results() -> str:
exception_result = fetch_with_exception("exception")
predicate_result = fetch_with_predicate("predicate")
return exception_result.upper() + predicate_result.upper()