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
| 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.
# 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 attemptsWhat 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 :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.
# 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 RateLimitedTo 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:
: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:
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/2warnings. If neither appears, skip to the last two boxes. - [ ] For each warned service, decide: a real bound, or
max_attempts: :infinity/wait: :infinityto keep 2.x behavior. - [ ] Set
wait: :infinityexplicitly on any service used from a Flow pipeline or background job. - [ ] Check for
:expiryvalues under 100ms; if any, confirm the new attempt count and timing are what you want. - [ ] Divide every
:tolerateby the:max_attemptsit was sized against — it counts calls now. - [ ] Check
:withinis wide enough for:toleratefailing calls to arrive, not attempts. Leaving it unset lets:autosize it; an explicit value is your own to check, and slow services need a wider window than before. - [ ] Add an
:expiryto any service retrying withmax_attempts: :infinity, or setcircuit_breaker: [melt: :per_attempt]. - [ ] Add
{:decorator, "~> 1.4"}to your deps if you use@decorate external_call. - [ ] Add clauses for
ExternalService.RetriesExhaustedandExternalService.RateLimitedwherever 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.