C1 — Backoff policies as pure functions.
Every policy returns a plain fn of attempt → delay in milliseconds, where
attempt is the 1-based failure count ((retry-fn 1) is the delay after
the first failure). This matches the contract d-core.libs.retriable
already documents.
Delays are always long milliseconds. This is the settled decision for
this pillar: it matches d-core.libs.retriable (Thread/sleep), and it maps
onto both existing ecosystem implementations with no conversion, which is what
makes their migration provably behaviour-preserving (see
backoff-test/matches-async-proxy-compute-backoff-ms and
backoff-test/matches-http-calc-backoff-ms). java.time.Duration remains the
vocabulary of Pillar A at the boundary; this policy layer stays in ms.
:jitter accepts either a keyword or a number:
| Value | Distribution |
|---|---|
:none | no jitter — fully deterministic |
:full | uniform [0, d) — AWS "full jitter" |
:equal | uniform [d/2, d) — AWS "equal jitter" |
<number> k | uniform [d-(k·d), d+(k·d)] — symmetric/proportional |
The numeric form exists because it is exactly what
d-core-http/.../client.clj already does with :jitter 0.2, so that call
site can migrate without changing its observed delay distribution.
:rand-fn is an injection point (a 0-arg fn returning [0.0, 1.0), default
rand). It is mandatory whenever jitter is active — a jittered policy
without it would be untestable, and silently defaulting would make the
dependency invisible at the call site.
:max-ms caps the delay before jitter is applied. Consequently:
:none, :full or :equal, the observed delay never exceeds the cap
(:full is in fact strictly below it);k, the observed delay is bounded by
max-ms · (1 + k), i.e. the cap bounds the jitter centre, not the
absolute ceiling. This is deliberate: it is the behaviour of the existing
d-core-http implementation, and changing it would change the retry
envelope of a live client.exponential-backoff, linear-backoff and constant-backoff are pure: the
returned fn depends only on its attempt argument.
decorrelated-jitter is inherently stateful (its next delay depends on the
previous draw, not on attempt), so it is deliberately not an
attempt -> ms fn. It is exposed as a pure state transducer — see its
docstring — rather than bending the common contract and hiding an atom.
C1 — Backoff policies as pure functions. Every policy returns a plain fn of `attempt` → delay in milliseconds, where `attempt` is the **1-based failure count** (`(retry-fn 1)` is the delay after the first failure). This matches the contract `d-core.libs.retriable` already documents. ## Units Delays are **always `long` milliseconds**. This is the settled decision for this pillar: it matches `d-core.libs.retriable` (`Thread/sleep`), and it maps onto both existing ecosystem implementations with no conversion, which is what makes their migration provably behaviour-preserving (see `backoff-test/matches-async-proxy-compute-backoff-ms` and `backoff-test/matches-http-calc-backoff-ms`). `java.time.Duration` remains the vocabulary of Pillar A at the *boundary*; this policy layer stays in ms. ## Jitter `:jitter` accepts either a keyword or a number: | Value | Distribution | | :----------- | :-------------------------------------------------- | | `:none` | no jitter — fully deterministic | | `:full` | uniform `[0, d)` — AWS "full jitter" | | `:equal` | uniform `[d/2, d)` — AWS "equal jitter" | | `<number> k` | uniform `[d-(k·d), d+(k·d)]` — symmetric/proportional | The numeric form exists because it is exactly what `d-core-http/.../client.clj` already does with `:jitter 0.2`, so that call site can migrate without changing its observed delay distribution. `:rand-fn` is an injection point (a 0-arg fn returning `[0.0, 1.0)`, default `rand`). It is **mandatory whenever jitter is active** — a jittered policy without it would be untestable, and silently defaulting would make the dependency invisible at the call site. ## Cap semantics `:max-ms` caps the delay *before* jitter is applied. Consequently: - with `:none`, `:full` or `:equal`, the observed delay never exceeds the cap (`:full` is in fact strictly below it); - with numeric jitter `k`, the observed delay is bounded by `max-ms · (1 + k)`, i.e. the cap bounds the jitter *centre*, not the absolute ceiling. This is deliberate: it is the behaviour of the existing `d-core-http` implementation, and changing it would change the retry envelope of a live client. ## Purity `exponential-backoff`, `linear-backoff` and `constant-backoff` are pure: the returned fn depends only on its `attempt` argument. `decorrelated-jitter` is *inherently stateful* (its next delay depends on the previous draw, not on `attempt`), so it is deliberately **not** an `attempt -> ms` fn. It is exposed as a pure state transducer — see its docstring — rather than bending the common contract and hiding an atom.
(constant-backoff)(constant-backoff opts)Returns a pure fn (attempt) -> delay-ms that always yields the same delay.
Options: :ms (default 500), plus the :jitter / :rand-fn pair
documented on exponential-backoff.
Returns a pure fn `(attempt) -> delay-ms` that always yields the same delay. Options: `:ms` (default `500`), plus the `:jitter` / `:rand-fn` pair documented on `exponential-backoff`.
(decorrelated-jitter attempt state)(decorrelated-jitter _attempt state opts)AWS "decorrelated jitter" as a pure state transducer.
The AWS algorithm is a recurrence, not a closed form:
d(1) = base-ms
d(n) = min(max-ms, rand(base-ms, d(n-1) · multiplier))
d(n) depends on the previous draw, not on n, so it cannot satisfy this
namespace's (attempt) -> delay-ms contract. Rather than hide an atom behind
that contract, the recurrence is exposed honestly:
(decorrelated-jitter attempt state opts) ;=> {:delay-ms n :next-state s}
Thread :next-state into the following call. The function is pure — the same
(attempt, state, opts) always yields the same result — so a retry loop keeps
its own state locally and no two loops can couple. Because state also counts
its own draws, restarting from initial-state on every loop is safe, and the
first draw is always exactly base-ms.
(let [opts {:base-ms 100 :rand-fn (constantly 0.9)}
s0 (initial-state opts)
r1 (decorrelated-jitter 1 s0 opts) ;=> base-ms
r2 (decorrelated-jitter 2 (:next-state r1) opts)]
[(:delay-ms r1) (:delay-ms r2)]) ;=> [100 280]
Options: :base-ms (default 100), :multiplier (default 3.0),
:max-ms (default 30_000), :rand-fn (required).
Each draw is monotonic in rand-fn's value, so a constant :rand-fn yields
the deterministic traces asserted in backoff-test.
AWS "decorrelated jitter" as a **pure state transducer**.
The AWS algorithm is a recurrence, not a closed form:
```
d(1) = base-ms
d(n) = min(max-ms, rand(base-ms, d(n-1) · multiplier))
```
`d(n)` depends on the previous *draw*, not on `n`, so it cannot satisfy this
namespace's `(attempt) -> delay-ms` contract. Rather than hide an atom behind
that contract, the recurrence is exposed honestly:
```clojure
(decorrelated-jitter attempt state opts) ;=> {:delay-ms n :next-state s}
```
Thread `:next-state` into the following call. The function is pure — the same
`(attempt, state, opts)` always yields the same result — so a retry loop keeps
its own state locally and no two loops can couple. Because `state` also counts
its own draws, restarting from `initial-state` on every loop is safe, and the
first draw is always exactly `base-ms`.
```clojure
(let [opts {:base-ms 100 :rand-fn (constantly 0.9)}
s0 (initial-state opts)
r1 (decorrelated-jitter 1 s0 opts) ;=> base-ms
r2 (decorrelated-jitter 2 (:next-state r1) opts)]
[(:delay-ms r1) (:delay-ms r2)]) ;=> [100 280]
```
Options: `:base-ms` (default `100`), `:multiplier` (default `3.0`),
`:max-ms` (default `30_000`), `:rand-fn` (**required**).
Each draw is monotonic in `rand-fn`'s value, so a constant `:rand-fn` yields
the deterministic traces asserted in `backoff-test`.(exponential-backoff)(exponential-backoff opts)Returns a pure fn (attempt) -> delay-ms implementing capped exponential
backoff with configurable jitter.
attempt is the 1-based failure count; the first delay therefore equals
:base-ms (exponent attempt - 1).
Options:
:base-ms first delay, default 100:multiplier growth factor per failure, default 2.0:max-ms cap applied before jitter, default 30_000:jitter :none (default) | :full | :equal | number:rand-fn 0-arg random source; required when :jitter != :none(def f (exponential-backoff {:base-ms 100 :multiplier 2.0 :max-ms 1000}))
(map f [1 2 3 5]) ;=> (100 200 400 1000)
Deterministic test example:
(def f (exponential-backoff {:base-ms 100 :jitter :full
:rand-fn (constantly 0.5)}))
(f 3) ;=> 200
Returns a pure fn `(attempt) -> delay-ms` implementing capped exponential
backoff with configurable jitter.
`attempt` is the 1-based failure count; the first delay therefore equals
`:base-ms` (exponent `attempt - 1`).
Options:
- `:base-ms` first delay, default `100`
- `:multiplier` growth factor per failure, default `2.0`
- `:max-ms` cap applied before jitter, default `30_000`
- `:jitter` `:none` (default) | `:full` | `:equal` | number
- `:rand-fn` 0-arg random source; required when `:jitter` != `:none`
```clojure
(def f (exponential-backoff {:base-ms 100 :multiplier 2.0 :max-ms 1000}))
(map f [1 2 3 5]) ;=> (100 200 400 1000)
```
Deterministic test example:
```clojure
(def f (exponential-backoff {:base-ms 100 :jitter :full
:rand-fn (constantly 0.5)}))
(f 3) ;=> 200
```(initial-state)(initial-state opts)Initial state for decorrelated-jitter. :base-ms defaults to 100.
Initial state for `decorrelated-jitter`. `:base-ms` defaults to `100`.
(linear-backoff)(linear-backoff opts)Returns a pure fn (attempt) -> delay-ms implementing capped linear backoff:
base-ms + increment-ms · (attempt - 1).
Options: :base-ms (default 250), :increment-ms (default 250),
:max-ms (default 5_000), plus the :jitter / :rand-fn pair documented
on exponential-backoff.
Returns a pure fn `(attempt) -> delay-ms` implementing capped linear backoff: `base-ms + increment-ms · (attempt - 1)`. Options: `:base-ms` (default `250`), `:increment-ms` (default `250`), `:max-ms` (default `5_000`), plus the `:jitter` / `:rand-fn` pair documented on `exponential-backoff`.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |