Quick recipes for configuring and calling services. See the guides for full detail.
Define a service
Module front door (recommended)
defmodule MyApp.Api do
use ExternalService,
circuit_breaker: [tolerate: 5, within: :timer.seconds(1), reset: :timer.seconds(5)],
rate_limit: [limit: 100, per: :timer.seconds(1), wait: :timer.seconds(1)],
retry: [max_attempts: 5, backoff: :exponential, jitter: true]
def fetch(id), do: call(fn -> HTTP.get("/things/#{id}") end)
endStart it under a supervisor:
children = [MyApp.Api]
Supervisor.start_link(children, strategy: :one_for_one)Functional API
ExternalService.start(:payments,
circuit_breaker: [tolerate: 5, within: 1_000, reset: 5_000],
retry: [max_attempts: 3]
)
ExternalService.call(:payments, fn -> charge() end)Calling
Synchronous
MyApp.Api.call(fn -> work() end)
MyApp.Api.call([max_attempts: 2], fn -> work() end) # per-call retry opts
MyApp.Api.call!(fn -> work() end) # raises on failureAsync / parallel
task = MyApp.Api.call_async(fn -> work() end)
Task.await(task)
ids
|> MyApp.Api.call_async_stream(fn id -> fetch(id) end)
|> Enum.to_list()Triggering retries
Return values
call fn ->
case HTTP.get(url) do
{:ok, %{status: 200} = r} -> {:ok, r}
{:ok, %{status: s}} when s in 500..599 -> {:retry, s} # retry
{:ok, %{status: 429}} -> :retry # retry
other -> other # success
end
endKey rule
| Return | Effect |
|---|---|
:retry | retry |
{:retry, reason} | retry (reason recorded) |
value matched by :retry_on predicate | retry (result recorded as reason) |
| anything else | success, returned as-is |
| raised exception | propagates (retried only if in :retry_exceptions) |
Circuit breaker config
Options
circuit_breaker: [
tolerate: 5, # failed ATTEMPTS per window (default 10);
# retries melt too, so tolerate ≈
# failing calls × max_attempts
within: :timer.seconds(1), # window ms (default 10_000)
reset: :timer.seconds(5) # ms open before reset (default 60_000)
]
circuit_breaker: [tolerate: :infinity] # no breaker at all;
# never opens, holds no stateTrip the cluster together
circuit_breaker: [
tolerate: 5,
backend: ExternalService.CircuitBreaker.Cluster
]
# narrow the broadcast:
backend: {ExternalService.CircuitBreaker.Cluster,
nodes: &MyApp.api_nodes/0}Default breaker is node-local (each node trips on its own).
Introspect & reset
MyApp.Api.available?() # breaker closed?
MyApp.Api.blown?() # breaker open?
MyApp.Api.reset() # force closed
ExternalService.all_available?([:a, :b])Drive it directly
# count a failure that happened outside call/3
ExternalService.CircuitBreaker.melt(:api)
# :ok | :blown | :not_started
ExternalService.CircuitBreaker.ask(:api)Rate limit config
Options
rate_limit: [
limit: 100, # calls per window
per: :timer.seconds(1), # window ms
wait: :timer.seconds(1) # :infinity | ms | false
] # unset waits forever, and warns
rate_limit: [limit: :infinity, per: 1_000] # no limiter at allBurst up to limit, then paced at per / limit.
Bound the wait
rate_limit: [limit: 100, per: 1_000, wait: 2_000]
# budget exhausted -> function never runs:
{:error, %ExternalService.RateLimited{
context: %{retry_after: ms}}} # http_status 429wait: false fails immediately. Never melts the breaker; never retried.
Share a limit across a cluster
defmodule MyApp.RateLimit do
use Hammer, backend: Hammer.Redis
end
rate_limit: [
limit: 100, per: 1_000,
backend: {ExternalService.RateLimiter.Hammer,
module: MyApp.RateLimit}
]Default backend is node-local: N nodes ⇒ up to N × limit.
Ask, and spend, directly
ExternalService.rate_limited?(:api) # boolean, consumes nothing
ExternalService.RateLimiter.peek(:api) # :ok | {:wait, ms}
# spend budget for a call made some other way
ExternalService.RateLimiter.request(:api)Concurrency limit
Options
concurrency: [
limit: 25, # calls in flight at once
reclaim_after: :timer.seconds(30),# slot expiry; must exceed
# your client timeout
wait: 50 # ms | false (default)
]Over the limit, calls shed rather than queue. A short
:wait absorbs bursts; :infinity is rejected.
Errors & introspection
{:error, %ExternalService.ServiceSaturated{
context: %{limit: l, in_flight: n}}} # http_status 503
ExternalService.saturated?(:svc)
ExternalService.Concurrency.in_flight(:svc)Saturation does not melt the breaker and is not retried.
Retry options
All options
retry: [
backoff: :exponential, # or :linear
base: 100, # initial delay ms (default 10)
factor: 1, # :linear growth factor
cap: :timer.seconds(2), # max single delay
max_attempts: 5, # attempt count bound (or :infinity)
expiry: :timer.seconds(10), # time budget ms (or :infinity)
jitter: true, # ±10%, or a float proportion
retry_on: &match?({:error, _}, &1), # predicate over the result
retry_exceptions: [] # exception modules to retry
]Recipes
# Fast, bounded
retry: [max_attempts: 3, backoff: :linear, base: 50]
# Resilient HTTP default
retry: [backoff: :exponential, base: 100, cap: 2_000,
max_attempts: 5, jitter: true]
# Retry a transient exception
retry: [retry_exceptions: [MyApp.TransientError]]
# Retry on the result of an unmodified function
retry: [retry_on: &match?({:error, %{status: 500}}, &1)]
# Deliberately unbounded (silences the start/2 warning)
retry: [max_attempts: :infinity, cap: 30_000]Always set a bound
Setting neither :max_attempts nor :expiry retries forever — the circuit
breaker does not reliably stop it. start/2 warns when you do.
Error handling
Returned by call
case MyApp.Api.fetch(id) do
{:ok, v} -> v
{:error, %ExternalService.RetriesExhausted{}} -> degrade()
{:error, %ExternalService.CircuitBreakerOpen{}} -> degrade()
{:error, %ExternalService.RateLimited{}} -> shed()
{:error, reason} -> {:error, reason} # your own error
endRaised by call!
rescue
e in [ExternalService.RetriesExhausted,
ExternalService.CircuitBreakerOpen] ->
send_resp(conn, 503, "")Telemetry events
Events
[:external_service, :call, :start]
[:external_service, :call, :stop]
[:external_service, :call, :exception]
[:external_service, :call, :retry]
[:external_service, :circuit_breaker, :blown]
[:external_service, :rate_limit, :sleep]
[:external_service, :concurrency, :rejected]
[:external_service, :concurrency, :waited]Attach
:telemetry.attach_many(
"es-handler",
[[:external_service, :call, :retry],
[:external_service, :circuit_breaker, :blown]],
&MyApp.Telemetry.handle/4,
nil
)Testing
Make a service inert
# test.exs child spec override
{MyApp.Api,
circuit_breaker: [tolerate: :infinity],
rate_limit: [limit: :infinity],
retry: [max_attempts: 1]}Both :infinity keys remove state, so nothing leaks between tests.
Isolate per test
setup context do
service = :"#{context.module}.#{context.test}"
on_exit(fn -> ExternalService.stop(service) end)
{:ok, service: service}
endOr reset shared state
setup do
MyApp.Api.reset_all() # breaker + limiter
:ok
endreset/0 clears only the breaker.
Force the failure paths
# open the breaker (tolerate + 1 melts)
Enum.each(0..tolerate, fn _ ->
ExternalService.CircuitBreaker.melt(svc)
end)
# spend the rate limit budget
ExternalService.RateLimiter.request(svc)
# fail a fraction of calls
circuit_breaker: [fault_injection: 0.25]Keep it off the clock
retry: [max_attempts: 3, base: 0] # instant retries
rate_limit: [wait: false] # shed, don't waitNever sleep_function: fn _ -> :ok end — it busy-waits.