acemq_amqp.retry

When to try again, where to wait, and when to stop.

The schedule arithmetic here is part of the cross-language contract rather than a local choice: the same policy must produce the same delays in Java, Go, .NET and Python, because the same message can be retried by a consumer written in any of them. The jitter is the one exception — it is random by definition — so RetryPolicy.schedule() exposes the delays without it, which is what to read when deciding whether a policy is the one you meant.

Where the waiting happens is the other half. A short wait is spent in the consumer, holding the delivery; a long one is spent in the broker, in a queue whose message TTL is the wait. The threshold between them is DEFAULT_BROKER_WAIT_THRESHOLD unless a policy says otherwise, and the reason for having a threshold at all rather than one rule is in RetryPolicy.

  1# Copyright 2026 AceMQ.
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License");
  4# you may not use this file except in compliance with the License.
  5# You may obtain a copy of the License at
  6#
  7#     https://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS,
 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12# See the License for the specific language governing permissions and
 13# limitations under the License.
 14
 15"""When to try again, where to wait, and when to stop.
 16
 17The schedule arithmetic here is part of the cross-language contract rather than
 18a local choice: the same policy must produce the same delays in Java, Go, .NET
 19and Python, because the same message can be retried by a consumer written in
 20any of them. The jitter is the one exception — it is random by definition —
 21so :meth:`RetryPolicy.schedule` exposes the delays *without* it, which is what
 22to read when deciding whether a policy is the one you meant.
 23
 24Where the waiting happens is the other half. A short wait is spent in the
 25consumer, holding the delivery; a long one is spent in the broker, in a queue
 26whose message TTL is the wait. The threshold between them is
 27:data:`DEFAULT_BROKER_WAIT_THRESHOLD` unless a policy says otherwise, and the
 28reason for having a threshold at all rather than one rule is in
 29:class:`RetryPolicy`.
 30"""
 31
 32from __future__ import annotations
 33
 34import random
 35from dataclasses import dataclass, replace
 36from datetime import timedelta
 37
 38ZERO = timedelta(0)
 39
 40#: Waits this long or longer are spent in the broker rather than in the
 41#: consumer. Thirty seconds is roughly where the two costs cross: below it, the
 42#: seconds a restart loses are only seconds and a held prefetch slot is cheap;
 43#: above it, a consumer that restarts mid-wait loses the wait entirely, because
 44#: the broker redelivers the unacknowledged message at once and a five-minute
 45#: backoff becomes instant.
 46DEFAULT_BROKER_WAIT_THRESHOLD = timedelta(seconds=30)
 47
 48
 49@dataclass(frozen=True, slots=True)
 50class Wait:
 51    """How long before the next attempt, and where the message spends it.
 52
 53    Two answers rather than one because they cannot be worked out separately:
 54    jitter applies only to a wait spent in the consumer, so a caller given a
 55    delay alone could not tell whether it had already been moved — and a
 56    jittered delay does not name a rung queue.
 57
 58    :param delay: how long the message waits
 59    :param in_broker: whether it waits on a rung queue rather than here
 60    """
 61
 62    delay: timedelta
 63    in_broker: bool
 64
 65
 66@dataclass(frozen=True, slots=True)
 67class RetryPolicy:
 68    """How many times to try, how long to wait, and where.
 69
 70    Waits below ``broker_wait_threshold`` are spent in the consumer, which holds
 71    the delivery and one prefetch slot for the duration. Waits at or above it
 72    are spent in a ``{queue}.retry.{delay}`` queue whose ``x-message-ttl`` is the
 73    wait and whose dead-letter target is the source queue, so the broker returns
 74    the message when the time is up and a consumer restart cannot shorten it.
 75
 76    Splitting at a threshold rather than picking one of the two takes the
 77    durability where it is worth its complexity and leaves the simplicity where
 78    it is not. A queue's TTL is fixed at declaration and so cannot express a
 79    jittered delay, which is why jitter applies below the threshold only; above
 80    it the spread comes free, because each message's TTL starts when it enters
 81    the rung, so a fleet that failed over ten seconds is released over ten
 82    seconds.
 83
 84    :param max_attempts: total deliveries including the first. 1 means no retry
 85    :param initial_delay: the wait before the second attempt
 86    :param multiplier: what the delay is multiplied by each time
 87    :param max_delay: the ceiling, or zero for none
 88    :param jitter_factor: how far a delay may move either side, 0 to 1
 89    :param max_message_age: give up on anything older, or zero for never
 90    :param broker_wait_threshold: waits this long or longer are spent in the
 91        broker, or zero to spend every wait in the consumer
 92    """
 93
 94    max_attempts: int = 1
 95    initial_delay: timedelta = ZERO
 96    multiplier: float = 2.0
 97    max_delay: timedelta = ZERO
 98    jitter_factor: float = 0.0
 99    max_message_age: timedelta = ZERO
