Repository navigation
fix(executors): cap a retry backoff that overflows timedelta - #1040
Merged
janbjorge merged 1 commit intoOct 25, 2026
Merged
Conversation
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.
Owner
|
cool, thansk! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Agentic
Summary
DatabaseRetryEntrypointExecutorworks out the retry delay asmin(initial_delay * backoff_multiplier**attempts, max_delay). The product is built before the cap, so once it passes what atimedeltacan hold (999999999 days), the multiplication raisesOverflowError. That happens inside the executor'sexceptblock, so the queue manager records the job as a terminal failure with anOverflowErrortraceback whilemax_attemptsstill 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=5never gets there. It shows up when the budget is set large on purpose and the cap does the work, for examplemax_attempts=300with 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
QueueManagerwith asyncpg against PostgreSQL 16.2, on main (1ed405a) and on PyPI 1.6.0, nothing mocked. The handler raisesConnectionErrorevery time. The delays are in milliseconds so a run takes seconds; where it stops depends oninitial_delay * backoff_multiplier**attempts, not onmax_delay.The same happens with
on_failure="hold"(the job is held asfailedwith attempts 57 and 17 on main), and onPsycopgDriver(the x10 run, on main and with the fix).This is separate from #1030 / #1039, where the
retry_jobwrite fails. Here the retry is never requested.Change
pgqueuer/core/executors.py, +8/-4 in one file: the existingmin(...)moves under atry, andexcept OverflowErrorfalls back tomax_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.mdalready describesmax_delayas 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 pytestpassed (PostgreSQL 16.2 throughEXTERNAL_POSTGRES_DSN, Python 3.12; mypy run asmypy . --no-incremental): 1535 passed, 10 skipped. On main: 1528 passed, 10 skipped (the 7 new cases are the difference).test_retry_requeue.pygets two tests.test_database_retry_executor_caps_delay_past_timedelta_rangecalls the executor directly at attempt 47 (x2), 14 (x10) and 1100 (past the float range), which all raiseOverflowErroron main and getmax_delaywith the fix, plus attempts 3 and 46 as controls that pass on both.test_database_retry_executor_uses_full_budget_postgresdrains a real queue with 1 ms, x10 andmax_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'sValueError. Itsmax_attempts=10case stays below the overflow and passes on both.#1039 also adds to
test_retry_requeue.pyand theRELEASE.mdFixed list. The test hunks don't overlap; whichever lands second may need a one-lineRELEASE.mdrebase.Checklist