Skip to content

fix: bound RetryTaskException so permanently-failing tasks stop looping - #18

Merged
adhikjoshi merged 1 commit into
mainfrom
fix/bound-retry-task-exception
Aug 18, 2026
Merged

adhikjoshi merged 1 commit into
mainfrom
fix/bound-retry-task-exception

Conversation

@adhikjoshi

@adhikjoshi adhikjoshi commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Problem

RetryTaskException is re-queued unconditionally, with no attempt counter:

except RetryTaskException as e:
    logger.warning(f"Task {task.task_name} requested retry: {e}")
    new_task_dict = task.to_dict()
    new_task_dict["payload"] = task.original_payload
    self.enqueue_delayed_task(new_task_dict, delay_seconds=self.delay_seconds)

A task whose failure is permanent therefore never stops. It re-enters the queue every delay_seconds, forever.

Impact measured in production

On one image worker, over a single 3,238-line log window:

Tasks started 120
Requested retry 119
Distinct task ids involved 47
Max retries seen on one task 9 (within that window alone)

Every failure was a source image that no longer exists — HTTP 404 from an ephemeral delivery URL, or HTTP 403 from a CloudFront signature that had expired 3–4 days earlier. None could ever succeed, and together they were consuming essentially the whole worker capacity of the container.

Fix

Bound the loop.

  • The counter rides on the payload so it survives the round trip through Redis.
  • It uses a reserved _retry_attempts key, so it cannot collide with the caller-facing retries budget that the TaskProcessingError path already decrements (base.py ~864–877).
  • Once the budget is spent, the task goes through the normal failure path — final state stored, error logged, on_error middleware, webhook — instead of being silently re-queued.
  • Configurable via retry_task_max_attempts, default 3, so genuinely transient retries (e.g. a model that briefly fails to load) behave as before.

Tests

tests/test_retry_budget.py covers:

  • the budget being spent, and no further re-queue afterwards
  • the reserved counter leaving the caller's own retries value untouched
  • a zero budget failing immediately
  • the default being finite (the old behaviour was unbounded)
66 passed   (with fix)
62 passed   (baseline, new file excluded)

No regressions; the 4 new tests are the difference.

Note

This caps the blast radius for every task type. The matching upstream fix — not classifying a permanent 4xx download failure as retryable in the first place — is handled separately in dreambooth-server.


View with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is enabled.

RetryTaskException was re-queued unconditionally:

    except RetryTaskException as e:
        new_task_dict = task.to_dict()
        new_task_dict["payload"] = task.original_payload
        self.enqueue_delayed_task(new_task_dict, delay_seconds=self.delay_seconds)

There is no attempt counter, so a task whose failure is permanent never stops.
It re-enters the queue every delay_seconds, forever.

Observed in production on an image worker: over one 3,238-line log window,
120 tasks started and 119 requested a retry, spread across only 47 distinct
task ids - one task had been retried 9 times inside that window alone. Every
one had failed on a source image that no longer exists (HTTP 404 from an
ephemeral delivery URL, or HTTP 403 from a CloudFront signature that expired
3-4 days earlier). None of them could ever succeed, and together they were
consuming essentially the entire worker capacity of the container.

Bound the loop. The counter rides on the payload so it survives the round trip
through Redis, under a reserved "_retry_attempts" key so it cannot collide with
the caller-facing "retries" budget that the TaskProcessingError path already
decrements. Once the budget is spent the task is marked failed and taken
through the normal failure path (final state stored, error log, on_error
middleware, webhook), rather than being silently re-queued.

The limit is configurable via retry_task_max_attempts and defaults to 3, so
genuinely transient retries (a model that briefly fails to load) still work as
before while an unfixable task now dies after a few attempts.

Tests cover the budget being spent, the reserved counter leaving the caller's
own "retries" value untouched, a zero budget failing immediately, and the
default being finite.
@adhikjoshi
adhikjoshi merged commit 0831d65 into main Aug 18, 2026
3 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.

1 participant