100    broker_wait_threshold: timedelta = DEFAULT_BROKER_WAIT_THRESHOLD
101
102    def give_up_after(self, age: timedelta) -> RetryPolicy:
103        """A copy that abandons a message older than ``age``.
104
105        The honest limit when a queue has been paused: attempts say nothing
106        about how long a message has been waiting, and a message four days old
107        is usually one nobody wants delivered now.
108        """
109        return replace(self, max_message_age=age)
110
111    def with_jitter(self, factor: float) -> RetryPolicy:
112        """A copy using a different jitter factor, between 0 and 1."""
113        return replace(self, jitter_factor=factor)
114
115    def wait_in_broker_from(self, threshold: timedelta) -> RetryPolicy:
116        """A copy that moves the line between waiting here and waiting there.
117
118        Zero is the way out: with no threshold nothing is long enough to reach
119        the broker, so every wait is spent in the consumer and no rung queue is
120        needed. That is the right setting for a service whose broker it may not
121        declare queues on, and the wrong one for a policy with delays measured
122        in minutes.
123
124        :param threshold: waits this long or longer go to a rung queue, or zero
125            for none of them
126        :returns: a new policy
127        """
128        return replace(self, broker_wait_threshold=threshold)
129
130    def waits_in_broker(self, delay: timedelta) -> bool:
131        """Whether a wait of this length belongs on a rung queue.
132
133        :param delay: an unjittered delay, as :meth:`schedule` reports them
134        :returns: whether the broker rather than the consumer should hold it
135        """
136        return (
137            self.broker_wait_threshold > ZERO
138            and delay > ZERO
139            and delay >= self.broker_wait_threshold
140        )
141
142    def broker_rungs(self) -> list[timedelta]:
143        """The delays this policy needs a rung queue for, longest last.
144
145        Exactly the entries of :meth:`schedule` that are at or above the
146        threshold, with repeats removed — a fixed policy that waits a minute
147        three times needs one queue, not three. It is a finite list because the
148        schedule is, which is what makes the queues declarable up front rather
149        than conjured by a consumer at the moment it first fails.
150
151        :returns: one delay per queue, in the order the schedule reaches them
152        """
153        return list(dict.fromkeys(d for d in self.schedule() if self.waits_in_broker(d)))
154
155    def next_delay(self, attempt: int, message_age: timedelta = ZERO) -> timedelta | None:
156        """How long to wait before the next attempt, or ``None`` to give up.
157
158        :param attempt: the attempt that has just failed, starting at 1
159        :param message_age: how old the message is
160        :returns: the delay, or ``None`` when there should be no further attempt
161        """
162        wait = self.next_wait(attempt, message_age)
163        return None if wait is None else wait.delay
164
165    def next_wait(self, attempt: int, message_age: timedelta = ZERO) -> Wait | None:
166        """The whole answer: how long to wait, and where.
167
168        :param attempt: the attempt that has just failed, starting at 1
169        :param message_age: how old the message is
170        :returns: the wait, or ``None`` when there should be no further attempt
171        """
172        if attempt >= self.max_attempts:
173            return None
174        if self.max_message_age > ZERO and message_age >= self.max_message_age:
175            return None
176
177        delay = self._unjittered(attempt)
178
179        if self.waits_in_broker(delay):
180            # Deliberately not jittered. A rung queue's TTL is fixed when it is
181            # declared, so a moved delay would name a queue that does not exist;
182            # and the spread jitter buys is already there, because each message's
183            # TTL starts when it arrives rather than when the batch failed.
184            return Wait(delay, in_broker=True)
185
186        if self.jitter_factor > 0 and delay > ZERO:
187            # Both directions, so a fleet of consumers that failed together
188            # does not come back together. One-sided jitter only ever delays,
189            # which turns a thundering herd into a slower thundering herd.
190            factor = 1 + ((random.random() * 2 - 1) * self.jitter_factor)
191            delay = delay * factor
192
193        return Wait(max(delay, ZERO), in_broker=False)
194
195    def _unjittered(self, attempt: int) -> timedelta:
196        """The delay after ``attempt``, before jitter and before the threshold."""
197        delay = self.initial_delay
198        for _ in range(1, attempt):
199            delay = delay * self.multiplier
200            if self.max_delay > ZERO and delay > self.max_delay:
201                delay = self.max_delay
202                break
203        if self.max_delay > ZERO and delay > self.max_delay:
204            delay = self.max_delay
205        return delay
206
207    def schedule(self) -> list[timedelta]:
208        """The delays this policy would use, without jitter.
209
210        What to look at when deciding whether a policy is the one you meant:
211        ``ExponentialRetry(5, 1s, 1m).schedule()`` is ``[1s, 2s, 4s, 8s]``, and
212        four numbers are easier to argue with than three parameters.
213        """
214        delays: list[timedelta] = []
215        delay = self.initial_delay
216        for _ in range(1, max(self.max_attempts, 0)):
217            capped = delay
218            if self.max_delay > ZERO and capped > self.max_delay:
219                capped = self.max_delay
220            delays.append(capped)
221            delay = delay * self.multiplier
222        return delays
223
224
225def no_retry() -> RetryPolicy:
226    """One delivery, no second chance."""
227    return RetryPolicy(max_attempts=1)
228
229
230def exponential_retry(
231    max_attempts: int,
232    initial_delay: timedelta,
233    max_delay: timedelta = ZERO,
234) -> RetryPolicy:
235    """Doubling delays with 20% jitter, which is the sane default.
236
237    :param max_attempts: total deliveries including the first
238    :param initial_delay: the wait before the second attempt
239    :param max_delay: the ceiling, or zero for none
240    """
241    return RetryPolicy(
242        max_attempts=max_attempts,
243        initial_delay=initial_delay,
244        multiplier=2.0,
245        max_delay=max_delay,
246        jitter_factor=0.2,
247    )
248
249
250def fixed_retry(max_attempts: int, delay: timedelta) -> RetryPolicy:
251    """The same wait every time, with no jitter."""
252    return RetryPolicy(max_attempts=max_attempts, initial_delay=delay, multiplier=1.0)
ZERO = datetime.timedelta(0)
DEFAULT_BROKER_WAIT_THRESHOLD = datetime.timedelta(seconds=30)
@dataclass(frozen=True, slots=True)
class Wait:
50@dataclass(frozen=True, slots=True)
51class Wait:
52    """How long before the next attempt, and where the message spends it.
53
54    Two answers rather than one because they cannot be worked out separately:
55    jitter applies only to a wait spent in the consumer, so a caller given a
56    delay alone could not tell whether it had already been moved — and a
57    jittered delay does not name a rung queue.
58
59    :param delay: how long the message waits
60    :param in_broker: whether it waits on a rung queue rather than here
61    """
62
63    delay: timedelta
64    in_broker: bool

