Skip to content

add jitter and decorrelated backoff - #2

Merged
azeemshaik025 merged 12 commits into
mainfrom
jitter
Aug 9, 2026
Merged

add jitter and decorrelated backoff#2
azeemshaik025 merged 12 commits into
mainfrom
jitter

Conversation

@azeemshaik025

Copy link
Copy Markdown
Owner

No description provided.

Retrying on a fixed schedule means every client that failed together
retries together, so a dependency coming back up gets a synchronized
wave. Jitter is the fix. Two shapes ship here.

`Jitter::Full` / `Jitter::Equal` with the `Jittered<B>` wrapper and the
`Backoff::jittered(mode)` combinator randomize any strategy's delays.
Opt-in; retry's default stays deterministic.

`DecorrelatedBackoff` is the AWS formula, `min(cap, rand(base, prev*3))`.
It can't be a `Jitter` mode because the randomness is in the recurrence:
each range is set by the delay actually drawn last time, so there's no
deterministic sequence underneath to wrap. Validated config, `max_retries`
give-up, and the same `BackoffConfigError` as the exponential strategy.

Neither randomized type is `Clone`. Copying the RNG state would make
every copy replay identical delays, which puts concurrent requests on one
host into lockstep, which is the herd again. Hold the config, which is
`Clone`, and build a fresh strategy from it.

Both are seedable (`with_seed`) so jittered retries stay reproducible.
Tests are mutation-checked: a no-op `Equal` mode, a dropped `mode`
argument, a fixed seed in place of entropy, the raw draw fed back instead
of the capped one, a smaller multiplier, and the cap applied before the
draw all fail the suite.

ADR004 records the `fastrand` dependency (measured against the cost of
the `tracing` tree we already take), why decorrelated is a strategy
rather than a mode, and why the randomized types aren't `Clone`.
The backoff tests cover the delay sequences in isolation, but nothing
exercised a strategy carrying its own RNG through the pin-projected state
machine. Seeded, so the delays stay fixed.

Blocking twin skipped: its driver is a plain loop, so it can't have the
class of bug these cover (state surviving a move into a pinned future).
`ConstantBackoff` was on the 0.3.0 list, but `ExponentialBackoff` with
`factor: 1` already produces exactly that: the delay never grows, so it
stays at `base`. A separate type would be a permanent public commitment
for something already expressible, so this is one doc line and a test
pinning the behaviour instead.
Jitter only. The RetryError<E> work is breaking and ships separately, so
this release is purely additive: existing code compiles unchanged and the
default backoff stays deterministic.

Bumps the version because cargo-semver-checks compares against the
published 0.2.0, and adds a README section since jitter is user-facing.
Equal jitter's floor is delay/2. With the default factor of 2 that is also
the previous ladder value, so the two readings give the same number and
the existing tests -- which all use factor 2 -- cannot tell them apart.

This is documentation rather than coverage, and the comment says so.
Jitter::apply takes one Duration and keeps no history, so the previous-step
reading is not implementable without a structural change; the test exists to
stop that change happening by accident and to state the rule at a factor
where the two numbers differ.
Equal jitter is dominated. AWS's own post says it "does slightly more work
than Full Jitter, and takes much longer", and their SDK agrees: full jitter
is the default for STANDARD and ADAPTIVE retry modes while equal survives
only on the LEGACY throttling path.

Simulating both against mettle's actual implementations under a contended
resource reproduced it. Equal lost to full on total calls AND completion
time in all five configurations tried, across client counts and base delays.

An option nobody should pick is a trap rather than a choice, and "equal"
reads as the safe middle option, which is exactly backwards. With one mode
left the enum was ceremony, so `jittered()` now takes no argument and the
`Jitter` type is gone.

The cost is the extension point: `Jitter` was #[non_exhaustive] so a fourth
mode would have been free to add, and now it would need a separate
`jittered_with(mode)`. Accepted -- the literature defines three, we ship the
one that measures best, and decorrelated cannot be a mode at all.

