This guide walks you through adding ExternalService to a project and making
your first reliable call to an external API — with retries, a circuit breaker,
and (optionally) rate limiting all working for you out of the box.
For full reference material, see the ExternalService module docs.
Installation
Add external_service to your dependencies in mix.exs:
def deps do
[
{:external_service, "~> 2.0"}
]
endThen run mix deps.get.
The big idea
Calling an external service is risky: the network hiccups, the service is
briefly overloaded, or it goes down entirely. ExternalService wraps those
calls with two complementary safety mechanisms:
- Retries smooth over transient failures by trying a failed request again, with configurable backoff.
- A circuit breaker protects you from a service that is persistently failing: once failures cross a threshold the breaker "opens" and further calls fail fast instead of piling up against a service that is already down.
- Optionally, a rate limiter keeps you under the call quota the external service imposes.
You wrap your call to the external service in a function, hand that function to
ExternalService, and it applies all of the above on every call.
Your first service
The recommended way to use the library is the declarative module front door,
use ExternalService. Define a module for the service you depend on and
configure its behavior in one place:
defmodule MyApp.Stripe do
use ExternalService,
circuit_breaker: [tolerate: 5, within: :timer.seconds(1), reset: :timer.seconds(5)],
retry: [max_attempts: 5, backoff: :exponential, jitter: true]
def charge(params) do
call fn ->
case Stripe.charge(params) do
{:ok, result} -> {:ok, result}
{:error, %{status: status}} when status in 500..599 -> :retry
other -> other
end
end
end
endA few things to notice:
- The module configures its own circuit breaker and retry policy. No fuse names to juggle at the call site.
charge/1wraps the real Stripe call in a zero-argument function and passes it to the generatedcall/1.- The function returns
:retry(or{:retry, reason}) to ask for another attempt; any other value is treated as success and returned as-is.
Start it under your supervisor
A service must be started before it can be called — starting installs its circuit breaker (and rate limiter, if configured). Add the module to your supervision tree:
def start(_type, _args) do
children = [
MyApp.Stripe
# ... the rest of your children
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
endThat's it. Anywhere in your application you can now call:
MyApp.Stripe.charge(%{amount: 1000, currency: "usd", source: token})and the retry, circuit-breaker, and rate-limit logic is applied automatically.
Triggering a retry
Inside the function you pass to call/1, you decide what counts as a retriable
failure. There are two ways to ask for a retry:
- return the atom
:retry, or - return a tuple
{:retry, reason}, wherereasonis any term (handy for logging and telemetry).
Any other return value is considered a success and is returned to the caller
untouched — including the function's own {:error, reason} values. This is the
key distinction: an {:error, ...} you return is your error and passes
through; only :retry/{:retry, reason} drive the retry machinery.
def fetch(id) do
call fn ->
case HTTP.get("/widgets/#{id}") do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: status}} when status in 500..599 -> {:retry, status}
{:ok, %{status: 404}} -> {:error, :not_found} # not retried — your error
{:error, %HTTPError{}} = err -> err # not retried — your error
end
end
endBy default, raised exceptions are not retried — they propagate to the
caller. If you want an exception type to trigger a retry, list it in the
:retry_exceptions retry option; to retry based on the return value of a function
you don't want to modify, use the :retry_on predicate. See the
Retries guide for the full set of retry knobs.
Handling failures
When retries are exhausted or the circuit breaker is open, call/1 returns a
structured error:
case MyApp.Stripe.charge(params) do
{:ok, result} ->
handle_success(result)
{:error, %ExternalService.RetriesExhausted{}} ->
# transient failure outlasted our retry budget
{:error, :payment_unavailable}
{:error, %ExternalService.CircuitBreakerOpen{}} ->
# the breaker is open; fail fast
{:error, :payment_unavailable}
{:error, reason} ->
# an error your own function returned
{:error, reason}
endIf you'd rather these failures raise instead of being returned, use the
generated call!/1. See the Error handling guide for the
full picture.
Where to go next
- The module front door — everything
use ExternalServicegenerates, plus supervision and per-environment overrides. - Circuit breakers — how the breaker trips and resets, and how to introspect it.
- Retries — backoff strategies, jitter, attempt and time budgets, and retrying on exceptions.
- Rate limiting — staying under a service's quota.
- Error handling —
callvscall!and the structured error types. - Telemetry — observing calls, retries, and breaker trips.
- Migrating to 2.0 — upgrading from 1.x.