Skip to content

Commit cbfd0a1

Browse files
NSHkrNSHkr
authored andcommitted
infrastructure initial implementation. 30 properties, 400 tests, 0 failures, 39 excluded
1 parent 30b08e6 commit cbfd0a1

File tree

17 files changed

+2733
-4
lines changed

17 files changed

+2733
-4
lines changed

.dialyzer.ignore.exs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[
2+
# circuit_breaker.ex contract_supertype warnings
3+
{"lib/elixir_scope/foundation/infrastructure/circuit_breaker.ex", :contract_supertype},
4+
5+
# connection_manager.ex contract_supertype warnings
6+
{"lib/elixir_scope/foundation/infrastructure/connection_manager.ex", :contract_supertype},
7+
8+
# infrastructure.ex contract_supertype warnings
9+
{"lib/elixir_scope/foundation/infrastructure/infrastructure.ex", :contract_supertype},
10+
11+
# rate_limiter.ex contract_supertype warnings
12+
{"lib/elixir_scope/foundation/infrastructure/rate_limiter.ex", :contract_supertype},
13+
14+
# pool_workers contract_supertype warnings
15+
{"lib/elixir_scope/foundation/infrastructure/pool_workers/http_worker.ex", :contract_supertype}
16+
]

lib/elixir_scope/application.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ defmodule ElixirScope.Application do
2222
{ElixirScope.Foundation.Services.EventStore, [namespace: :production]},
2323
{ElixirScope.Foundation.Services.TelemetryService, [namespace: :production]},
2424