How long before the next attempt, and where the message spends it.

Two answers rather than one because they cannot be worked out separately: jitter applies only to a wait spent in the consumer, so a caller given a delay alone could not tell whether it had already been moved — and a jittered delay does not name a rung queue.

Parameters
  • delay: how long the message waits
  • in_broker: whether it waits on a rung queue rather than here
Wait(delay: datetime.timedelta, in_broker: bool)
delay: datetime.timedelta
in_broker: bool
@dataclass(frozen=True, slots=True)
class RetryPolicy:
 67@dataclass(frozen=True, slots=True)
 68class RetryPolicy:
 69    """How many times to try, how long to wait, and where.
 70
 71    Waits below ``broker_wait_threshold`` are spent in the consumer, which holds
 72    the delivery and one prefetch slot for the duration. Waits at or above it
 73    are spent in a ``{queue}.retry.{delay}`` queue whose ``x-message-ttl`` is the
 74    wait and whose dead-letter target is the source queue, so the broker returns
 75    the message when the time is up and a consumer restart cannot shorten it.
 76
 77    Splitting at a threshold rather than picking one of the two takes the
 78    durability where it is worth its complexity and leaves the simplicity where
 79    it is not. A queue's TTL is fixed at declaration and so cannot express a
 80    jittered delay, which is why jitter applies below the threshold only; above
 81    it the spread comes free, because each message's TTL starts when it enters
 82    the rung, so a fleet that failed over ten seconds is released over ten
 83    seconds.
 84
 85    :param max_attempts: total deliveries including the first. 1 means no retry
 86    :param initial_delay: the wait before the second attempt
 87    :param multiplier: what the delay is multiplied by each time
 88    :param max_delay: the ceiling, or zero for none
 89    :param jitter_factor: how far a delay may move either side, 0 to 1
 90    :param max_message_age: give up on anything older, or zero for never
 91    :param broker_wait_threshold: waits this long or longer are spent in the
 92        broker, or zero to spend every wait in the consumer
 93    """
 94
 95    max_attempts: int = 1
 96    initial_delay: timedelta = ZERO
 97    multiplier: float = 2.0
 98    max_delay: timedelta = ZERO
 99    jitter_factor: float = 0.0
