# Migrating to 3.0

ExternalService 3.0 changes four things, and **nothing renames**. Your code
compiles unchanged; it behaves differently. That is exactly what makes this a
major version: there is no call site to fix, because the call sites were never
wrong — the *defaults* were.

Three of the four changes are defaults that used to mean "never give up".
Retrying without a bound, waiting for a rate limit without a bound, and a retry
time budget that quietly rounded itself up. Each now stops at a finite point, and
each has a one-line way to keep the old behavior if it was what you wanted.

> If your application has been running 2.4.0 or later, **you have already been
> told which of these affect you.** The two largest changes have warned at boot
> since then. See [Have you been warned already?](#have-you-been-warned-already)
> below — for many applications the answer is "none of this applies", and that
> question is answerable in a minute.

## At a glance

| Area                          | 2.x                                          | 3.0                                       | Keep 2.x behavior with        |
| ----------------------------- | -------------------------------------------- | ----------------------------------------- | ----------------------------- |
| Retry bound                   | unbounded — retries forever                  | `max_attempts: 5`                         | `retry: [max_attempts: :infinity]` |
| Rate limit wait               | unbounded — sleeps until admitted            | one window (`:per`), capped at 5s         | `rate_limit: [wait: :infinity]` |
| `:expiry` under 100ms         | floors the last delay at 100ms and adds an attempt | trims the last delay to the budget  | no equivalent — see below     |
| Circuit breaker `:tolerate`   | counts failing **attempts**                  | counts failing **calls**                  | `circuit_breaker: [melt: :per_attempt]` |
| Circuit breaker `:within`     | `10_000`                                     | `:auto` — sized from the retry options, never below `10_000` | `circuit_breaker: [within: 10_000]` |
| `:decorator` dependency       | installed transitively                       | declare it yourself                       | add it to your `deps`         |

The first two are silent: same code, different behavior. The third is silent but
narrow. The fifth is loud — it fails your build with a clear message. The fourth
is the one to read carefully: it is silent, it is not narrow, and it changes what
a number you have already tuned means.

## Have you been warned already?

Since 2.4.0, `ExternalService.start/2` logs a warning for each of the two
unbounded defaults. Search your boot logs:

```
sets no retry bound: neither :max_attempts nor :expiry is configured
sets no rate limit wait budget: :wait is unset
```

- **Neither appears** → changes 1 and 2 do not affect you. You already configure
  both bounds explicitly.
- **One or both appear** → that service takes the new default in 3.0. The
  warning names the service and the one-line fix.

The warnings disappear in 3.0, because the defaults they were warning about are
gone.

## 1. Retries are bounded by default

Retry options that set neither `:max_attempts` nor `:expiry` used to retry
forever. The circuit breaker was not a reliable backstop for this: growing
backoff delays outpace its `:within` window, so a fully default breaker paired
with `retry: [base: 100]` never opens.

```elixir
# 2.x — retries until it succeeds, however long that takes
ExternalService.start(:my_service, circuit_breaker: [tolerate: 5, within: 1_000])

# 3.0 — the same call now stops after 5 attempts
```

**What you will see if this affects you.** A call that used to block until it
eventually succeeded now returns `{:error, %ExternalService.RetriesExhausted{}}`
— into a `case` that may have no clause for it. That is the failure mode to look
for: not a crash at the call site, but an unhandled error a few frames up.

**To keep the old behavior**, say so explicitly. `:infinity` has been accepted
since 2.4.0 for exactly this, so it is safe to add *before* upgrading:

```elixir
retry: [max_attempts: :infinity]
```

**Better, where you can:** pick a real bound. Unbounded retrying is almost never
what a request path wants, and a bound is what lets the circuit breaker do its
job. See [Retries](retries.md#bounding-retries).

Two things worth knowing about the number `5`. It is what
`ExternalService.start/2` has been suggesting in its warning since 2.4.0, so if
you took that advice you are already on the 3.0 default. And it is a **bound**
rather than a generous allowance: with the default `base: 10` and exponential
backoff the delays are `[10, 20, 40, 80]`, so a defaulted call waits at most
150ms across its four retries. If your dependency needs a longer retry window,
raise `:base` — `base: 100` is the usual choice for HTTP — rather than
`:max_attempts`.

It also restores the circuit breaker at its own defaults. Every failing attempt
melts, so five attempts melt five of the ten a default breaker tolerates: two
fully-failing calls open it. Today, a default breaker paired with default retry
options never opens at all, because growing backoff delays outpace its `:within`
window.

## 2. The rate limit wait is bounded by default

An unset `rate_limit: [:wait]` used to sleep the calling process until the
limiter admitted it. In a request path that converts sustained throttling into
unbounded latency and process growth rather than a fast `429` — even though
`ExternalService.RateLimited` already carries `retry_after` and maps to `429`.

```elixir
# 2.x — a throttled call waits as long as it takes
ExternalService.start(:my_service, rate_limit: [limit: 50, per: 1_000])

# 3.0 — the same call gives up after one window (1s here) and returns RateLimited
```

**To keep the old behavior:**

```elixir
rate_limit: [limit: 50, per: 1_000, wait: :infinity]
```

> #### Flow pipelines want the old behavior {: .warning}
>
> [Flow Pipelines](flow.md) recommends an unbounded wait, and is right to:
> sleeping is how back-pressure propagates upstream through a pipeline. A finite
> budget converts that into shedding work mid-pipeline.
>
> **If you run guarded calls inside a Flow or a background job, set
> `wait: :infinity` explicitly.** This is the one place where the 2.x default was
> the correct choice, and 3.0 makes you say so.

The new default is **one window — `:per`, capped at 5 seconds** — rather than
`false`. One window is the most a limiter can ask you to wait for the next
refill, so it absorbs a burst exactly and no more: measured at
`limit: 50, per: 1_000`, a 2× instantaneous burst goes from 50% shed to 0%, while
a *sustained* 2× overload still sheds around 15%. Shedding is the right answer to
real overload; the wait exists to absorb bursts.

`wait: false` was rejected for the opposite reason — it sheds half of a burst a
healthy service should absorb, which makes bursty-but-fine traffic look like an
outage. The 5-second cap keeps a large window sane: a per-minute quota would
otherwise block a caller for a full minute.

## 3. `:expiry` no longer overshoots a small budget

`:expiry` is documented as a total time budget for retrying. Under 2.x, budgets
below 100ms did not honor that: the last delay was floored at 100ms, so a tight
budget cost at least 100ms and always bought one more attempt.

Only budgets **under about 100ms** are affected. For everything larger, 3.0
produces exactly the same delays as 2.x:

| `:expiry` | 2.x                              | 3.0                              |
| --------- | -------------------------------- | -------------------------------- |
| 50ms      | 2 attempts, 100ms slept          | 4 attempts, 50ms slept           |
| 250ms     | 6 attempts, 250ms slept          | 6 attempts, 250ms slept — same   |
| 1000ms    | 8 attempts, 1000ms slept         | 8 attempts, 1000ms slept — same  |

Note that the change runs in both directions at once: *more* attempts, in *less*
time. A tight budget now does what it says — it retries as fast as the backoff
allows and stops at the deadline, rather than sleeping past it.

There is no option that restores the 2.x shape, because it was not a shape anyone
chose deliberately. If you were relying on "one retry roughly 100ms later", say
that directly:

```elixir
retry: [max_attempts: 2, backoff: :linear, base: 100]
```

## 4. `:tolerate` counts calls, not attempts

**Before:** every failing *attempt* melted the circuit breaker. A single `call/3`
with `max_attempts: 5` contributed up to five melts, so `:tolerate` and
`:max_attempts` could not be tuned independently — and raising the attempt count
made the breaker open *sooner*, because each call spent more of its budget.

**Now:** a call melts the breaker once, when its retrying gives up. `tolerate: 3`
means three failing calls, whatever `:max_attempts` is.

### What to change

Divide by your attempt count. If you followed the old guidance and sized
`:tolerate` as *(calls you will absorb) × `:max_attempts`*, that multiplication is
now done for you:

```elixir
# 2.x: "open after about three dead calls", with max_attempts: 5
circuit_breaker: [tolerate: 15, within: :timer.seconds(5)]

# 3.0: the same intent
circuit_breaker: [tolerate: 3, within: :timer.seconds(5)]
```

Left alone, `tolerate: 15` now means fifteen failing calls, so your breaker opens
five times later than you intended. That is silent, and in the dangerous
direction.

### `:within` may need to be wider

This is the part most easily missed. Melts used to be spread across a call's retry
window; now each call contributes one melt at the moment it gives up, so `:within`
has to span the interval across which `:tolerate` failing *calls* arrive. For fast
calls that is easier than before. For slow ones it is not:

```elixir
# A background job whose calls take ~30s (max_attempts: :infinity, expiry: 30s).
# 2.x: 20 melts arrived within one call, so a 30s window was ample.
circuit_breaker: [tolerate: 20, within: :timer.seconds(30)]

# 3.0: three failing calls take about 90s, so a 30s window never sees them all.
circuit_breaker: [tolerate: 3, within: :timer.seconds(120)]
```

**`:within` now defaults to `:auto`, which does this arithmetic for you** — it
sizes the window against your retry options and the `:melt` setting, and is never
narrower than the 10 seconds it replaces. The example above is what `:auto`
computes. You only need to set `:within` yourself if you want something other than
that floor, and an explicit value is left exactly as given.

Note what `:auto` cannot know: a failing call takes its retry window *plus*
however long its attempts run for, and no configuration states the latter. So it
is a floor rather than a guarantee. If your dependency's attempts are slow, size
`:within` against how long a failing call really takes.

### Unbounded retrying now needs a time budget

Under `:per_call`, a call that never gives up never melts — so
`max_attempts: :infinity` with no `:expiry` would retry forever with nothing to
stop it. That combination now raises, at `start/2` and at any `call/3` that
overrides its way into it. Add the budget:

```elixir
retry: [max_attempts: :infinity, expiry: :timer.seconds(30)]
```

### Keeping the 2.x behavior

One option, and everything above stops applying:

```elixir
circuit_breaker: [melt: :per_attempt]
```

That is the honest choice if you rely on a single unbounded call being halted by
its own breaker, which only attempt-counting can do.

## 5. `:decorator` is an optional dependency

`ExternalService.Decorator` — the `@decorate external_call` annotations — is now
behind an optional dependency, the same treatment `:flow` has always had. If you
use it, declare it:

```elixir
# mix.exs
{:decorator, "~> 1.4"}
```

Unlike the three above, this one is **loud**. If you use the decorator and do not
add the dependency, the build fails immediately and says so:

```
error: module ExternalService.Decorator is not loaded and could not be found
** (CompileError) cannot compile module MyApp.Stripe (errors have been logged)
```

If you do not use `@decorate external_call`, you need to do nothing, and you get
one fewer transitive dependency.

## Upgrade checklist

- [ ] Search boot logs for the two `start/2` warnings. If neither appears, skip to the last two boxes.
- [ ] For each warned service, decide: a real bound, or `max_attempts: :infinity` / `wait: :infinity` to keep 2.x behavior.
- [ ] Set `wait: :infinity` explicitly on any service used from a Flow pipeline or background job.
- [ ] Check for `:expiry` values under 100ms; if any, confirm the new attempt count and timing are what you want.
- [ ] Divide every `:tolerate` by the `:max_attempts` it was sized against — it counts calls now.
- [ ] Check `:within` is wide enough for `:tolerate` failing *calls* to arrive, not attempts. Leaving it unset lets `:auto` size it; an explicit value is your own to check, and slow services need a wider window than before.
- [ ] Add an `:expiry` to any service retrying with `max_attempts: :infinity`, or set `circuit_breaker: [melt: :per_attempt]`.
- [ ] Add `{:decorator, "~> 1.4"}` to your deps if you use `@decorate external_call`.
- [ ] Add clauses for `ExternalService.RetriesExhausted` and `ExternalService.RateLimited` wherever a previously-unbounded call is handled — these are the errors that could not occur before.
- [ ] Run your test suite. Tests that relied on a call blocking until success are the ones most likely to surface this.
