Skip to content
Draft
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
3 changes: 2 additions & 1 deletion backoff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
from backoff._common import Attempt
from backoff._decorator import aretry_context, on_exception, on_predicate, retry_context
from backoff._jitter import full_jitter, random_jitter
from backoff._wait_gen import constant, decay, expo, fibo, runtime
from backoff._wait_gen import capped, constant, decay, expo, fibo, runtime

__all__ = [
"Attempt",
"aretry_context",
"capped",
"constant",
"decay",
"expo",
Expand Down
45 changes: 45 additions & 0 deletions backoff/_wait_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
if TYPE_CHECKING:
from collections.abc import Callable, Generator, Iterable

from backoff._typing import _WaitGenerator


def expo(
base: float = 2,
Expand Down Expand Up @@ -99,6 +101,49 @@ def constant(interval: float | Iterable[float] = 1) -> Generator[float, Any, Non
yield val


def capped(
wait_gen: _WaitGenerator,
*,
min_value: float | None = None,
max_value: float | None = None,
) -> _WaitGenerator:
"""Wraps a wait generator, clamping each value it yields.

Useful for wait generators without their own bound, such as
`constant` or `runtime` (e.g. capping a server-provided
`Retry-After` value so a misbehaving server can't stall retries
indefinitely):

backoff.on_predicate(
backoff.capped(backoff.runtime, max_value=60),
predicate=lambda r: r.status_code == 429,
value=lambda r: int(r.headers.get("Retry-After", 1)),
)

Args:
wait_gen: The wait generator to wrap.
min_value: The minimum value to yield. Values below this are
raised to min_value.
max_value: The maximum value to yield. Values above this are
lowered to max_value.
"""

def generator(**kwargs: Any) -> Generator[float, Any, None]:
gen = wait_gen(**kwargs)
gen.send(None)

send_value = yield 0
while True:
value = gen.send(send_value)
if max_value is not None:
value = min(value, max_value)
if min_value is not None:
value = max(value, min_value)
send_value = yield value

return generator


def runtime(*, value: Callable[[Any], float]) -> Generator[float, Any, None]:
"""Generator that is based on parsing the return value or thrown
exception of the decorated method
Expand Down
5 changes: 5 additions & 0 deletions docs/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ Complete API documentation for the backoff module.
show_root_heading: true
show_source: true

::: backoff.capped
options:
show_root_heading: true
show_source: true

## Jitter Functions

::: backoff.full_jitter
Expand Down
15 changes: 15 additions & 0 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ def my_function():
- **Fibonacci** (`backoff.fibo`) - Gentler backoff, good for polling
- **Constant** (`backoff.constant`) - Fixed intervals, good for regular polling
- **Runtime** (`backoff.runtime`) - Server-directed wait times (Retry-After headers)
- **Capped** (`backoff.capped`) - Wraps another strategy to clamp its wait times, e.g. bounding `runtime`

### What is jitter and why is it important?

Expand All @@ -179,6 +180,20 @@ def api_call():
return requests.get(url)
```

To guard against an untrusted or misbehaving server sending an excessive
value, wrap it with `backoff.capped`:

```python
@backoff.on_predicate(
backoff.capped(backoff.runtime, max_value=60),
predicate=lambda r: r.status_code == 429,
value=lambda r: int(r.headers.get("Retry-After", 1)),
jitter=None,
)
def api_call():
return requests.get(url)
```

## Async Questions

### Does backoff work with async/await?
Expand Down
28 changes: 28 additions & 0 deletions docs/user-guide/wait-strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,34 @@ def custom_retry():
- Custom retry logic from application responses
- API rate limiting with Retry-After headers

## Capped

Wraps another wait generator and clamps each value it yields. Useful for
bounding a wait strategy that has no cap of its own — `constant` or
`runtime`, for example — such as when a misbehaving server sends an
excessive `Retry-After` value:

```python
@backoff.on_predicate(
backoff.capped(backoff.runtime, max_value=60),
predicate=lambda r: r.status_code == 429,
value=lambda r: int(r.headers.get("Retry-After", 1)),
)
def api_call():
return requests.get(api_url)
```

### Parameters

- **wait_gen** - The wait generator to wrap
- **min_value** - Minimum value to yield (default: None)
- **max_value** - Maximum value to yield (default: None)

### Best For

- Bounding `runtime`/`constant` wait times that have no built-in cap
- Guarding against untrusted or misbehaving server-provided delays

## Jitter

All wait strategies support jitter to add randomness and prevent thundering herd problems.
Expand Down
22 changes: 22 additions & 0 deletions tests/test_wait_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,25 @@ def test_runtime() -> None:
gen.send(None)
for i in range(20):
assert i == gen.send(i)


def test_capped_max_value() -> None:
gen = backoff.capped(backoff.expo, max_value=10)()
gen.send(None)
expected = [1, 2, 4, 8, 10, 10, 10]
for expect in expected:
assert expect == next(gen)


def test_capped_min_value() -> None:
gen = backoff.capped(backoff.decay, min_value=5)(decay_factor=3)
gen.send(None)
for i in range(10):
assert max(math.e ** (-i * 3), 5) == pytest.approx(next(gen))


def test_capped_passes_through_kwargs_and_send_value() -> None:
gen = backoff.capped(backoff.runtime, max_value=60)(value=lambda x: x)
gen.send(None)
assert gen.send(30) == 30
assert gen.send(100) == 60