100    max_message_age: timedelta = ZERO
101    broker_wait_threshold: timedelta = DEFAULT_BROKER_WAIT_THRESHOLD
102
103    def give_up_after(self, age: timedelta) -> RetryPolicy:
104        """A copy that abandons a message older than ``age``.
105
106        The honest limit when a queue has been paused: attempts say nothing
107        about how long a message has been waiting, and a message four days old
108        is usually one nobody wants delivered now.
109        """
110        return replace(self, max_message_age=age)
111
112    def with_jitter(self, factor: float) -> RetryPolicy:
113        """A copy using a different jitter factor, between 0 and 1."""
114        return replace(self, jitter_factor=factor)
115
116    def wait_in_broker_from(self, threshold: timedelta) -> RetryPolicy:
117        """A copy that moves the line between waiting here and waiting there.
118
119        Zero is the way out: with no threshold nothing is long enough to reach
120        the broker, so every wait is spent in the consumer and no rung queue is
121        needed. That is the right setting for a service whose broker it may not
122        declare queues on, and the wrong one for a policy with delays measured
123        in minutes.
124
125        :param threshold: waits this long or longer go to a rung queue, or zero
126            for none of them
127        :returns: a new policy
128        """
129        return replace(self, broker_wait_threshold=threshold)
130
131    def waits_in_broker(self, delay: timedelta) -> bool:
132        """Whether a wait of this length belongs on a rung queue.
133
134        :param delay: an unjittered delay, as :meth:`schedule` reports them
135        :returns: whether the broker rather than the consumer should hold it
136        """
137        return (
138            self.broker_wait_threshold > ZERO
139            and delay > ZERO
140            and delay >= self.broker_wait_threshold
141        )
142
143    def broker_rungs(self) -> list[timedelta]:
144        """The delays this policy needs a rung queue for, longest last.
145
146        Exactly the entries of :meth:`schedule` that are at or above the
147        threshold, with repeats removed — a fixed policy that waits a minute
148        three times needs one queue, not three. It is a finite list because the
149        schedule is, which is what makes the queues declarable up front rather
150        than conjured by a consumer at the moment it first fails.
151
152        :returns: one delay per queue, in the order the schedule reaches them
153        """
154        return list(dict.fromkeys(d for d in self.schedule() if self.waits_in_broker(d)))
155
156    def next_delay(self, attempt: int, message_age: timedelta = ZERO) -> timedelta | None:
157        """How long to wait before the next attempt, or ``None`` to give up.
158
159        :param attempt: the attempt that has just failed, starting at 1
160        :param message_age: how old the message is
161        :returns: the delay, or ``None`` when there should be no further attempt
162        """
163        wait = self.next_wait(attempt, message_age)
164        return None if wait is None else wait.delay
165
166    def next_wait(self, attempt: int, message_age: timedelta = ZERO) -> Wait | None:
167        """The whole answer: how long to wait, and where.
168
169        :param attempt: the attempt that has just failed, starting at 1
170        :param message_age: how old the message is
171        :returns: the wait, or ``None`` when there should be no further attempt
172        """
173        if attempt >= self.max_attempts:
174            return None
175        if self.max_message_age > ZERO and message_age >= self.max_message_age:
176            return None
177
178        delay = self._unjittered(attempt)
179
180        if self.waits_in_broker(delay):
181            # Deliberately not jittered. A rung queue's TTL is fixed when it is
182            # declared, so a moved delay would name a queue that does not exist;
183            # and the spread jitter buys is already there, because each message's
184            # TTL starts when it arrives rather than when the batch failed.
185            return Wait(delay, in_broker=True)
186
187        if self.jitter_factor > 0 and delay > ZERO:
188            # Both directions, so a fleet of consumers that failed together
189            # does not come back together. One-sided jitter only ever delays,
190            # which turns a thundering herd into a slower thundering herd.
191            factor = 1 + ((random.random() * 2 - 1) * self.jitter_factor)
192            delay = delay * factor
193
194        return Wait(max(delay, ZERO), in_broker=False)
195
196    def _unjittered(self, attempt: int) -> timedelta:
197        """The delay after ``attempt``, before jitter and before the threshold."""
198        delay = self.initial_delay
199        for _ in range(1, attempt):
200            delay = delay * self.multiplier
201            if self.max_delay > ZERO and delay > self.max_delay:
202                delay = self.max_delay
203                break
204        if self.max_delay > ZERO and delay > self.max_delay:
205            delay = self.max_delay
206        return delay
207
208    def schedule(self) -> list[timedelta]:
209        """The delays this policy would use, without jitter.
210
211        What to look at when deciding whether a policy is the one you meant:
212        ``ExponentialRetry(5, 1s, 1m).schedule()`` is ``[1s, 2s, 4s, 8s]``, and
213        four numbers are easier to argue with than three parameters.
214        """
215        delays: list[timedelta] = []
216        delay = self.initial_delay
217        for _ in range(1, max(self.max_attempts, 0)):
218            capped = delay
219            if self.max_delay > ZERO and capped > self.max_delay:
220                capped = self.max_delay
221            delays.append(capped)
222            delay = delay * self.multiplier
223        return delays