Docs now say which to reach for: `.jittered()` composes with any strategy
and spreads widest; `DecorrelatedBackoff` gives a floor, at the price of
never retrying sooner than `base`.
Two papercuts found by writing the API from a user's side rather than
from inside the crate.

`.jittered()` reads well but couldn't be seeded, and seeding meant
`Jittered::with_seed(ExponentialBackoff::default(), 42)` -- so a test and
the production config it was supposed to be testing looked nothing alike.
`jittered_with_seed(seed)` closes that.

And `Clock` wasn't implemented for references, so `.clock(&mock)` was a
compile error (E0599) and every mock had to wrap its own state in Rc or
Arc before it could be passed at all. That is friction on the exact path
this crate advertises. Added for `&C` and `Arc<C>` on both Clock traits;
a mock is now a plain struct with a RefCell.

Both additive. Verified from a downstream crate: a mock with no interior
sharing now works through &clock, through Arc, and through the seeded
combinator, all three producing identical delays.
The release is additive at the signature level, but adding `impl Clock for
&C` collides with the same impl if a user already wrote it themselves --
most likely as a workaround for it being missing. Verified: a crate with
both `impl Clock for MyClock` and `impl Clock for &MyClock` compiles
against 0.2.0 and fails with E0119 against this branch.

Blast radius is small and the fix is deleting a line that this release
makes redundant, so the impls stay. But cargo-semver-checks does not flag
added impls, so nothing else would have told anyone. It goes in the
CHANGELOG.
Every gate was green and every one of these was still wrong, because
nothing checks whether prose is current.

The crate-level docs -- the docs.rs landing page, the first thing anyone
reads -- never mentioned jitter at all. The ADR index never listed ADR004.
And examples/retry.rs, which the README calls the place to start, showed
none of the release's headline feature.

Also corrected the 'available now' line, which still described 0.2.0.
A changelog answers 'what changed and what must I do'. Rationale is a
different question with a different audience and a different lifetime: it
belongs where someone goes looking for why, not where someone goes looking
for whether to upgrade.

The 0.3.0 entry had grown three paragraphs of argument about AWS's jitter
measurements, why decorrelated is a strategy rather than a mode, and why
the reference impls exist. That is ADR material. The entry is now the
change list, the new dependency, and the one thing that can break a build,
with a link out for the why.

ADR004 gains the two decisions that had no home: why both a readable and a
seeded jitter path exist, and why Clock and Now are implemented for
references -- including the honest note that our own tests hid that gap for
two releases by working around it on day one.
ADR004 was carrying two things that are not about jitter: why both a
readable and a seeded jitter path exist, and why Clock and Now are
implemented for references. Both came out of shipping jitter, but neither
is a jitter decision -- they are about whether the deterministic path this
crate advertises is actually usable.

ADR005 also records the practice that found them, which is the part worth
keeping: build a throwaway crate against a path dependency and write the
code a first-time reader would write. Our own tests hid the reference-impl
gap for two releases because they worked around it on day one, so a suite
that lives inside the crate cannot tell you whether the front door opens.

Numbering: retry-error and circuit-breaker will shift their ADRs to 006
and 007 when they rebase.
Fair question to get asked: the crate injects time through a trait, so why
is randomness a bare fastrand::Rng?

Because the two problems are not the same shape. You cannot seed the system
clock, so the only way a test controls time is to replace the source, which
is what the Clock trait is for. Randomness has a cheaper answer: seeding IS
the determinism mechanism, and with_seed already gives full reproducibility
without replacing anything. A trait would buy the ability to swap the
algorithm, which nobody has asked for and which costs a second type
parameter on two public types forever.

The coupling is three functions (Rng::new, Rng::with_seed, rng.u64) and
fastrand appears in no public signature, so the abstraction is not needed to
keep the option of swapping it either.

Also fixes an inconsistency the question surfaced: DecorrelatedBackoff's
seeded constructor warned that exact values are not an API contract, and the
two jitter seed paths -- the ones people reach for first -- did not.
@azeemshaik025
azeemshaik025 merged commit d184799 into main Aug 9, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant