Summary#
A Backoff is a retry policy you can hold — a value that answers one question, how long to wait before attempt
N. It deliberately does not answer whether to retry: that belongs to whatever is doing the retrying (a
milestone's attempt budget, a step's failure), and a policy that answered both would be two things under one name.
Three factories say how the wait grows, and three fluent members bound it. The bounds are members rather than
more arguments because they are all numbers: nobody reading Backoff.Exponential(2s, 4, 5m) can say which is which,
and .MaxAttempts(4).Cap(TimeSpan.FromMinutes(5)) says it.
Signature#
Backoff.Fixed(<TimeSpan>) // 2s, 2s, 2s, 2s …
Backoff.Linear(<TimeSpan>) // 2s, 4s, 6s, 8s …
Backoff.Exponential(<TimeSpan>) // 2s, 4s, 8s, 16s …
.MaxAttempts(<int>) // how many attempts in TOTAL (the first one included)
.Cap(<TimeSpan>) // no single wait may exceed this
.Jitter(<decimal>) // spread each wait uniformly ± this fraction of itselfDescription#
The three shapes#
The name says the sequence. With an interval of 2 seconds:
| policy | the waits |
|---|---|
Backoff.Fixed(TimeSpan.FromSeconds(2)) | 2s, 2s, 2s, 2s |
Backoff.Linear(TimeSpan.FromSeconds(2)) | 2s, 4s, 6s, 8s |
Backoff.Exponential(TimeSpan.FromSeconds(2)) | 2s, 4s, 8s, 16s |
The first wait is always the interval — attempt 1 is the first retry, and the original try was not a retry.
.Cap(…) — the bound that makes exponential safe to write down#
Doubling is the growth people mean and the ceiling is the part they forget. Backoff.Exponential(2s) on its tenth
attempt waits 17 minutes; on its fifteenth, 9 hours. Without a cap the interesting parameter becomes the
attempt count, which is the wrong knob — you wanted "keep trying, but never sit idle longer than five minutes":
Backoff.Exponential(TimeSpan.FromSeconds(2)).Cap(TimeSpan.FromMinutes(5))
// 2s, 4s, 8s, 16s, 32s, 64s, 2m8s, 4m16s, 5m, 5m, 5m ….Jitter(…) — so a herd does not retry in lockstep#
When one dependency goes down, every run waiting on it computes the same delay and comes back at the same
instant — which is the outage's second wave. .Jitter(0.2) spreads each wait uniformly ±20% of itself.
Jitter is opt-in, and the default is exact. That is what makes a retry sequence assertable in a test, and what makes a run's timeline read as a sequence rather than a scatter. Reach for it when many runs retry against one shared dependency; leave it off otherwise.
.MaxAttempts(…) — how many, in TOTAL#
.MaxAttempts(3) means three attempts, not three retries after the first.
⚠ A durable step's retry: REQUIRES it. A milestone can leave it out because Retries = N supplies the budget;
a step has nothing else in scope, so an uncapped policy there would retry for ever and is a compile error. See
Workflow.Once (run a step at most once).
⚠ A milestone's Retries = N counts the other way — it is how many further windows follow the first, which is
W43's original wording and is not being changed. So Retries = 2 and .MaxAttempts(3) describe the same thing. A
milestone that declares both is a compile error rather than a silent preference, because the two do not even
count the same unit:
this milestone sets `Retries = 2` and its `Backoff` policy also caps the attempts with `.MaxAttempts(…)` —
they are two budgets for one thing. Keep ONE: drop `.MaxAttempts(…)` and leave `Retries = 2`, or drop
`Retries` and write `.MaxAttempts(3)` on the policy. ⚠ They count differently — `Retries` is how many
FURTHER windows follow the first, `MaxAttempts` is how many windows there are in TOTAL.A plain TimeSpan is still a policy#
Everywhere a Backoff is accepted, a bare TimeSpan is too, and it means Fixed — the same wait every time.
Nothing already written changes meaning, and Backoff = TimeSpan.FromMinutes(30); stays the shortest way to say the
simplest thing.
Examples#
On a milestone, the policy is what delays each further window. Here a machine-filled slot gets three attempts whose gaps double, so a dependency that is briefly unavailable is retried quickly and a genuinely broken one is not hammered:
enum JobStage { Queued, Running, Escalated }
enum Decision { Ok }
[Principal] entity Person {
[Required, MaxLength(80)] string Name;
security { allow read, create when IsAuthenticated; }
}
entity Job {
[Required, MaxLength(120)] string Title;
JobStage Stage;
security { allow read, create, update when IsAuthenticated; }
}
workflow JobFlow {
Tracks = Job.Stage;
Autostart = true;
Initial = Queued;
event Start();
event Complete(Decision decision);
state Queued { subscribe Start(); on Start { goto Running; } }
state Running {
subscribe Complete(Decision decision) as Worker {
Finished {
Within = TimeSpan.FromMinutes(5);
// Three attempts, five minutes apart, then ten, then twenty — but never more than an hour idle.
Backoff = Backoff.Exponential(TimeSpan.FromMinutes(5)).MaxAttempts(3).Cap(TimeSpan.FromHours(1));
Exhausted { } // tried and gave up
Unfinished { goto Escalated; } // …and this is where the run goes
}
}
on Worker(Decision decision) { default { goto Escalated; } }
}
terminal error Escalated { Message = "job escalated"; }
}The same policy written the other way round — an attempt budget on the milestone, growth on the policy:
Finished {
Within = TimeSpan.FromMinutes(5);
Retries = 2; // two further windows after the first
Backoff = Backoff.Exponential(TimeSpan.FromMinutes(5));
Unfinished { goto Escalated; }
}Many runs retrying against one shared dependency, spread so they do not arrive together:
Backoff = Backoff.Exponential(TimeSpan.FromSeconds(2))
.Cap(TimeSpan.FromMinutes(5))
.Jitter(0.2)
.MaxAttempts(8);See also#
- Assigned / Finished (milestones) —
Retries/Backoff/Exhausted, the milestone that consumes a policy - Workflow.Once (run a step at most once) —
retry:, the other consumer: a durable step that FAILED rather than a deadline that passed - subscribe — the slot a milestone hangs off