How many times to try, how long to wait, and where.

Waits below broker_wait_threshold are spent in the consumer, which holds the delivery and one prefetch slot for the duration. Waits at or above it are spent in a {queue}acemq_amqp.retry.{delay} queue whose x-message-ttl is the wait and whose dead-letter target is the source queue, so the broker returns the message when the time is up and a consumer restart cannot shorten it.

Splitting at a threshold rather than picking one of the two takes the durability where it is worth its complexity and leaves the simplicity where it is not. A queue's TTL is fixed at declaration and so cannot express a jittered delay, which is why jitter applies below the threshold only; above it the spread comes free, because each message's TTL starts when it enters the rung, so a fleet that failed over ten seconds is released over ten seconds.

Parameters
  • max_attempts: total deliveries including the first. 1 means no retry
  • initial_delay: the wait before the second attempt
  • multiplier: what the delay is multiplied by each time
  • max_delay: the ceiling, or zero for none
  • jitter_factor: how far a delay may move either side, 0 to 1
  • max_message_age: give up on anything older, or zero for never
  • broker_wait_threshold: waits this long or longer are spent in the broker, or zero to spend every wait in the consumer
RetryPolicy( max_attempts: int = 1, initial_delay: datetime.timedelta = datetime.timedelta(0), multiplier: float = 2.0, max_delay: datetime.timedelta = datetime.timedelta(0), jitter_factor: float = 0.0, max_message_age: datetime.timedelta = datetime.timedelta(0), broker_wait_threshold: datetime.timedelta = datetime.timedelta(seconds=30))
max_attempts: int
initial_delay: datetime.timedelta
multiplier: float
max_delay: datetime.timedelta
jitter_factor: float
max_message_age: datetime.timedelta
broker_wait_threshold: datetime.timedelta
def give_up_after(self, age: datetime.timedelta) -> RetryPolicy:
103    def give_up_after(self, age: timedelta) -> RetryPolicy:
104        """A copy that abandons a message older than ``age``.
105
106        The honest limit when a queue has been paused: attempts say nothing
107        about how long a message has been waiting, and a message four days old
108        is usually one nobody wants delivered now.
109        """
110        return replace(self, max_message_age=age)

