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.7.0 - 2026-08-18
Added
:retry_exceptionsaccepts a predicate, not just a list of modules (issue #63). A module list settles retriability by type, which is the wrong grain when the same exception is transient in one instance and permanent in another — an HTTP client that raises one error struct for every status, say. Pass a predicate and it is run on the exception itself:retry: [ retry_exceptions: fn %MyApp.HTTPError{status: status} -> status >= 500 _other -> false end ]A truthy return retries and melts the circuit breaker; anything else propagates the exception untouched, exactly as an unlisted module would. The predicate replaces the list rather than supplementing it, so fold any module checks you still want into it.
This is what makes the raised half of an Errata integration expressible — an Errata error type can decide from its own
:reasonor:context, and a module list cannot ask it:retry_exceptions: fn error -> Errata.is_error(error) and Errata.retryable?(error) endThe structured error types now declare their retryability (issue #62). Errata 1.5.0 added a retryability classification, and
Errata.retryable?/1exposes it for any Errata error. Left to the default for infrastructure errors, all five ofExternalService's error types would have answeredtrue— includingServiceNotStarted, where retrying can never help. Each type now says so for itself:Error retryable?/1CircuitBreakerOpentrueRateLimitedtrueServiceSaturatedtrueRetriesExhaustedfalseServiceNotStartedfalseThe three retryable ones share a shape: the wrapped function never ran, and the condition clears on its own.
ServiceNotStartedis a configuration mistake — the same reasoning that gives it a500rather than a503.RetriesExhaustedis the one worth reading twice. It is not retryable because retrying is exactly what has already failed, and an outer loop branching onretryable?/1would spin on it. Errata's classification carries no notion of when, so this means "not worth retrying now" — re-attempting the work at a coarser layer, such as a background job re-enqueuing itself minutes later, is still perfectly reasonable.Note that this describes
ExternalService's own errors only. What your wrapped function returns or raises still passes through untouched;ExternalServicedoes not consultErrata.retryable?/1when deciding whether to retry your function.An exception retry reason is chained as the error's
:cause. When a call exhausts its retries with a reason that is an exception — any Errata error included — that value is now set asRetriesExhausted's:causeas well as its:context.reason.Errata.cause/1andErrata.root_cause/1reach the underlying failure, andErrata.format_chain/1prints it:ExternalService.RetriesExhausted: exhausted all retries while calling the external service Caused by: MyApp.UpstreamTimeout: upstream timed outA reason that is not an exception is left in
:context.reasonalone, with no:causeset.A guide for applications that use Errata themselves (Using Errata in Your Application). Covers letting your own error types drive retries through the
:retry_onand:retry_exceptionspredicates, the distinction between an error being retryable and a call being safe to repeat, howRetriesExhaustedchains your error as its:cause, and the sharp edges —require Erratafor the guard macro, guardingErrata.retryable?/1against non-Errata values, and aggregates being retryable only when every member is.It also documents something that applies well beyond Errata: predicates cannot be given to
use ExternalServiceas anonymous functions, because the options are stored in a module attribute. A remote capture (&MyApp.Retry.retryable_error?/1) works; the Retries guide now says so too.
Fixed
A retry predicate that fails no longer changes the outcome of the call (issue #67). A
:retry_exceptionspredicate runs on a path that is already failing, so a bug in it replaced the exception it had been called to classify — the caller got the predicate's error instead of its own, and nothing was retried. The:retry_onpredicate was worse: because it runs inside the same rescue, its exception was itself evaluated against:retry_exceptions, so a matching:retry_exceptionswould re-run an already-successful function for every remaining attempt.A predicate that fails — raising, throwing, or exiting rather than answering — is now treated as no match. The call's own result or exception is left exactly as it was, nothing is retried, the circuit breaker is untouched, and a warning naming the option, the service and the predicate's own failure is logged so the bug is findable:
[warning] The :retry_exceptions predicate for :my_service did not return, so the call was treated as not retriable and its own result or exception was left untouched. ... ** (RuntimeError) predicate blew up lib/my_app/retry.ex:12: MyApp.Retry.transient?/1Not retrying is the safe reading: retrying is the consequential interpretation, and a predicate that just crashed has not authorized it.
This is easy to hit by accident with Errata:
Errata.is_error/1is a guard macro, so a predicate module that forgetsrequire Errataraises, andErrata.retryable?/1raises on any value that is not an Errata error.A retried exception now keeps its original stacktrace. When retries ran out while retrying an exception, it was re-raised with
raise/1, which generates a fresh stacktrace — so the trace handed to the caller pointed intoExternalService's own retry loop rather than at the code that raised:** (RuntimeError) KABOOM! (external_service) lib/external_service.ex:815: ExternalService.call_with_retry/4The exception is now re-raised with the stacktrace captured where it was raised. For a library whose failure mode is "your call failed N times", the old behaviour discarded exactly the information you needed.
Changed
- The
:erratadependency requirement is now~> 1.5(was~> 1.3).
2.6.0 - 2026-08-05
Added
A per-service concurrency limit — the bulkhead pattern (issue #49).
concurrency: [limit: 25, reclaim_after: :timer.seconds(30)]caps how many calls may be in flight against a service at once. Over the limit a call is not dropped: it returns the newExternalService.ServiceSaturatederror to its caller, which is free to enqueue the work, serve something stale, or answer- There is no cooldown — unlike the circuit breaker, a slot is available again the instant the call holding it finishes, so recovery is continuous.
This closes the gap that opens when a service degrades rather than fails. The breaker counts failures, so slow-but-successful calls are invisible to it; the rate limiter counts starts, not concurrency, so
limit: 100, per: 1_000against a service that slows to 10 seconds per call leaves roughly a thousand processes parked in the same call, each holding a connection.Saturation is your own backpressure rather than the service's failure, so it does not melt the circuit breaker and is not retried — exactly like
ExternalService.RateLimited.ServiceSaturatedmaps to503rather than429for the same reason: it is your application shedding load, not the external service refusing you.State is an
:atomicsarray with one slot per permit — no process, supervisor, or registry, the same design asExternalService.RateLimiter.Local, at roughly 0.4µs for an uncontended acquire and release. Slots are taken per attempt and inside the rate limiter, so a call sitting in backoff or sleeping on a:waitbudget holds no capacity.:reclaim_afterbounds how long a slot may be held before it is reused. A slot is released whenever the call finishes, raises, throws, or exits — but not when the calling process is killed from outside, because an exit signal does not runafterblocks. That includes the ordinary:shutdowna supervisor sends while draining, so it is not an edge case. Without expiry each such caller would burn a slot permanently and the service would ratchet toward wedged;:reclaim_afterbounds the damage to one slot for one window. It is required rather than defaulted because it must exceed the longest legitimate call, which depends on a client timeout the library cannot see.An optional
:waitbudget on:concurrencyabsorbs short bursts instead of shedding them.concurrency: [limit: 25, reclaim_after: 30_000, wait: 50]parks a caller for up to 50ms waiting for a slot before returningServiceSaturated. Waiting callers hold no slot and no connection, so the number parked is bounded by arrival rate times the budget — smoothing without reintroducing the pile-up a concurrency limit exists to prevent. Defaults tofalse(shed immediately).Unlike the rate limiter's
:wait,:infinityis not accepted: sleeping until a quota refills is bounded by the quota, but a slot only frees when another call finishes, so an unbounded wait is the pile-up itself.start/2raises with an explanation rather than a bare type error, since anyone reaching for it is coming from the rate limiter where:infinityis often correct.ExternalService.saturated?/1(with a generatedsaturated?/0), plusExternalService.Concurrency.in_flight/1andlimit/1, completing the trio withavailable?/1andrate_limited?/1.reset_all/1frees every slot.[:external_service, :concurrency, :rejected]and[:external_service, :concurrency, :waited]telemetry, and a new Concurrency Limiting guide. The guide documents what a rejection actually means — the call is handed back to its caller, not dropped — that there is no cooldown, and the measured shed rate against offered load (0% below capacity, 12% at capacity, 54% at twice capacity).
Changed
- Documented that
ExternalServiceimposes no timeout (issue #44). The breaker protects against a service that fails, not one that hangs: a blocking function blockscall/3, melts nothing, and trips no breaker — measured withtolerate: 1, a slow in-flight call leaves the service reportingavailable?: true. A new When the service hangs section in the circuit breaker guide says so plainly, shows where the timeout belongs (the client's receive and pool-checkout timeouts), and explains why running attempts in aTaskwould cost a process on the hot path without reliably cancelling anything. - Corrected what
:expirybounds. It was documented as a time budget for retries, which reads like a wall-clock bound on the call. It is evaluated between attempts, so it bounds when the next attempt starts and never how long the current one runs. Measured withmax_attempts: 4, expiry: 100against a function sleeping 300ms per attempt: 2 attempts, 706ms total — seven times the budget. A function that never returns is never bounded by it at all. Both measurements are now pinned by tests. - The circuit breaker guide names what the library does not bound — attempt duration and in-flight concurrency — and points at where each belongs. The rate limiter bounds how often calls start, not how many are running.
2.5.0 - 2026-08-05
Added
circuit_breaker: [tolerate: :infinity]installs no breaker at all (issue #55). It never opens, ignores melts, and holds no state. Useful in production for a service where opening the breaker is worse than the failures it would prevent, and in tests because a breaker with no state cannot leak between them. Rejected in combination with:fault_injection, which exists to open the breaker — the contradiction raises atstart/2rather than letting either option silently win.rate_limit: [limit: :infinity]installs no limiter at all. Calls pass straight through, exactly as if:rate_limithad been omitted. It exists for the case where omitting is not possible: child spec overrides are deep merged, so they can replace a key but never remove one.Together these are the answer to #55's "first-class test mode" question. Both are exact where
tolerate: 1_000_000was only large, and both are meaningful outside tests, so neither is API whose only purpose is switching the library off. The Testing guide now shows the combination, and says plainly that a service made inert is not a service being tested.ExternalService.RateLimiter.reset/1discards a service's recorded rate limit usage, and theExternalService.RateLimiterbehaviour gained a correspondingExternalService.RateLimiter.reset/2callback. The control API was asymmetric without it: the circuit breaker could be asked, melted, and reset, but a drained rate limit budget could not be cleared at all.ExternalService.reset_all/1clears every stateful mechanism for a service — the circuit breaker and the rate limiter — with areset_all/0counterpart generated byuse ExternalService.reset/1still resets only the breaker, deliberately: clearing a limiter in production releases a burst at the service, which is rarely what someone closing a breaker intended.reset_all/1is what a testsetupblock wants.
2.4.0 - 2026-08-05
Added
ExternalService.start/2now warns when a service configures no retry bound (issue #43). Retry options that set neither:max_attemptsnor:expiryretry forever, and the circuit breaker does not reliably stop them: exponential backoff eventually spaces attempts further apart than the breaker's:withinwindow, so failures stop accumulating fast enough to reach:tolerate. This is not a pathological corner — a fully default breaker withretry: [base: 100]never opens, and the call never returns. The library's own documentation has always advised against this configuration; now the advice reaches the place the mistake is made.:max_attemptsand:expiryaccept:infinity. It behaves exactly like leaving the bound unset, but states the intent explicitly and silences the new warning — for background work that really should retry until it succeeds, or for a service whose call sites each supply their own bound.ExternalService.start/2now warns when a rate limited service sets no:waitbudget (issue #47). A throttled call sleeps the calling process until the limiter admits it, which is correct for background work and wrong in a request path, where it converts load into latency and process growth instead of a fast 429. The warning fires only for services that configure:rate_limit.wait: :infinitystates the unbounded intent explicitly and silences it.A Testing guide (issue #45). Covers the thing an adopter hits first and the guides never addressed: service state is global — it lives in
:persistent_termand:fusekeyed on the service term — so nothing is torn down between tests andasync: truetests sharing a service share one breaker and one rate-limit bucket. Also covers keeping tests off the clock, driving the breaker and limiter directly to reach failure paths, and asserting on telemetry. Every example is executed as part of this library's suite (test/testing_guide_examples_test.exs), so the guide cannot drift from the API.
Changed
- The
:max_attemptsdocumentation no longer describes the circuit breaker as a bound on retries, because it isn't one in the general case (see above). :tolerateis now documented as counting failed attempts, not failed calls (issue #46). Every failing retry attempt melts the breaker, so:tolerateand:max_attemptscannot be tuned independently: atolerate: 10breaker paired withmax_attempts: 5opens during the third failing call, not the tenth. The circuit breaker guide now carries the measured numbers and the arithmetic, and the retries guide cross-references it — it is the same coupling seen from the other side.:waitno longer carries a documented default of:infinity. Runtime behavior is unchanged — an unset:waitstill waits as long as the limiter requires — but it is now distinguishable from an explicit:infinity, which is what letsstart/2warn about the former only.
Deprecated
- Leaving both retry bounds unset is on the path to becoming an error. A future
3.0 is expected to give
:max_attemptsa finite default;:infinityis the forward-compatible way to keep unbounded behavior. - Leaving
:waitunset is likewise on the path to changing meaning. 3.0 is expected to default it to a finite,:per-derived budget;wait: :infinityis the forward-compatible way to keep waiting indefinitely.
Fixed
- The
:sleep_functiondocumentation no longer recommends a no-op for tests.sleep_function: fn _ms -> :ok endwas presented as the way to avoid real delays under a rate limit. It does not avoid them: the limiter is asked again immediately, still says wait, and the loop spins until real time has passed. Measured atlimit: 1, per: 2_000, the throttled call still took 2000ms and invoked the no-op 2,075,418 times — the same wall clock, with a core burned.:sleep_functionis documented as an instrumentation hook, and the Testing guide points atwait: falsefor keeping rate limited tests fast. guides/is now included in the Hex package (issue #42). The README links into the guides eight times, and hex.pm renders the README out of the package tarball — which did not contain them, so every one of those links 404'd on the package page. HexDocs was unaffected, since ExDoc reads the guides from the working directory at doc-build time and rewrites the links.
2.3.0 - 2026-07-31
Added
- A public control API for the circuit breaker and rate limiter
(issue #26), for the
cases that fall outside a guarded
call/3. 2.2.0 madeExternalService.CircuitBreakerandExternalService.RateLimiterpublic as behaviours; this makes them usable directly.ExternalService.CircuitBreaker.melt/1records a failure the library never saw — a dropped streaming connection, a webhook that never arrived, a call made through a different client. It counts toward the service's:tolerateexactly as an in-call failure does, so enough melts open the breaker (and, with the cluster backend, open it across the cluster). The mirror image ofreset/1.ExternalService.CircuitBreaker.ask/1reports:ok,:blown, or:not_started— the three-valued form ofavailable?/1andblown?/1.ExternalService.CircuitBreaker.reset/1, the same operationExternalService.reset/1performs.ExternalService.RateLimiter.request/1spends one call's worth of budget without running anything, for traffic that reaches the service by some path other thancall/3. It honors the service's:waitsetting and returnsExternalService.RateLimitedif that budget runs out.
- Rate limit introspection.
ExternalService.rate_limited?/1reports whether a call would currently be throttled, andExternalService.RateLimiter.peek/1reports how long the wait would be. Both are reads that consume nothing, so they are safe to ask speculatively before committing to expensive work — symmetric withavailable?/1for the circuit breaker.
Changed
The
ExternalService.RateLimiterbehaviour gained apeek/2callback. Backends must answer the same way ascheck/2without consuming anything. Both shipped backends implement it;Localreads its atomics slot without the compare-and-exchange, andHammerassembles the answer fromget/2andexpires_at/2, since Hammer'shit/3both checks and consumes.This is a breaking change for anyone who wrote a rate limiter backend against 2.2.0. It is being made immediately after that release, while no third-party backends exist, precisely so that it does not have to be made later.
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!