Per-exchange circuit breakers using the :fuse Erlang library.
Prevents cascade failures when exchanges are down. Each exchange has isolated state — binance down does not affect bybit.
How It Works
- Each registered exchange uses its generated module atom as the fuse name
- Fuses are installed lazily on first request
- After N failures within M milliseconds, the circuit opens
- Opened circuits reject requests immediately (fast fail)
- After reset timeout, circuit closes and allows requests again
What Triggers the Circuit
Melt decisions flow from the Phase 13 retry classification carried on a
normalized Bourse.Error (the :network and :server_busy buckets), with a
raw HTTP 5xx / transport fallback so transport-level failures still trip.
| Response | Melts? | Reason |
|---|---|---|
| HTTP 500+ | Yes | Server error |
Timeouts (transport or body :network) | Yes | Server unresponsive |
| Connection refused | Yes | Server unavailable |
Body-level exchange_not_available (:server_busy) | Yes | Exchange down/maintenance |
HTTP 429 / :rate_limit | No | Handled by rate limiter |
HTTP 4xx / :auth / :non_retryable | No | Client error, not server issue |
Configuration
config :bourse, :circuit_breaker,
enabled: true,
max_failures: 5,
window_ms: 10_000,
reset_ms: 15_000
Summary
Functions
Returns status of all installed circuit breakers.
Checks if requests are allowed for an exchange.
Returns circuit breaker configuration.
Records a failed request. Enough melts within the window opens the circuit.
Records the result of a request using should_melt?/1 logic.
Records a successful request. Success prevents further melts.
Resets a circuit breaker for an exchange.
Resets a circuit breaker, raising on error.
Determines if a response should trip the circuit breaker.
Returns the status of a circuit breaker for an exchange.
Functions
@spec all_statuses() :: %{required(String.t()) => :ok | :blown}
Returns status of all installed circuit breakers.
@spec check(String.t()) :: :ok | :blown
Checks if requests are allowed for an exchange.
Installs the fuse lazily if not already installed.
@spec config() :: %{ enabled: boolean(), max_failures: pos_integer(), window_ms: pos_integer(), reset_ms: pos_integer() }
Returns circuit breaker configuration.
@spec record_failure(String.t()) :: :ok
Records a failed request. Enough melts within the window opens the circuit.
Records the result of a request using should_melt?/1 logic.
Accepts either the raw result from Req ({:ok, %Req.Response{}} or
{:error, reason}) or the normalized Bourse.Error outcome returned by
Bourse.HTTP.
@spec record_success(String.t()) :: :ok
Records a successful request. Success prevents further melts.
@spec reset(String.t()) :: :ok | {:error, :not_found}
Resets a circuit breaker for an exchange.
@spec reset!(String.t()) :: :ok
Resets a circuit breaker, raising on error.
Determines if a response should trip the circuit breaker.
Accepts both raw Req results and normalized {:error, %Bourse.Error{}}
outcomes. For a normalized error the decision flows from the Phase 13 retry
classification: :network and :server_busy melt, everything else
(:rate_limit, :auth, :non_retryable, unclassified) does not — with a
raw HTTP 5xx fallback so server errors melt regardless of body classification.
Melts on: HTTP 500+, transport errors, :network/:server_busy errors.
Does NOT melt on: HTTP 429, HTTP 4xx, :auth/:non_retryable errors, successes.
@spec status(String.t()) :: :ok | :blown | :not_installed
Returns the status of a circuit breaker for an exchange.
:ok— circuit closed, requests allowed:blown— circuit open, requests rejected:not_installed— no fuse yet (no requests made)