A copy that abandons a message older than age.

The honest limit when a queue has been paused: attempts say nothing about how long a message has been waiting, and a message four days old is usually one nobody wants delivered now.

def with_jitter(self, factor: float) -> RetryPolicy:
112    def with_jitter(self, factor: float) -> RetryPolicy:
113        """A copy using a different jitter factor, between 0 and 1."""
114        return replace(self, jitter_factor=factor)

A copy using a different jitter factor, between 0 and 1.

def wait_in_broker_from(self, threshold: datetime.timedelta) -> RetryPolicy:
116    def wait_in_broker_from(self, threshold: timedelta) -> RetryPolicy:
117        """A copy that moves the line between waiting here and waiting there.
118
119        Zero is the way out: with no threshold nothing is long enough to reach
120        the broker, so every wait is spent in the consumer and no rung queue is
121        needed. That is the right setting for a service whose broker it may not
122        declare queues on, and the wrong one for a policy with delays measured
123        in minutes.
124
125        :param threshold: waits this long or longer go to a rung queue, or zero
126            for none of them
127        :returns: a new policy
128        """
129        return replace(self, broker_wait_threshold=threshold)

A copy that moves the line between waiting here and waiting there.

Zero is the way out: with no threshold nothing is long enough to reach the broker, so every wait is spent in the consumer and no rung queue is needed. That is the right setting for a service whose broker it may not declare queues on, and the wrong one for a policy with delays measured in minutes.

Parameters
  • threshold: waits this long or longer go to a rung queue, or zero for none of them :returns: a new policy
def waits_in_broker(self, delay: datetime.timedelta) -> bool:
131    def waits_in_broker(self, delay: timedelta) -> bool:
132        """Whether a wait of this length belongs on a rung queue.
133
134        :param delay: an unjittered delay, as :meth:`schedule` reports them
135        :returns: whether the broker rather than the consumer should hold it
136        """
137        return (
138            self.broker_wait_threshold > ZERO
139            and delay > ZERO
140            and delay >= self.broker_wait_threshold
141        )

Whether a wait of this length belongs on a rung queue.

Parameters
  • delay: an unjittered delay, as schedule() reports them :returns: whether the broker rather than the consumer should hold it
def broker_rungs(self) -> list[datetime.timedelta]:
143    def broker_rungs(self) -> list[timedelta]:
144        """The delays this policy needs a rung queue for, longest last.
145
146        Exactly the entries of :meth:`schedule` that are at or above the
147        threshold, with repeats removed — a fixed policy that waits a minute
148        three times needs one queue, not three. It is a finite list because the
149        schedule is, which is what makes the queues declarable up front rather
150        than conjured by a consumer at the moment it first fails.
151
152        :returns: one delay per queue, in the order the schedule reaches them
153        """
154        return list(dict.fromkeys(d for d in self.schedule() if self.waits_in_broker(d)))

The delays this policy needs a rung queue for, longest last.

Exactly the entries of schedule() that are at or above the threshold, with repeats removed — a fixed policy that waits a minute three times needs one queue, not three. It is a finite list because the schedule is, which is what makes the queues declarable up front rather than conjured by a consumer at the moment it first fails.

:returns: one delay per queue, in the order the schedule reaches them

def next_delay( self, attempt: int, message_age: datetime.timedelta = datetime.timedelta(0)) -> datetime.timedelta | None:
156    def next_delay(self, attempt: int, message_age: timedelta = ZERO) -> timedelta | None:
157        """How long to wait before the next attempt, or ``None`` to give up.
158
159        :param attempt: the attempt that has just failed, starting at 1
160        :param message_age: how old the message is
161        :returns: the delay, or ``None`` when there should be no further attempt
162        """
163        wait = self.next_wait(attempt, message_age)
164        return None if wait is None else wait.delay

How long to wait before the next attempt, or None to give up.

