Skip to content

Commit 77a06e2

Browse files
Add multiplier parameter to ExponentialBackoff (#151)
Co-authored-by: Amp <amp@ampcode.com>
1 parent 0f96fd6 commit 77a06e2

File tree

4 files changed

+111
-1
lines changed

4 files changed

+111
-1
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added `multiplier` parameter to `ExponentialBackoff` for scaling the exponential term.
13+
1014
## [0.7.0] - 2025-11-27
1115

1216
### Added

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,18 @@ def send_email(to, subject, body):
358358

359359
The default policy is the exponential backoff. We recommend setting both the backoff policy and the dead-letter queue to limit the maximum number of execution attempts.
360360

361+
The `ExponentialBackoff` policy calculates the visibility timeout using the formula:
362+
363+
```
364+
min_visibility_timeout + multiplier * (base ** attempts)
365+
```
366+
367+
Parameters:
368+
- `base` (default: 2): Exponential base in seconds
369+
- `min_visibility_timeout` (default: 0): Minimum delay in seconds
370+
- `max_visibility_timeout` (default: 1800): Maximum delay cap in seconds
371+
- `multiplier` (default: 1): Scaling factor for the exponential term
372+
361373
Alternatively, you can set the backoff to IMMEDIATE_RETURN to re-execute the failed task immediately.
362374

363375
```python

sqs_workers/backoff_policies.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,18 @@ def __init__(
3030
base: float = 2,
3131
min_visibility_timeout: float = 0,
3232
max_visbility_timeout: float = 30 * 60,
33+
multiplier: float = 1,
3334
) -> None:
3435
self.base = base # in seconds
3536
self.min_visibility_timeout = min_visibility_timeout
3637
self.max_visibility_timeout = max_visbility_timeout
38+
self.multiplier = multiplier
3739

3840
def get_visibility_timeout(self, message) -> int:
3941
prev_receive_count = int(message.attributes["ApproximateReceiveCount"]) - 1
40-
mu = self.min_visibility_timeout + (self.base**prev_receive_count)
42+
mu = self.min_visibility_timeout + self.multiplier * (
43+
self.base**prev_receive_count
44+
)
4145
sigma = float(mu) / 10
4246
visibility_timeout = random.normalvariate(mu, sigma)
4347
visibility_timeout = max(self.min_visibility_timeout, visibility_timeout)

tests/test_backoff_policies.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import random
2+
from unittest.mock import MagicMock
3+
4+
import pytest
5+
6+
from sqs_workers.backoff_policies import (
7+
ConstantBackoff,
8+
ExponentialBackoff,
9+
)
10+
11+
12+
@pytest.fixture
13+
def mock_message():
14+
def _make_message(receive_count: int):
15+
message = MagicMock()
16+
message.attributes = {"ApproximateReceiveCount": str(receive_count)}
17+
return message
18+
19+
return _make_message
20+
21+
22+
class TestConstantBackoff:
23+
def test_returns_constant_value(self):
24+
policy = ConstantBackoff(backoff_value=30)
25+
message = MagicMock()
26+
assert policy.get_visibility_timeout(message) == 30
27+
28+
def test_default_is_zero(self):
29+
policy = ConstantBackoff()
30+
message = MagicMock()
31+
assert policy.get_visibility_timeout(message) == 0
32+
33+
34+
class TestExponentialBackoff:
35+
def test_default_parameters_backward_compatible(self, mock_message):
36+
policy = ExponentialBackoff()
37+
assert policy.base == 2
38+
assert policy.min_visibility_timeout == 0
39+
assert policy.max_visibility_timeout == 30 * 60
40+
assert policy.multiplier == 1
41+
42+
def test_first_attempt_uses_base(self, mock_message):
43+
random.seed(42)
44+
policy = ExponentialBackoff(base=2, min_visibility_timeout=0, multiplier=1)
45+
timeout = policy.get_visibility_timeout(mock_message(1))
46+
assert timeout == 1
47+
48+
def test_exponential_growth(self, mock_message):
49+
random.seed(42)
50+
policy = ExponentialBackoff(base=2, min_visibility_timeout=0, multiplier=1)
51+
timeouts = [policy.get_visibility_timeout(mock_message(i)) for i in range(1, 6)]
52+
for i in range(1, len(timeouts)):
53+
assert timeouts[i] >= timeouts[i - 1]
54+
55+
def test_multiplier_scales_timeout(self, mock_message):
56+
random.seed(42)
57+
policy_no_mult = ExponentialBackoff(
58+
base=2, min_visibility_timeout=0, multiplier=1
59+
)
60+
timeout_no_mult = policy_no_mult.get_visibility_timeout(mock_message(3))
61+
62+
random.seed(42)
63+
policy_with_mult = ExponentialBackoff(
64+
base=2, min_visibility_timeout=0, multiplier=10
65+
)
66+
timeout_with_mult = policy_with_mult.get_visibility_timeout(mock_message(3))
67+
68+
assert timeout_with_mult == pytest.approx(timeout_no_mult * 10, rel=0.2)
69+
70+
def test_min_visibility_timeout_is_floor(self, mock_message):
71+
random.seed(42)
72+
policy = ExponentialBackoff(base=2, min_visibility_timeout=60)
73+
timeout = policy.get_visibility_timeout(mock_message(1))
74+
assert timeout >= 60
75+
76+
def test_max_visibility_timeout_is_ceiling(self, mock_message):
77+
random.seed(42)
78+
policy = ExponentialBackoff(
79+
base=10, min_visibility_timeout=0, max_visbility_timeout=100, multiplier=100
80+
)
81+
timeout = policy.get_visibility_timeout(mock_message(10))
82+
assert timeout <= 100
83+
84+
def test_multiplier_combined_with_min_timeout(self, mock_message):
85+
random.seed(42)
86+
policy = ExponentialBackoff(
87+
base=2, min_visibility_timeout=10, multiplier=5, max_visbility_timeout=1000
88+
)
89+
timeout = policy.get_visibility_timeout(mock_message(3))
90+
assert timeout >= 10

0 commit comments

Comments
 (0)