# Backoff (retry policy)

> A retry policy as a value: how long to wait before the next attempt. Three shapes say how the wait GROWS — `Fixed` (the same wait every time), `Linear` (it grows by the interval), `Exponential` (it doubles) — and three fluent bounds keep it safe: `.MaxAttempts(n)` how many attempts there are, `.Cap(max)` how long any one wait may get, `.Jitter(f)` how much to spread them so many runs do not retry in lockstep. Anywhere a policy is taken, a plain `TimeSpan` is still legal and means `Fixed`.

<!-- id: workflow-backoff · area: workflow · stability: stable · html: https://osysharp.com/reference/workflow/backoff/ -->

## Summary        {#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      {#signature}
```osy syntax
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 itself
```

## Description    {#description}

### The three shapes   {#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   {#cap}
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":

```osy syntax
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   {#jitter}
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   {#max-attempts}
`.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)](https://osysharp.com/reference/workflow/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:

```console
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   {#timespan}
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       {#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:

```osy title="a milestone whose retry waits double" test app=workflow-backoff-milestone
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:

```osy title="the same policy with the budget on the milestone" syntax
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:

```osy title="spreading many runs so retries do not arrive together" syntax
Backoff = Backoff.Exponential(TimeSpan.FromSeconds(2))
                 .Cap(TimeSpan.FromMinutes(5))
                 .Jitter(0.2)
                 .MaxAttempts(8);
```

## See also       {#see-also}
- [Assigned / Finished (milestones)](https://osysharp.com/reference/workflow/milestone/) — `Retries` / `Backoff` / `Exhausted`, the milestone that consumes a policy
- [Workflow.Once (run a step at most once)](https://osysharp.com/reference/workflow/once/) — `retry:`, the other consumer: a durable step that FAILED rather than a deadline that passed
- [subscribe](https://osysharp.com/reference/workflow/subscribe/) — the slot a milestone hangs off