Parameters
  • attempt: the attempt that has just failed, starting at 1
  • message_age: how old the message is :returns: the delay, or None when there should be no further attempt
def next_wait( self, attempt: int, message_age: datetime.timedelta = datetime.timedelta(0)) -> Wait | None:
166    def next_wait(self, attempt: int, message_age: timedelta = ZERO) -> Wait | None:
167        """The whole answer: how long to wait, and where.
168
169        :param attempt: the attempt that has just failed, starting at 1
170        :param message_age: how old the message is
171        :returns: the wait, or ``None`` when there should be no further attempt
172        """
173        if attempt >= self.max_attempts:
174            return None
175        if self.max_message_age > ZERO and message_age >= self.max_message_age:
176            return None
177
178        delay = self._unjittered(attempt)
179
180        if self.waits_in_broker(delay):
181            # Deliberately not jittered. A rung queue's TTL is fixed when it is
182            # declared, so a moved delay would name a queue that does not exist;
183            # and the spread jitter buys is already there, because each message's
184            # TTL starts when it arrives rather than when the batch failed.
185            return Wait(delay, in_broker=True)
186
187        if self.jitter_factor > 0 and delay > ZERO:
188            # Both directions, so a fleet of consumers that failed together
189            # does not come back together. One-sided jitter only ever delays,
190            # which turns a thundering herd into a slower thundering herd.
191            factor = 1 + ((random.random() * 2 - 1) * self.jitter_factor)
192            delay = delay * factor
193
194        return Wait(max(delay, ZERO), in_broker=False)

The whole answer: how long to wait, and where.

Parameters
  • attempt: the attempt that has just failed, starting at 1
  • message_age: how old the message is :returns: the wait, or None when there should be no further attempt
def schedule(self) -> list[datetime.timedelta]:
208    def schedule(self) -> list[timedelta]:
209        """The delays this policy would use, without jitter.
210
211        What to look at when deciding whether a policy is the one you meant:
212        ``ExponentialRetry(5, 1s, 1m).schedule()`` is ``[1s, 2s, 4s, 8s]``, and
213        four numbers are easier to argue with than three parameters.
214        """
215        delays: list[timedelta] = []
216        delay = self.initial_delay
217        for _ in range(1, max(self.max_attempts, 0)):
218            capped = delay
219            if self.max_delay > ZERO and capped > self.max_delay:
220                capped = self.max_delay
221            delays.append(capped)
222            delay = delay * self.multiplier
223        return delays

The delays this policy would use, without jitter.

What to look at when deciding whether a policy is the one you meant: ExponentialRetry(5, 1s, 1m).schedule() is [1s, 2s, 4s, 8s], and four numbers are easier to argue with than three parameters.

def no_retry() -> RetryPolicy:
226def no_retry() -> RetryPolicy:
227    """One delivery, no second chance."""
228    return RetryPolicy(max_attempts=1)

One delivery, no second chance.

def exponential_retry( max_attempts: int, initial_delay: datetime.timedelta, max_delay: datetime.timedelta = datetime.timedelta(0)) -> RetryPolicy:
231def exponential_retry(
232    max_attempts: int,
233    initial_delay: timedelta,
234    max_delay: timedelta = ZERO,
235) -> RetryPolicy:
236    """Doubling delays with 20% jitter, which is the sane default.
237
238    :param max_attempts: total deliveries including the first
239    :param initial_delay: the wait before the second attempt
240    :param max_delay: the ceiling, or zero for none
241    """
242    return RetryPolicy(
243        max_attempts=max_attempts,
244        initial_delay=initial_delay,
245        multiplier=2.0,
246        max_delay=max_delay,
247        jitter_factor=0.2,
248    )

Doubling delays with 20% jitter, which is the sane default.

Parameters
  • max_attempts: total deliveries including the first
  • initial_delay: the wait before the second attempt
  • max_delay: the ceiling, or zero for none
def fixed_retry( max_attempts: int, delay: datetime.timedelta) -> RetryPolicy:
251def fixed_retry(max_attempts: int, delay: timedelta) -> RetryPolicy:
252    """The same wait every time, with no jitter."""
253    return RetryPolicy(max_attempts=max_attempts, initial_delay=delay, multiplier=1.0)

The same wait every time, with no jitter.