Skip to content

SmartRetryMiddleware: retry delay compounds without schedule_source #716

Open

Description

@ericdelorefice

Taskiq version

0.13.0 (same on main at f08a54a)

Python version

Python 3.12

OS

macOS

What happened?

SmartRetryMiddleware sends a different delay on each retry depending on whether schedule_source is set.

Without schedule_source, on_send re-kicks the task with with_labels(delay=delay). That sets the message's delay label to the delay it just computed. On the next failure make_delay reads that label back as the base and works from it again. With schedule_source the label is left alone, so every retry starts from the original value.

Six failed attempts with default_delay=5 and the default max_delay_exponent=60. The broker rows are the delay label sent with each retry, and the schedule_source rows are the scheduled delay:

broker, exponent:        [5.0, 10.0, 30.0, 60.0, 60.0, 60.0]
schedule_source, exp.:   [5.0, 10.0, 15.0, 20.0, 25.0, 30.0]
broker, jitter:          [5.8, 6.6, 7.0, 7.3, 7.8, 8.2]
schedule_source, jitter: [5.8, 5.8, 5.4, 5.3, 5.5, 5.4]
broker, defaults:        [5.0, 5.0, 5.0, 5.0, 5.0, 5.0]

The label only changes the real wait on brokers that follow the delay convention, like taskiq-sqs. On those, use_delay_exponent hits the cap on the fourth retry. With use_jitter, each retry adds its random part on top of the last one, so the delay keeps creeping up. I get the same labels from a real InMemoryBroker worker loop, not just the harness below.

Neither path is exponential in the usual sense. With schedule_source each retry is base * n (5, 10, 15). Without it, each retry multiplies the previous result by the retry count (5, 10, 30, then 120 capped to 60), so it grows as base * n!. The problem is that the result depends on whether schedule_source is set. The docs describe the task's delay argument as "Initial delay before retrying the task, in seconds."

I'm happy to send a PR, but I wasn't sure which fix you'd want. The broker docs (docs/extending-taskiq/broker.md, Conventions) say a broker should delay execution by the delay label, so the retry still has to set it. One way is to keep the first delay in its own private label, the way _retries is kept, and compute from that. If the growth on the broker path is what you meant, the schedule_source path is the one to change instead.

Broker initialization code

Full repro, no services needed:

import asyncio
import datetime
import random

from taskiq import InMemoryBroker, ScheduleSource, SmartRetryMiddleware
from taskiq.message import TaskiqMessage
from taskiq.result import TaskiqResult


class Broker(InMemoryBroker):
    async def kick(self, message):
        self.last = message


class Source(ScheduleSource):
    async def get_schedules(self):
        return []

    async def add_schedule(self, schedule):
        self.last = schedule


async def delays(source=None, **kwargs):
    mw = SmartRetryMiddleware(
        default_retry_label=True, default_retry_count=8, default_delay=5,
        schedule_source=source, **kwargs,
    )
    broker = Broker().with_middlewares(mw)
    labels, out = {}, []
    for _ in range(6):
        now = datetime.datetime.now(datetime.timezone.utc)
        msg = TaskiqMessage(task_id="t", task_name="t", labels=labels, args=[], kwargs={})
        result = TaskiqResult(is_err=True, return_value=None, execution_time=0)
        await mw.on_error(msg, result, ValueError())
        if source is None:  # the next worker gets the re-kicked message
            labels = dict(broker.last.labels)
            out.append(round(float(labels["delay"]), 1))
        else:
            labels = dict(source.last.labels)
            out.append(round((source.last.time - now).total_seconds(), 1))
    return out


async def main():
    print("broker, exponent:      ", await delays(use_delay_exponent=True))
    print("schedule_source, exp.: ", await delays(Source(), use_delay_exponent=True))
    random.seed(0)
    print("broker, jitter:        ", await delays(use_jitter=True))
    random.seed(0)
    print("schedule_source, jitter:", await delays(Source(), use_jitter=True))
    print("broker, defaults:      ", await delays())


asyncio.run(main())

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions