ExternalService handles all retry and circuit breaker logic for calls to external services.
The recommended way to use it is the declarative module-based front door,
use ExternalService (see __using__/1), which lets you configure a service's
circuit breaker, rate limiting, and default retry options in one place. The
functional API (start/2, call/3, and friends) is the lower-level foundation
it is built on, and can be used directly when you need more control.
Telemetry
ExternalService emits :telemetry events so
that calls to external services can be observed and instrumented. Attach a
handler to any of the events below to forward them to your metrics or logging
backend.
All events carry a :service key in their metadata, which is the name of the
service the event relates to.
[:external_service, :call, :start]- emitted when a guarded call begins.- Measurements:
:system_time,:monotonic_time - Metadata:
:service
- Measurements:
[:external_service, :call, :stop]- emitted when a guarded call completes (including when it returns an error such asExternalService.RetriesExhaustedorExternalService.CircuitBreakerOpen).- Measurements:
:duration,:monotonic_time - Metadata:
:service,:result(the value returned from the call)
- Measurements:
[:external_service, :call, :exception]- emitted when a guarded call raises (for example a non-retriable exception, orcall!/3raising on an open circuit breaker or exhausted retries).- Measurements:
:duration,:monotonic_time - Metadata:
:service,:kind,:reason,:stacktrace
- Measurements:
[:external_service, :call, :retry]- emitted each time a call's function fails in a way that melts the circuit breaker: it returned:retry/{:retry, reason}, it returned a result matched by the:retry_onpredicate, or it raised an exception listed in the:retry_exceptionsretry option. Exceptions not listed in:retry_exceptionsneither melt the breaker nor emit this event. Whether another attempt is actually made depends on the retry options.- Measurements:
:count(always1) - Metadata:
:service,:reason
- Measurements:
[:external_service, :circuit_breaker, :blown]- emitted when a call is rejected because the service's circuit breaker is blown.- Measurements:
:count(always1) - Metadata:
:service
- Measurements:
[:external_service, :rate_limit, :sleep]- emitted when a call is throttled and put to sleep to stay within the configured rate limit.- Measurements:
:sleep_time(milliseconds) - Metadata:
:service
- Measurements:
Summary
Types
Error returned when a service's circuit breaker is open
Union type representing all the possible error return values
Error returned when a call is throttled beyond the rate limit :wait budget
Error returned when the allowable number of retries has been exceeded
A term that uniquely identifies an external service.
Error returned when a service has not been started with ExternalService.start/2
The sleep function called when a call is throttled to stay within the rate limit.
Functions
Defines a module-based gateway to an external service.
Returns true only if every service in fuse_names is available?/1.
Returns true if the service is currently available, meaning its circuit
breaker is not blown.
Returns true if the service's circuit breaker is currently blown.
Executes a function for the given service, handling retry and circuit breaker logic.
Like call/3, but raises an exception if retries are exhausted or the circuit breaker is open.
Asynchronous version of ExternalService.call.
Parallel, streaming version of ExternalService.call.
Parallel, streaming version of ExternalService.call.
Parallel, streaming version of ExternalService.call.
Returns true if a call to the service would currently be throttled by its
rate limit.
Resets the circuit breaker for the given service.
Resets every stateful mechanism for the given service: the circuit breaker, the rate limiter, and the concurrency limit.
Returns true if service has no concurrency slot free right now.
Initializes the circuit breaker (and optional rate limiting and default retry options) for a specific service.
Stops the fuse for a specific service.
Types
@type circuit_breaker_open() :: {:error, ExternalService.CircuitBreakerOpen.t()}
Error returned when a service's circuit breaker is open
@type error() :: retries_exhausted() | circuit_breaker_open() | service_not_started() | rate_limited()
Union type representing all the possible error return values
@type options() :: keyword()
Options for start/2. See the schema documented under start/2.
@type rate_limited() :: {:error, ExternalService.RateLimited.t()}
Error returned when a call is throttled beyond the rate limit :wait budget
@type retriable_function() :: (-> retriable_function_result())
@type retries_exhausted() :: {:error, ExternalService.RetriesExhausted.t()}
Error returned when the allowable number of retries has been exceeded
@type service() :: term()
A term that uniquely identifies an external service.
@type service_not_started() :: {:error, ExternalService.ServiceNotStarted.t()}
Error returned when a service has not been started with ExternalService.start/2
@type sleep_function() :: (non_neg_integer() -> any())
The sleep function called when a call is throttled to stay within the rate limit.
Blocking the calling process for an extended period is sometimes undesirable
(for example in tests), so this can be overridden. Defaults to Process.sleep/1.
Functions
Defines a module-based gateway to an external service.
use ExternalService generates a small, declarative wrapper around the
functional API. Configure the circuit breaker, rate limiting, and default
retry options at the module level, then start the module under a supervisor
and call the service through the generated call/1 (and friends).
Example
defmodule MyApp.Stripe 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 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
endStart it under your supervision tree:
children = [MyApp.Stripe]
Supervisor.start_link(children, strategy: :one_for_one)Configuration can be overridden when starting (useful in tests), and is deep
merged with the options given to use:
{MyApp.Stripe, circuit_breaker: [tolerate: 1], retry: [max_attempts: 1]}Options
Accepts the same options as start/2 (:circuit_breaker, :rate_limit,
:retry, :sleep_function), plus:
:name- the term that identifies the service. Defaults to the module name.
Generated functions
call/1,call/2,call!/1,call!/2call_async/1,call_async/2call_async_stream/2,call_async_stream/3,call_async_stream/4available?/0,blown?/0,saturated?/0,reset/0,reset_all/0child_spec/1,start_link/1
Returns true only if every service in fuse_names is available?/1.
Useful for guarding work that depends on several services at once.
Examples
if ExternalService.all_available?([:payments, :inventory]) do
place_order(order)
else
{:error, :service_unavailable}
end
Returns true if the service is currently available, meaning its circuit
breaker is not blown.
This is useful for the circuit breaker pattern: before kicking off expensive work, you can check whether the services it depends on are available and bail out early (returning a degraded response) if any of them are not.
A service that has not been started (see start/2) is reported as not
available. Note that availability can change between this check and a
subsequent call/3, so this is a best-effort signal, not a guarantee.
Examples
if ExternalService.available?(:payments) do
charge(order)
else
{:error, :payments_unavailable}
end
Returns true if the service's circuit breaker is currently blown.
A service that has not been started (see start/2) is not considered blown;
use available?/1 if you want "ready to use" semantics that also account for
services that were never started.
@spec call(service(), retriable_function()) :: error() | (function_result :: any())
Executes a function for the given service, handling retry and circuit breaker logic.
ExternalService.start/2 must be called for the service before using call.
The provided function can indicate that a retry should be performed by returning the atom
:retry or a tuple of the form {:retry, reason}, where reason is any arbitrary term. Any
other result is considered successful, so the operation will not be retried and the result of
the function will be returned as the result of call.
For functions that were not written to return :retry/{:retry, reason}, the :retry_on retry
option takes a predicate that is run on the return value; when it returns a truthy value the call
is retried as though the function had returned {:retry, result} (the result becomes the retry
reason and the circuit breaker melts). An explicit :retry/{:retry, reason} return always
takes precedence over the predicate.
Raised exceptions are only retried if their type is listed in the :retry_exceptions retry option
(which defaults to []); otherwise they propagate to the caller untouched. An exception that is
not retried also does not melt the circuit breaker — :retry_exceptions governs both retrying
and whether a raised exception counts as a circuit-breaker failure.
retry_opts may be a ExternalService.RetryOptions.t/0 struct or a keyword list of retry
options. A keyword list is treated as per-call overrides: it is merged onto the service's
configured default retry options (from start/2), so it overrides only the keys it lists and
inherits the rest. A RetryOptions struct, being a complete set of options, replaces the
service defaults entirely. When omitted (the two-argument form call/2), the service's
configured defaults are used.
@spec call( service(), ExternalService.RetryOptions.t() | keyword(), retriable_function() ) :: error() | (function_result :: any())
@spec call!(service(), retriable_function()) :: function_result :: any() | no_return()
Like call/3, but raises an exception if retries are exhausted or the circuit breaker is open.
@spec call!( service(), ExternalService.RetryOptions.t() | keyword(), retriable_function() ) :: function_result :: any() | no_return()
@spec call_async(service(), retriable_function()) :: Task.t()
Asynchronous version of ExternalService.call.
Returns a Task that may be used to retrieve the result of the async call.
@spec call_async( service(), ExternalService.RetryOptions.t() | keyword(), retriable_function() ) :: Task.t()
@spec call_async_stream(Enumerable.t(), service(), (any() -> retriable_function_result())) :: Enumerable.t()
Parallel, streaming version of ExternalService.call.
See call_async_stream/5 for full documentation.
@spec call_async_stream( Enumerable.t(), service(), ExternalService.RetryOptions.t() | (async_opts :: list()), (any() -> retriable_function_result()) ) :: Enumerable.t()
Parallel, streaming version of ExternalService.call.
See call_async_stream/5 for full documentation.
@spec call_async_stream( Enumerable.t(), service(), ExternalService.RetryOptions.t() | keyword() | nil, async_opts :: list(), (any() -> retriable_function_result()) ) :: Enumerable.t()
Parallel, streaming version of ExternalService.call.
This function uses Elixir's built-in Task.async_stream/3 function and the description below is
taken from there.
Returns a stream that runs the given function function concurrently on each
item in enumerable.
Each enumerable item is passed as argument to the given function function
and processed by its own task. The tasks will be linked to the current
process, similarly to async/1.
Returns true if a call to the service would currently be throttled by its
rate limit.
This is a read: it consumes none of the service's budget, so it is safe to ask speculatively before committing to expensive work. A service with no rate limit configured is never rate limited.
As with available?/1, this is a best-effort signal — the answer can change
between the check and a subsequent call/3. Use
ExternalService.RateLimiter.peek/1 when you want to know how long the wait
would be rather than merely whether there is one.
Examples
if ExternalService.rate_limited?(:payments) do
{:error, :busy}
else
charge(order)
end
@spec reset(service()) :: :ok | {:error, :not_found}
Resets the circuit breaker for the given service.
After reset, the breaker will be closed with no recorded failures.
@spec reset_all(service()) :: :ok | {:error, :not_found}
Resets every stateful mechanism for the given service: the circuit breaker, the rate limiter, and the concurrency limit.
reset/1 closes the breaker only, and deliberately leaves the rate limiter
alone — clearing a limiter in production releases a burst at the service, which
is rarely what someone closing a breaker meant to do. This function is for when
you do want a clean slate.
Its main use is between tests. A service's state is global — it lives in
:persistent_term and :fuse, keyed on the service term — so a test that
trips the breaker or drains the rate limit budget leaves it that way for
whatever runs next:
setup do
ExternalService.reset_all(MyApp.Stripe)
:ok
endA service with no rate limit configured resets just the breaker. One that was
never started answers {:error, :not_found}, exactly as reset/1 does.
Note that this shares state rather than isolating it, so it does not make concurrent tests independent — see the Testing guide.
Returns true if service has no concurrency slot free right now.
Completes the trio with available?/1 (is the breaker closed?) and
rate_limited?/1 (would a call be throttled?). A service with no
:concurrency limit configured — including one that was never started — is
never saturated, since nothing is holding calls back.
Like the others this is a best-effort signal: a slot can be taken or released
between the check and a subsequent call, so it lets you bail out early rather
than replacing handling of ExternalService.ServiceSaturated from the call
itself.
Initializes the circuit breaker (and optional rate limiting and default retry options) for a specific service.
The service is a term that uniquely identifies an external service within the
scope of an application.
Options
:circuit_breaker(keyword/0) - Circuit breaker configuration. The default value is[].:tolerate- Number of failed attempts tolerated within the:withinwindow before the breaker opens. Every failing retry attempt melts the breaker, so a singlecall/3withmax_attempts: 5contributes up to 5 of them —:tolerateand:max_attemptscannot be tuned independently.:infinityinstalls no breaker at all: it never opens, holds no state, and cannot be combined with:fault_injection. The default value is10.:within(pos_integer/0) - Length of the failure-counting window, in milliseconds. The default value is10000.:reset(pos_integer/0) - Milliseconds to wait before the breaker resets (closes) after it has opened. The default value is60000.:fault_injection(float/0) - If set to a rate between0.0and1.0, randomly fails that fraction of calls. Intended for testing how dependents behave when this service is degraded.:backend- The circuit breaker implementation to use, either as a module or as a{module, options}tuple whose options are passed through to that backend. Defaults to the node-local:fuse-based breaker.
:rate_limit(keyword/0) - Optional rate-limiting configuration. Omit for no rate limiting.:limit- Required. Maximum number of calls allowed within each:perwindow.:infinityinstalls no limiter at all — calls pass straight through, exactly as if:rate_limithad been omitted. It is meant for overriding a configured limit (a child spec override cannot remove a key); to have no rate limiting in the first place, omit:rate_limit.:peris still required, and ignored.:per(pos_integer/0) - Required. Length of the rate-limiting window, in milliseconds.:wait- How long a throttled call may wait for the rate limit to admit it.:infinitywaits as long as it takes,falsenever waits, and an integer is a millisecond budget for the whole call. When the budget runs out the call is not made and anExternalService.RateLimitederror is returned (or raised bycall!/3). Leaving this unset waits like:infinitybut logs a warning atstart/2, because an unbounded wait converts sustained throttling into unbounded latency rather than shed load.:backend- The rate limiter implementation to use, either as a module or as a{module, options}tuple whose options are passed through to that backend. Defaults toExternalService.RateLimiter.Local. UseExternalService.RateLimiter.Hammerfor a limit shared across a cluster.
:concurrency(keyword/0) - Optional concurrency limit (the bulkhead pattern). Omit for no limit. SeeExternalService.Concurrency.:limit(pos_integer/0) - Required. Maximum number of calls allowed to be in flight against the service at once.:reclaim_after(pos_integer/0) - Required. Milliseconds after which a held slot is considered abandoned and may be reused. A slot is normally released as soon as the call finishes, but a caller killed from outside — including by the ordinary:shutdowna supervisor sends while draining — never runs its release. This bounds how long such a slot is lost. Required rather than defaulted: it must exceed the longest legitimate call, which depends on the timeout configured in your HTTP client. Setting it too low silently admits more than:limit.:wait- How long a call may wait for a slot before being shed.false(the default) sheds immediately; an integer is a millisecond budget. A short budget absorbs bursts without allowing a pile-up, since waiting callers hold no slot and are bounded by arrival rate times the budget. Unlike the rate limiter's:wait,:infinityis not accepted — an unbounded wait is the pile-up a concurrency limit exists to prevent. The default value isfalse.
:retry- Default retry options for the service, used bycall/2. SeeExternalService.RetryOptionsfor the available keys. The default value is[].:sleep_function(function of arity 1) - Overrides the function used to sleep while rate limited (defaults toProcess.sleep/1).
@spec stop(service()) :: :ok
Stops the fuse for a specific service.
Stopping is idempotent: it is safe to call on a service that was never started or has already been stopped.