All notable changes to this project, from version 1.0.0 onward, will be documented in this file.
The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
Unreleased
2.2.0 - 2026-07-30
This line makes ExternalService work correctly on more than one node. See the
new Distributed Elixir guide for the full picture.
The two halves of that problem are not the same kind of problem, and are not solved the same way. A node-local rate limit is a correctness bug — four nodes configured for 100 calls per second send up to 400, violating the quota you configured — so the fix is shared counters. A node-local circuit breaker is a defensible design rather than a bug, since a node with a bad network path should stop calling a service without taking the cluster down with it, so cross-node tripping is offered as an opt-in choice.
Added
Pluggable circuit breaker and rate limiter backends (issue #12, issue #13). Both
:circuit_breakerand:rate_limitaccept a:backendoption, given as a module or a{module, options}tuple whose options are passed through to that backend:use ExternalService, circuit_breaker: [backend: ExternalService.CircuitBreaker.Cluster], rate_limit: [limit: 100, per: 1_000, backend: {MyApp.Limiter, some: :option}]ExternalService.CircuitBreakerandExternalService.RateLimiterare now documented behaviours you can implement — five callbacks for a breaker, two for a limiter. Backends are stateless modules: theinstall/initcallback returns an opaque config term that is stored with the rest of the service state and handed back to every other callback, so a backend needs no process, supervisor, or registry of its own.Note that this exposes the breaker and limiter as behaviours, not as user-facing control APIs; the operations themselves remain internal (issue #26).
ExternalService.CircuitBreaker.Cluster, an opt-in circuit breaker that trips the whole cluster when any one node trips (issue #13). Each node keeps its own ordinary breaker; when one transitions from closed to open it sends a fire-and-forget:erpc.multicast/4to the other nodes, each of which trips its own breaker and then recovers on its own reset timer. There is no shared store, no distributed state, and no process or supervision tree for this library to run. A:nodesoption (a list, or a zero-arity function returning one; default&Node.list/0) narrows the broadcast. Read the module docs before enabling it: it trades isolation for convergence, and one bad node can trip the whole cluster.ExternalService.RateLimiter.Hammer, a rate limiter backend that meters against a Hammer module (issue #12). With a shared Hammer backend such ashammer_backend_redisevery node draws from the same counters, so the service sees the limit you configured rather than that limit multiplied by your node count. Hammer is not a dependency of this library — the backend callshit/3on the module you supply.rate_limit: [wait: ...]to bound how long a throttled call may block::infinity(the default, and the previous behavior), a millisecond budget for the whole call, orfalseto never wait. Previously a throttled call waited as long as the limiter required with no upper bound.ExternalService.RateLimited, returned bycall/3and raised bycall!/3when the:waitbudget runs out. The wrapped function is not called. It carries:context.retry_after(milliseconds until the call would have been admitted) and reportshttp_status/1of429. Being throttled is this library's own back-pressure rather than a failure of the external service, so it does not melt the circuit breaker and is not retried.A Distributed Elixir guide, plus rate limiting and circuit breaker guide sections and cheatsheet entries covering the above.
Changed
The default rate limiter is now a token bucket, and paces calls differently.
ExternalService.RateLimiter.Localreplaces theex_ratedfixed window. It admits a burst of exactly:limitand then paces the rest at one call per:per / :limit, refilling one call at a time.What you will notice: waiting out a full window no longer hands you a fresh full burst. The fixed window allowed
:limitcalls at the end of one window and another:limitat the start of the next, briefly sending twice your configured rate at the service — which could trip the provider's own limiter even though you had configured yours correctly. Smoothing that out is the point of the change, but it does mean bursty workloads are now paced where they previously were not.No configuration changes:
:limitand:permean what they did before. The new limiter keeps its counters in a single:atomicsslot per service, so it needs no owning process, and it is correct under concurrent access (a compare-and-exchange loop, rather than a lock or a best-effort counter).Rate limit sleeps are now as long as they need to be, and no longer. Backends report a real time-to-next-window, where
ex_ratedcould only be given thewindow / limitestimate this library computed for it. Expect the[:external_service, :rate_limit, :sleep]telemetry to report different (and more accurate) durations.
Removed
The
ex_rateddependency, which has had no release since December 2021. Rate limiting is now handled by the built-inExternalService.RateLimiter.Localor a backend of your choosing.If your own code called
ExRateddirectly — it was previously reaching you as a transitive dependency — add{:ex_rated, "~> 2.1"}to yourdeps. Nothing in theExternalServiceAPI changes.
2.1.0 - 2026-07-30
Added
ExternalService.Decorator: decorator-based annotations for marking a function as an external call (issue #28).use ExternalService.Decoratorbrings@decorate external_call(service)(and a raisingexternal_call!) into scope, wrapping the function body inExternalService.call/2(orcall/3when passed per-call retry options) instead of writingcall fn -> ... endby hand. Built on thedecoratorlibrary.ExternalService.Flow: process an enumerable (or an existingFlow) through guardedExternalServicecalls as a stage of aFlowpipeline (issue #27).ExternalService.Flow.map/3,4,5returns aFlow, reusingcall/3per element so retries, the circuit breaker, rate limiting, telemetry, and the structured-error returns all apply (errors arrive as{:error, ...}elements; results are unordered).:flowis an optional dependency — the module is only compiled when you add it. For simple ordered parallel maps,call_async_stream/5remains the right tool.
2.0.0 - 2026-06-23
The 2.0 line modernizes the project and introduces breaking changes. See the migration guide for a step-by-step upgrade from 1.x.
Added
- Documentation overhaul: a set of guides (Getting Started, the module front door, circuit breakers, retries, rate limiting, error handling, telemetry), a cheatsheet, and a step-by-step migration guide, all published on HexDocs.
- Introspection for circuit breaker state (issue #5):
ExternalService.available?/1,ExternalService.blown?/1, andExternalService.all_available?/1, plusavailable?/0andblown?/0on modules usingExternalService.Gateway. :telemetryevents for guarded calls:[:external_service, :call, :start | :stop | :exception](a span around each call),[:external_service, :call, :retry],[:external_service, :circuit_breaker, :blown], and[:external_service, :rate_limit, :sleep]. See theExternalServicemodule docs for measurements and metadata.RetryOptions.max_attemptsto bound the total number of attempts (initial plus retries), complementing the existing time-based:expiry.RetryOptions.jitterto control random jitter on retry delays (truefor +/- 10%, or a float proportion such as0.25).RetryOptions.retry_onaccepts a predicate over the return value (an arity-1 function), so retries can be driven from a function that does not itself return:retry/{:retry, reason}(the common case when adapting an existing client function). When the predicate returns a truthy value the call is retried — the result becomes the retry reason and the circuit breaker melts — exactly like an explicit:retryreturn, which still takes precedence (issue #29).- Declarative module front door:
use ExternalServicegenerates a small wrapper (call/1,2,call!/1,2, async/stream variants,available?/0,blown?/0,reset/0,child_spec/1,start_link/1) around a service configured with validated:circuit_breaker/:rate_limit/:retryoptions. - A service now remembers the default retry options given to
start/2; the two-argumentcall/2(andcall!/2,call_async/2) use that default. - Option validation via NimbleOptions for
start/2andRetryOptions, with the accepted options rendered into the docs. - Structured error types (built on Errata):
ExternalService.RetriesExhausted,ExternalService.CircuitBreakerOpen, andExternalService.ServiceNotStarted. Each is an exception struct carrying a:context(always including the:service), anhttp_status/1, and JSON encoding, so the same value can be returned fromcall/3or raised bycall!/3.
Changed (breaking)
Error representation overhauled.
call/3now returns structured error structs instead of nested tuples, andcall!/3raises the same structs:Before (1.x) After (2.0) {:error, {:retries_exhausted, reason}}{:error, %ExternalService.RetriesExhausted{context: %{service: name, reason: reason}}}{:error, {:fuse_blown, name}}{:error, %ExternalService.CircuitBreakerOpen{context: %{service: name}}}{:error, {:fuse_not_found, name}}{:error, %ExternalService.ServiceNotStarted{context: %{service: name}}}raise ExternalService.RetriesExhaustedErrorraise ExternalService.RetriesExhaustedraise ExternalService.FuseBlownErrorraise ExternalService.CircuitBreakerOpenraise ExternalService.FuseNotFoundErrorraise ExternalService.ServiceNotStartedResults returned directly by the wrapped function (including its own
{:error, reason}values) are unchanged. See the migration guide for the full mapping.Configuration and terminology overhauled to drop the leaked "fuse" wording:
start/2now takescircuit_breaker: [tolerate:, within:, reset:, fault_injection:]andrate_limit: [limit:, per:](and an optionalretry:) instead offuse_strategy: {:standard, max, window}/fuse_refresh:and therate_limit: {limit, window}tuple. Options are validated by NimbleOptions.- The
fuse_nameargument/type is nowservice. reset_fuse/1is nowreset/1.
Retry options reshaped (
ExternalService.RetryOptions):backoffis now:exponential/:linearwith separate:baseand:factor, instead of{:exponential, delay}/{:linear, delay, factor}.randomizeis nowjitter.rescue_onlyis nowretry_exceptions, and defaults to[]— raised exceptions are no longer retried by default (issue #7). List exception modules in:retry_exceptionsto retry on them.:retry_exceptionsnow also governs the circuit breaker: an exception that is not retried no longer melts the breaker (it propagates untouched), so a raised exception counts as a circuit-breaker failure only when its type is in:retry_exceptions. Explicit:retry/{:retry, reason}return values always melt the breaker.call/3andcall!/3now also accept a keyword list of retry options. A keyword list is treated as per-call overrides: it is merged onto the service's configured:retrydefaults (overriding only the keys it lists and inheriting the rest). A%RetryOptions{}struct still replaces the defaults entirely.
use ExternalService.Gatewayis deprecated in favor ofuse ExternalService. It still works (emitting a deprecation warning) and keeps theexternal_call/*andreset_fuse/0names as aliases, but uses the same new option shape asuse ExternalService— the oldfuse: [...]options are no longer supported.
Removed (breaking)
- The
ExternalService.RetriesExhaustedError,ExternalService.FuseBlownError, andExternalService.FuseNotFoundErrorexception modules, replaced by the structured error types above.
Fixed
ExternalService.Gatewaynow applies thefuse: [strategy:, refresh:]options it was configured with. Previously these keys did not match the:fuse_strategy/:fuse_refreshkeys thatExternalService.start/2reads, so every gateway silently ran on the default circuit-breaker configuration.- Added a regression test for the
:fault_injectionstrategy (issue #4); the:fuse_monitorcrash no longer reproduces on fuse 2.5. - Rate limiting now works for a service whose name is any term, not only an atom
or binary. The rate-limit bucket name is now derived with
inspect/1; previously it usedModule.concat/2, which raised for names such as tuples (circuit breaker and retries already accepted any term).
Changed
- Raise the minimum Elixir requirement to
~> 1.15. - Modernize the build: refreshed dependency versions, added
nimble_optionsandtelemetry, ExDoc/Dialyxir bumps, GitHub Actions CI (test matrix, quality, and Dialyzer jobs), and Hex package/docs metadata cleanup. - Store per-service state in
:persistent_terminstead of an unsupervisedAgent, removing a process that could crash and was never linked to a supervisor.ExternalService.stop/1now accepts any term as a fuse name (matchingstart/2), not only atoms, and is idempotent — it is safe to call on a service that was never started or has already been stopped.
1.1.4 - 2024-01-04
Fixed
- Replace use of deprecated
System.stacktrace/0with__STACKTRACE__/0(PR #17 from @iperks)
[1.1.3] - 2023-05-12
Changed
- Update to retry 0.18.0
- Update ex_rated to 2.1
1.1.2 - 2021-09-30
Changed
- Make sleep function configurable (PR #11 from @doorgan)
1.1.1 - 2021-09-17
Changed
- Update to fuse 2.5
- Update ex_rated to 2.0
1.1.0 - 2021-09-17
Added
Changed
- Allow any term as fuse name (PR #10 from @doorgan)
1.0.1 - 2020-06-08
Added
- Add ability to reset fuses
- Add documentation for initialization and configuration of gateway modules
1.0.0 - 2020-06-05
Added
- Add new ExternalService.Gateway module for module-based service gateways.
- Add this changelog...better late than never!