jonnify·redis /redis-patterns/time-delay-priority/backoff-retry/exponential-backoff

R4.04.1 · Backoff & retry — dive 1

The doubling curve.

delay = base × 2^(attempts-1), clamped at a cap. Attempt 1 waits base, attempt 2 waits 2 × base, attempt 3 waits 4 × base. The owner of the curve is a host-side pure function, not the wire.

Grounding: EchoMQ.Backoff.delay_ms/2 (echo/apps/echo_mq/lib/echo_mq/backoff.ex, the :exponential clause) · EchoMQ.Jobs.retry/7 carries the literal delay to the wire.

Exponential vs linear. Hover or select an attempt to read the doubling delay; the dashed line shows a linear schedule for contrast.
a1 a2 a3 a4 a5 delay
readout · Select an attempt. Exponential doubles the wait each step; the dashed linear line grows by a fixed amount and falls behind fast.

Static verdicts (base 100 ms) — exponential: 100, 200, 400, 800, 1600 · linear (step 100): 100, 200, 300, 400, 500. By attempt 5 the exponential is three times the linear.

§1

The doubling curve

delayed-queue.md.txt · Retry with Backoff

Exponential backoff multiplies the wait by two on each failed attempt. With a base of 100 ms the schedule is 100, 200, 400, 800, 1600 — the gap between retries grows as fast as the failures persist. A dependency that recovers quickly costs one short delay; a dependency that stays down is hit less and less often, which is what a struggling service needs.

The exponent is the attempt that has failed, minus one. attempts = 1 (the first failure) gives 2⁰ = 1, so the first retry waits exactly base. This anchors the curve: the first retry is fast, and each subsequent one doubles.

base
The first delay; attempt 1 waits exactly this.
attempts
The attempt that has failed; the exponent is attempts - 1.
2^(n-1)
The doubling factor: 1, 2, 4, 8, 16 for attempts 1 through 5.
cap
The ceiling the delay is clamped to, so the curve cannot climb forever.

An exponential ramp gives a recovering dependency a fast retry and a failing one ever-longer breathing room — from one formula.

§2

The clamp

EchoMQ.Backoff.delay_ms/2 · min(raw, cap)

An unbounded curve is a hazard. After 20 attempts 2¹⁹ × base is enormous — a job that would not re-fire for years. Exponential backoff in practice carries a cap: the delay is min(base × 2^(attempts-1), cap). The curve climbs, then holds flat at the ceiling. A clamp turns an exponential into a bounded ramp that protects the dependency without parking the job forever.

Choose a base, a cap, and an attempt below. The readout computes the raw doubling value, then the clamped delay — the literal EchoMQ.Backoff.delay_ms/2 returns.

Compute the clamped delay. Set base, cap, and attempt; the readout runs min(base × 2^(attempts-1), cap).
100
10000
3

Static verdicts — {:exponential, 100, 10_000}, 3 = 400 (no clamp) · {:exponential, 100, 10_000}, 20 = 10000 (clamped from 52428800).

The cap is the difference between an exponential and a runaway: a bounded ramp that flattens at the ceiling instead of scheduling a retry past the heat death of the queue.

§3

The formula owner is host-side

echo_mq/backoff.ex · delay_ms/2

In the real bus the curve lives above the wire, in EchoMQ.Backoff.delay_ms/2 — a pure function from a policy and an attempt count to a literal delay_ms. No process, no clock, no I/O; a consumer can compute, table, and test its retry cadence without a server. The wire takes a literal delay and never computes a curve. The exponential clause is one min:

# EchoMQ.Backoff.delay_ms/2 — the :exponential clause (verbatim)
def delay_ms({:exponential, base, cap}, attempts) do
  raw = base * Bitwise.bsl(1, attempts - 1)   # base × 2^(attempts-1)
  min(raw, cap)                                # clamped at the ceiling
end

Its own doctests pin the curve. The committed examples are the source of truth, not a benchmark:

the formula · the committed doctests, verbatim

The curve, pinned

The function carries its examples in the moduledoc; the suite runs them as tests, so the curve cannot drift:

echo_mq/backoff.ex · the delay_ms/2 doctestsfrozen
delay_ms({:exponential, 100, 10_000}, 1)   → 100    ← base × 2⁰
delay_ms({:exponential, 100, 10_000}, 3)   → 400    ← base × 2²
delay_ms({:exponential, 100, 10_000}, 20)  → 10000  ← clamped at the cap

EchoMQ.Jobs.retry/7 takes that literal delay_ms as its fifth argument and ZADDs the job onto the schedule set at now + delay_ms on the server clock. The wire receives one number — the delay — and never the formula that produced it. That separation is the design: the backoff math is a value a consumer owns and tests; the reschedule is the wire's only job. The functional-Elixir craft behind the pure backoff vocabulary is taught by /elixir.

Notes on ValkeyThe literal delay crosses to the wire as the score on one ZADD — the schedule set is a sorted set keyed by fire-time, and the score is all the backoff information the wire carries — valkey.io/topics/data-types.

the pattern

Exponential backoff waits base × 2^(attempts-1), clamped at a cap, so each retry waits twice as long and a stubborn job ramps to a ceiling rather than re-firing forever.

its EchoMQ application

EchoMQ.Backoff.delay_ms({:exponential, base, cap}, attempts) computes the literal delay host-side; EchoMQ.Jobs.retry/7 carries it to the wire and schedules the job at now + delay_ms.

The curve is a value, not a wire feature. It is owned, tabled, and tested above the connector; the wire schedules a job at one literal time.

§4

Recap — double, then clamp

next → R4.04.2

Exponential backoff doubles the wait each attempt — base × 2^(attempts-1) — and clamps it at a cap so the curve flattens instead of running away. The formula is a host-side pure function, EchoMQ.Backoff.delay_ms/2; the wire takes the literal delay and schedules the job. The next dive adds jitter: when a cohort fails together, a fixed curve schedules them all for the same instant, and the herd re-fires together.

§5

References

sources · related

Sources

Related in this course