cover/Elixir.WalletIntegrations.CircuitBreaker.html

1 defmodule WalletIntegrations.CircuitBreaker do
2 @moduledoc """
3 ETS-backed per-provider-operation circuit breaker (ADR 0008).
4
5 States:
6 - `:closed` — normal operation; failures are counted.
7 - `:open` — requests are rejected immediately; opened after failure threshold.
8 - `:half_open` — one probe request allowed; success closes, failure reopens.
9
10 Configuration (via Application.get_env or module defaults):
11 - `failure_threshold` — consecutive failures before opening (default: 5).
12 - `reset_timeout_ms` — milliseconds before attempting half-open probe (default: 30_000).
13 - `probe_success_threshold` — successes in half-open before closing (default: 1).
14
15 ETS table: `:wallet_integrations_circuit_breaker`
16 """
17
18 use GenServer
19
20 @table :wallet_integrations_circuit_breaker
21 @default_failure_threshold 5
22 @default_reset_timeout_ms 30_000
23
24
:-(
defstruct [
25 :provider,
26 :operation,
27 :state,
28 :failure_count,
29 :last_failure_at,
30 :opened_at,
31 :probe_success_count
32 ]
33
34
:-(
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
35
36 @doc "Check if a request is allowed for the given provider and operation."
37 @spec check(provider :: atom(), operation :: atom()) :: :ok | {:error, :circuit_open}
38 def check(provider, operation) do
39 28 GenServer.call(__MODULE__, {:check, provider, operation})
40 end
41
42 @doc "Record a successful call for the given provider and operation."
43 @spec record_success(provider :: atom(), operation :: atom()) :: :ok
44 def record_success(provider, operation) do
45 15 GenServer.call(__MODULE__, {:record_success, provider, operation})
46 end
47
48 @doc "Record a failed call. May transition the circuit to :open."
49 @spec record_failure(provider :: atom(), operation :: atom()) :: :ok | {:opened, :circuit_open}
50 def record_failure(provider, operation) do
51 56 GenServer.call(__MODULE__, {:record_failure, provider, operation})
52 end
53
54 @doc "Return current state struct for a provider/operation pair."
55 @spec get_state(provider :: atom(), operation :: atom()) :: map()
56 def get_state(provider, operation) do
57 5 GenServer.call(__MODULE__, {:get_state, provider, operation})
58 end
59
60 @spec reset() :: :ok
61 48 def reset, do: GenServer.call(__MODULE__, :reset)
62
63 # GenServer callbacks
64
65 @impl true
66 def init(_opts) do
67
:-(
:ets.new(@table, [:set, :protected, :named_table])
68 {:ok, %{}}
69 end
70
71 @impl true
72 def handle_call({:check, provider, operation}, _from, state) do
73 28 cb = get_or_init(provider, operation)
74 28 now = System.monotonic_time(:millisecond)
75 28 reset_timeout = breaker_config(:reset_timeout_ms, @default_reset_timeout_ms)
76
77 28 result =
78 28 case cb.state do
79 21 :closed ->
80 :ok
81
82 :open ->
83 7 elapsed = now - (cb.opened_at || now)
84 7 if elapsed >= reset_timeout do
85 3 updated = %{cb | state: :half_open, probe_success_count: 0}
86 3 put_breaker(updated)
87 :ok
88 else
89 {:error, :circuit_open}
90 end
91
92
:-(
:half_open ->
93 :ok
94 end
95
96 28 {:reply, result, state}
97 end
98
99 @impl true
100 def handle_call({:record_success, provider, operation}, _from, state) do
101 15 cb = get_or_init(provider, operation)
102
103 15 updated =
104 15 case cb.state do
105 :half_open ->
106 1 threshold = breaker_config(:probe_success_threshold, 1)
107 1 new_count = cb.probe_success_count + 1
108 1 if new_count >= threshold do
109 1 %{cb | state: :closed, failure_count: 0, opened_at: nil, probe_success_count: 0}
110 else
111
:-(
%{cb | probe_success_count: new_count}
112 end
113
114 _ ->
115 14 %{cb | failure_count: 0}
116 end
117
118 15 put_breaker(updated)
119 15 {:reply, :ok, state}
120 end
121
122 @impl true
123 def handle_call({:record_failure, provider, operation}, _from, state) do
124 56 cb = get_or_init(provider, operation)
125 56 threshold = breaker_config(:failure_threshold, @default_failure_threshold)
126 56 now = System.monotonic_time(:millisecond)
127
128 56 updated =
129 56 case cb.state do
130 :half_open ->
131 1 %{cb | state: :open, opened_at: now, last_failure_at: now, failure_count: cb.failure_count + 1}
132
133 :closed ->
134 55 new_count = cb.failure_count + 1
135 55 if new_count >= threshold do
136 9 %{cb | state: :open, failure_count: new_count, opened_at: now, last_failure_at: now}
137 else
138 46 %{cb | failure_count: new_count, last_failure_at: now}
139 end
140
141 :open ->
142
:-(
%{cb | failure_count: cb.failure_count + 1, last_failure_at: now}
143 end
144
145 56 put_breaker(updated)
146 56 result = if updated.state == :open, do: {:opened, :circuit_open}, else: :ok
147 56 {:reply, result, state}
148 end
149
150 @impl true
151 def handle_call({:get_state, provider, operation}, _from, state) do
152 5 cb = get_or_init(provider, operation)
153 5 {:reply, Map.from_struct(cb), state}
154 end
155
156 @impl true
157 def handle_call(:reset, _from, state) do
158 48 :ets.delete_all_objects(@table)
159 48 {:reply, :ok, state}
160 end
161
162 # Private helpers
163
164 defp get_or_init(provider, operation) do
165 104 key = {provider, operation}
166 104 case :ets.lookup(@table, key) do
167 86 [{_, cb}] -> cb
168 [] ->
169 18 cb = %__MODULE__{
170 provider: provider,
171 operation: operation,
172 state: :closed,
173 failure_count: 0,
174 last_failure_at: nil,
175 opened_at: nil,
176 probe_success_count: 0
177 }
178 18 :ets.insert(@table, {key, cb})
179 18 cb
180 end
181 end
182
183 defp put_breaker(%__MODULE__{provider: p, operation: o} = cb) do
184 74 :ets.insert(@table, {{p, o}, cb})
185 end
186
187 defp breaker_config(key, default) do
188 Application.get_env(:wallet_integrations, :circuit_breaker, [])
189 85 |> Keyword.get(key, default)
190 end
191 end
Line Hits Source