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? below — for many applications the answer is "none of this applies", and that question is answerable in a minute.

At a glance

Area2.x3.0Keep 2.x behavior with
Retry boundunbounded — retries forevermax_attempts: 5retry: [max_attempts: :infinity]
Rate limit waitunbounded — sleeps until admittedone window (:per), capped at 5srate_limit: [wait: :infinity]
:expiry under 100msfloors the last delay at 100ms and adds an attempttrims the last delay to the budgetno equivalent — see below
Circuit breaker :toleratecounts failing attemptscounts failing callscircuit_breaker: [melt: :per_attempt]
Circuit breaker :within10_000:auto — sized from the retry options, never below 10_000circuit_breaker: [within: 10_000]
:decorator dependencyinstalled transitivelydeclare it yourselfadd 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.

# 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:

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.

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 :basebase: 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.

# 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:

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

Flow pipelines want the old behavior

Flow Pipelines 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:

:expiry2.x3.0
50ms2 attempts, 100ms slept4 attempts, 50ms slept
250ms6 attempts, 250ms slept6 attempts, 250ms slept — same
1000ms8 attempts, 1000ms slept8 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:

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:

# 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:

# 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:

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

Keeping the 2.x behavior

One option, and everything above stops applying:

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:

# 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.