25+
# Infrastructure protection components
26+
{ElixirScope.Foundation.Infrastructure.ConnectionManager, []},
27+
{ElixirScope.Foundation.Infrastructure.RateLimiter.HammerBackend,
28+
[clean_period: :timer.minutes(1)]},
29+
2530
# Task supervisor for dynamic tasks
2631
{Task.Supervisor, name: ElixirScope.Foundation.TaskSupervisor}
2732

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
defmodule ElixirScope.Foundation.Infrastructure.CircuitBreaker do
2+
@moduledoc """
3+
Circuit breaker wrapper around :fuse library.
4+
5+
Provides standardized circuit breaker functionality with telemetry integration
6+
and ElixirScope-specific error handling. Translates :fuse errors to
7+
Foundation.Types.Error structures.
8+
9+
## Usage
10+
11+
# Start a fuse instance
12+
{:ok, _pid} = CircuitBreaker.start_fuse_instance(:my_service, options)
13+
14+
# Execute protected operation
15+
case CircuitBreaker.execute(:my_service, fn -> risky_operation() end) do
16+
{:ok, result} -> result
17+
{:error, error} -> handle_error(error)
18+
end
19+
20+
# Check circuit status
21+
status = CircuitBreaker.get_status(:my_service)
22+
"""
23+
24+
alias ElixirScope.Foundation.Types.Error
25+
alias ElixirScope.Foundation.Telemetry
26+
27+
@type fuse_name :: atom()
28+
@type fuse_options :: [
29+
strategy: :standard | :fault_injection,
30+
tolerance: non_neg_integer(),
31+
refresh: non_neg_integer()
32+
]
33+
@type operation :: (-> any())
34+
@type operation_result :: {:ok, any()} | {:error, Error.t()}
35+
36+
@doc """
37+
Start a new fuse instance with the given name and options.
38+
39+
## Parameters
40+
- `name`: Unique atom identifier for the fuse
41+
- `options`: Fuse configuration options
42+
43+
## Examples
44+
45+
iex> CircuitBreaker.start_fuse_instance(:database,
46+
...> strategy: :standard, tolerance: 5, refresh: 60_000)
47+
{:ok, #PID<0.123.0>}
48+
"""
49+
@spec start_fuse_instance(fuse_name(), fuse_options()) :: :ok | {:error, Error.t()}
50+
def start_fuse_instance(name, options \\ []) when is_atom(name) do
51+
default_options = {{:standard, 5, 60_000}, {:reset, 60_000}}
52+
53+
fuse_options =
54+
case options do
55+
[] ->
56+
default_options
57+
58+
[strategy: :standard, tolerance: tolerance, refresh: refresh] ->
59+
{{:standard, tolerance, refresh}, {:reset, refresh}}
60+
61+
_ ->
62+
default_options
63+
end
64+
65+
try do
66+
case :fuse.install(name, fuse_options) do
67+
:ok ->
68+
emit_telemetry(:fuse_installed, %{name: name, options: fuse_options})
69+
:ok
70+
71+
{:error, :already_installed} ->
72+
emit_telemetry(:fuse_already_installed, %{name: name})
73+
:ok
74+
75+
{:error, reason} ->
76+
error =
77+
Error.new(
78+
code: 5001,
79+
error_type: :circuit_breaker_install_failed,
80+
message: "Failed to install circuit breaker: #{inspect(reason)}",
81+
severity: :high,
82+
context: %{fuse_name: name, reason: reason}
83+
)
84+
85+
emit_telemetry(:fuse_install_failed, %{name: name, reason: reason})
86+
{:error, error}
87+
end
88+
rescue
89+
exception ->
90+
error =
91+
Error.new(
92+
code: 5002,
93+
error_type: :circuit_breaker_exception,
94+
message: "Exception during fuse installation: #{inspect(exception)}",
95+
severity: :critical,
96+
context: %{fuse_name: name, exception: exception}
97+
)
98+
99+
emit_telemetry(:fuse_install_exception, %{name: name, exception: exception})
100+
{:error, error}
101+
end
102+
end
103+
104+
@doc """
105+
Execute an operation protected by the circuit breaker.
106+
107+
## Parameters
108+
- `name`: Fuse instance name
109+
- `operation`: Function to execute
110+
- `metadata`: Additional telemetry metadata
111+
112+
## Examples
113+
114+
iex> CircuitBreaker.execute(:database, fn -> DB.query("SELECT 1") end)
115+
{:ok, [%{column: 1}]}
116+
117+
iex> CircuitBreaker.execute(:failing_service, fn -> raise "boom" end)
118+
{:error, %Error{error_type: :circuit_breaker_blown}}
119+
"""
120+
@spec execute(fuse_name(), operation(), map()) :: operation_result()
121+
def execute(name, operation, metadata \\ %{}) when is_atom(name) and is_function(operation, 0) do
122+
start_time = System.monotonic_time(:microsecond)
123+
124+
try do
125+
case :fuse.ask(name, :sync) do
126+
:ok ->
127+
# Circuit is closed, execute operation
128+
try do
129+
result = operation.()
130+
duration = System.monotonic_time(:microsecond) - start_time
131+
132+
emit_telemetry(
133+
:call_executed,
134+
Map.merge(metadata, %{
135+
name: name,
136+
duration: duration,
137+
status: :success
138+
})
139+
)
140+
141+
{:ok, result}
142+
rescue
143+
exception ->
144+
# Operation failed, melt the fuse
145+
:fuse.melt(name)
146+
duration = System.monotonic_time(:microsecond) - start_time
147+
148+
error =
149+
Error.new(
150+
code: 5003,
151+
error_type: :protected_operation_failed,
152+
message: "Protected operation failed: #{inspect(exception)}",
153+
severity: :medium,
154+
context: %{fuse_name: name, exception: exception}
155+
)
156+
157+
emit_telemetry(
158+
:call_executed,
159+
Map.merge(metadata, %{
160+
name: name,
161+
duration: duration,
162+
status: :failed,
163+
exception: exception
164+
})
165+
)
166+
167+
{:error, error}
168+
end
169+
170+
:blown ->
171+
# Circuit is open
172+
error =
173+
Error.new(
174+
code: 5004,
175+
error_type: :circuit_breaker_blown,
176+
message: "Circuit breaker is open for #{name}",
177+
severity: :medium,
178+
context: %{fuse_name: name},
179+
retry_strategy: :fixed_delay
180+
)
181+
182+
emit_telemetry(
183+
:call_rejected,
184+
Map.merge(metadata, %{
185+
name: name,
186+
reason: :circuit_blown
187+
})
188+
)
189+
190+
{:error, error}
191+
192+
{:error, :not_found} ->
193+
# Fuse not installed
194+
error =
195+
Error.new(
196+
code: 5005,
197+
error_type: :circuit_breaker_not_found,
198+
message: "Circuit breaker #{name} not found",
199+
severity: :high,
200+
context: %{fuse_name: name}
201+
)
202+
203+
emit_telemetry(
204+
:call_rejected,
205+
Map.merge(metadata, %{
206+
name: name,
207+
reason: :not_found
208+
})
209+
)
210+
211+
{:error, error}
212+
end
213+
rescue
214+
exception ->
215+
duration = System.monotonic_time(:microsecond) - start_time
216+
217+
error =
218+
Error.new(
219+
code: 5006,
220+
error_type: :circuit_breaker_exception,
221+
message: "Exception in circuit breaker execution: #{inspect(exception)}",
222+
severity: :critical,
223+
context: %{fuse_name: name, exception: exception}
224+
)
225+
226+
emit_telemetry(
227+
:call_executed,
228+
Map.merge(metadata, %{
229+
name: name,
230+
duration: duration,
231+
status: :exception,
232+
exception: exception
233+
})
234+
)
235+
236+
{:error, error}
237+
end
238+
end
239+
240+
@doc """
241+
Get the current status of a circuit breaker.
242+
243+
## Parameters
244+
- `name`: Fuse instance name
245+
246+
## Returns
247+
- `:ok` - Circuit is closed (healthy)
248+
- `:blown` - Circuit is open (unhealthy)
249+
- `{:error, Error.t()}` - Fuse not found or other error
250+
251+
## Examples
252+
253+
iex> CircuitBreaker.get_status(:my_service)
254+
:ok
255+
256+
iex> CircuitBreaker.get_status(:blown_service)
257+
:blown
258+
"""
259+
@spec get_status(fuse_name()) :: :ok | :blown | {:error, Error.t()}
260+
def get_status(name) when is_atom(name) do
261+
try do
262+
case :fuse.ask(name, :sync) do
263+
:ok ->
264+
:ok
265+
266+
:blown ->
267+
:blown
268+
269+
{:error, :not_found} ->
270+
error =
271+
Error.new(
272+
code: 5007,
273+
error_type: :circuit_breaker_not_found,
274+
message: "Circuit breaker #{name} not found",
275+
severity: :medium,
276+
context: %{fuse_name: name}
277+
)
278+
279+
{:error, error}
280+
end
281+
rescue
282+
exception ->
283+
error =
284+
Error.new(
285+
code: 5008,
286+
error_type: :circuit_breaker_exception,
287+
message: "Exception checking circuit breaker status: #{inspect(exception)}",
288+
severity: :medium,
289+
context: %{fuse_name: name, exception: exception}
290+
)
291+
292+
{:error, error}
293+
end
294+
end
295+
296+
@doc """
297+
Reset a blown circuit breaker manually.
298+
299+
## Parameters
300+
- `name`: Fuse instance name
301+
302+
## Examples
303+
304+
iex> CircuitBreaker.reset(:my_service)
305+
:ok
306+
"""
307+
@spec reset(fuse_name()) :: :ok | {:error, Error.t()}
308+
def reset(name) when is_atom(name) do
309+
try do
310+
case :fuse.reset(name) do
311+
:ok ->
312+
emit_telemetry(:state_change, %{name: name, new_state: :reset})
313+
:ok
314+
315+
{:error, :not_found} ->
316+
error =
317+
Error.new(
318+
code: 5009,
319+
error_type: :circuit_breaker_not_found,
320+
message: "Cannot reset circuit breaker #{name}: not found",
321+
severity: :medium,
322+
context: %{fuse_name: name}
323+
)
324+
325+
{:error, error}
326+
end
327+
rescue
328+
exception ->
329+
error =
330+
Error.new(
331+
code: 5010,
332+
error_type: :circuit_breaker_exception,
333+
message: "Exception resetting circuit breaker: #{inspect(exception)}",
334+
severity: :medium,
335+
context: %{fuse_name: name, exception: exception}
336+
)
337+
338+
{:error, error}
339+
end
340+
end
341+
342+
# Private helper functions
343+
344+
@spec emit_telemetry(atom(), map()) :: :ok
345+
defp emit_telemetry(event_type, metadata) do
346+
event_name = [:elixir_scope, :foundation, :infra, :circuit_breaker, event_type]
347+
Telemetry.emit_counter(event_name, metadata)
348+
end
349+
end

0 commit comments

Comments
 (0)