Skip to content

fix(executors): cap a retry backoff that overflows timedelta - #1040

Merged
janbjorge merged 1 commit into
janbjorge:mainfrom
ericdelorefice:fix/retry-backoff-overflow
Oct 25, 2026
Merged

janbjorge merged 1 commit into
janbjorge:mainfrom
ericdelorefice:fix/retry-backoff-overflow

Conversation

@ericdelorefice

@ericdelorefice ericdelorefice commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Agentic

Summary

DatabaseRetryEntrypointExecutor works out the retry delay as min(initial_delay * backoff_multiplier**attempts, max_delay). The product is built before the cap, so once it passes what a timedelta can hold (999999999 days), the multiplication raises OverflowError. That happens inside the executor's except block, so the queue manager records the job as a terminal failure with an OverflowError traceback while max_attempts still has attempts left. With the defaults (1 s, x2) that is attempt 47, and with a multiplier of 10 it is attempt 14. With a very large attempt count the float power overflows first (2.0**1100).

The default max_attempts=5 never gets there. It shows up when the budget is set large on purpose and the cap does the work, for example max_attempts=300 with the default 5 minute cap to ride out a long outage of a downstream service. With 1 s and x2 that job ends at attempt 47, about 3.3 hours in (worked out from the schedule, not run for that long).

I ran a real QueueManager with asyncpg against PostgreSQL 16.2, on main (1ed405a) and on PyPI 1.6.0, nothing mocked. The handler raises ConnectionError every time. The delays are in milliseconds so a run takes seconds; where it stops depends on initial_delay * backoff_multiplier**attempts, not on max_delay.

1 ms initial, 20 ms cap      main and 1.6.0                       with the fix
x2,  max_attempts=100        58 calls, ends at attempt 57         101 calls, ends at attempt 100
                             terminal error: OverflowError        terminal error: ConnectionError
x10, max_attempts=30         18 calls, ends at attempt 17         31 calls, ends at attempt 30
                             terminal error: OverflowError        terminal error: ConnectionError

The same happens with on_failure="hold" (the job is held as failed with attempts 57 and 17 on main), and on PsycopgDriver (the x10 run, on main and with the fix).

This is separate from #1030 / #1039, where the retry_job write fails. Here the retry is never requested.

Change

pgqueuer/core/executors.py, +8/-4 in one file: the existing min(...) moves under a try, and except OverflowError falls back to max_delay, with a one-line comment. A delay that fits is computed exactly as before, so every delay below the overflow point is unchanged. docs/guides/retry.md already describes max_delay as the cap on the backoff, so the docs need no change.

Testing

  • uv sync --frozen && uv run ruff check . && uv run ruff format . --check && uv run lint-imports && uv run mypy . && uv run pytest passed (PostgreSQL 16.2 through EXTERNAL_POSTGRES_DSN, Python 3.12; mypy run as mypy . --no-incremental): 1535 passed, 10 skipped. On main: 1528 passed, 10 skipped (the 7 new cases are the difference).
  • Additional testing steps: test_retry_requeue.py gets two tests. test_database_retry_executor_caps_delay_past_timedelta_range calls the executor directly at attempt 47 (x2), 14 (x10) and 1100 (past the float range), which all raise OverflowError on main and get max_delay with the fix, plus attempts 3 and 46 as controls that pass on both. test_database_retry_executor_uses_full_budget_postgres drains a real queue with 1 ms, x10 and max_attempts=20: on main the handler stops at attempt 17, and with the fix it sees attempts 0 to 20 and the terminal log row carries the handler's ValueError. Its max_attempts=10 case stays below the overflow and passes on both.

#1039 also adds to test_retry_requeue.py and the RELEASE.md Fixed list. The test hunks don't overlap; whichever lands second may need a one-line RELEASE.md rebase.

Checklist

  • I have read the Contributing Guide
  • I have added or updated tests
  • I have updated documentation if necessary

DatabaseRetryEntrypointExecutor multiplied initial_delay by
backoff_multiplier**attempts before applying max_delay. Once that
product passes timedelta's range (attempt 47 with the defaults,
attempt 14 with a multiplier of 10) the multiplication raises
OverflowError inside the executor's except block, so the job ends as
exception (or failed under on_failure="hold") with an OverflowError
traceback while max_attempts still has attempts left. A delay that
large is past any max_delay, so fall back to the cap.
@janbjorge

Copy link
Copy Markdown
Owner

cool, thansk!

@janbjorge
janbjorge merged commit bb27500 into janbjorge:main Oct 25, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants