From 1bbfcb1e4de843eef61bc0d8655e3c676c12a148 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Mon, 5 Oct 2026 19:59:06 +0200 Subject: [PATCH] Fix ExponentialBackoff overflow above attempt 46 The delay was computed as a timedelta multiplication before min() clamped it, so the product overflowed at attempt 47 and Executor.retry_delay ended the retry chain early. Fixes #47 --- tests/test_retry.py | 12 ++++++++++++ threadmill/retry.py | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_retry.py b/tests/test_retry.py index 40a9aaf..3b30f8b 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -63,6 +63,18 @@ def test_call__caps_delay_at_max_delay(self) -> None: delay = backoff(context) assert delay == backoff.max_delay + def test_call__returns_max_delay_for_large_attempt(self) -> None: + """Return max_delay instead of raising OverflowError above attempt 46.""" + backoff = retry.ExponentialBackoff( + base_delay=datetime.timedelta(seconds=1), + max_delay=datetime.timedelta(hours=1), + factor=2.0, + max_retries=720, + expected_exceptions=(ValueError,), + ) + context = _context(attempt=47, exception_class=ValueError) + assert backoff(context) == backoff.max_delay + def test_call__returns_none_after_max_retries(self) -> None: """Return None when the attempt count reaches max_retries.""" backoff = retry.ExponentialBackoff( diff --git a/threadmill/retry.py b/threadmill/retry.py index 25e720e..0cf30c7 100644 --- a/threadmill/retry.py +++ b/threadmill/retry.py @@ -52,7 +52,11 @@ def __call__(self, context: TaskContext) -> datetime.timedelta | None: if context.attempt < self.max_retries and issubclass( context.task_result.errors[-1].exception_class, self.expected_exceptions ): - return min(self.base_delay * (self.factor**context.attempt), self.max_delay) + delay_secs = min( + self.base_delay.total_seconds() * self.factor**context.attempt, + self.max_delay.total_seconds(), + ) + return datetime.timedelta(seconds=delay_secs) def deconstruct(self): return (