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.
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.
The doubling curve
delayed-queue.md.txt · Retry with BackoffExponential 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.
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.
min(base × 2^(attempts-1), cap).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.
The formula owner is host-side
echo_mq/backoff.ex · delay_ms/2In 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 curve, pinned
The function carries its examples in the moduledoc; the suite runs them as tests, so the curve cannot drift:
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.
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.
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.
Recap — double, then clamp
next → R4.04.2Exponential 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.
References
sources · relatedSources
- Redis — ZADD — re-add the job to the schedule set at the backoff fire-time.
- Valkey — Data types — the sorted set the schedule set is.
- Redis — Documentation — sorted sets and scoring in context.
- Sanfilippo, S. — antirez weblog — the Redis creator on treating delays and retries as scores on a sorted set.
Related in this course
- R4.04 · Backoff & retrythe module hub
- R4.04.2 · Jitterthe next dive
- R4.01 · Delayed queuethe schedule-set machinery the delay rides
- /echomq · the Queuethe retry path on the wire
- Functional Programming in Elixirthe pure-function craft behind the backoff vocabulary