defmodule Argos.Command.CircuitBreaker do @moduledoc """ Circuit Breaker para comandos del sistema. Implementa el patrón Circuit Breaker para prevenir la ejecución de comandos cuando hay demasiados fallos consecutivos. Esto mejora la resiliencia del sistema. ## Estados - `:closed` - Circuito cerrado, comandos se ejecutan normalmente - `:open` - Circuito abierto, comandos se rechazan inmediatamente - `:half_open` - Circuito semi-abierto, permite un intento de prueba ## Configuración config :argos, :circuit_breaker, threshold: 5, timeout: 60_000, enabled: true ## Uso # Ejecutar comando con circuit breaker Argos.Command.exec("risky_command", circuit_breaker: true) """ use GenServer require Logger @default_threshold 5 @default_timeout 60_000 @default_enabled true defstruct [ :state, :failure_count, :last_failure_time, :threshold, :timeout, :enabled ] @type state :: :closed | :open | :half_open @type t :: %__MODULE__{ state: state(), failure_count: non_neg_integer(), last_failure_time: integer() | nil, threshold: non_neg_integer(), timeout: non_neg_integer(), enabled: boolean() } @doc """ Inicia el Circuit Breaker. """ @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Verifica si el circuito permite ejecutar un comando. """ @spec call(atom(), (-> result)) :: {:ok, result} | {:error, :circuit_open} | {:error, term()} when result: term() def call(key, fun) when is_function(fun, 0) do GenServer.call(__MODULE__, {:call, key, fun}, :infinity) end @doc """ Registra un éxito en el circuito. """ @spec success(atom()) :: :ok def success(key) do GenServer.cast(__MODULE__, {:success, key}) end @doc """ Registra un fallo en el circuito. """ @spec failure(atom()) :: :ok def failure(key) do GenServer.cast(__MODULE__, {:failure, key}) end @doc """ Obtiene el estado actual del circuito. """ @spec get_state(atom()) :: state() def get_state(key) do GenServer.call(__MODULE__, {:get_state, key}, :infinity) end @doc """ Resetea el circuito a estado cerrado. """ @spec reset(atom()) :: :ok def reset(key) do GenServer.cast(__MODULE__, {:reset, key}) end @impl true def init(opts) do threshold = Keyword.get(opts, :threshold, @default_threshold) timeout = Keyword.get(opts, :timeout, @default_timeout) enabled = Keyword.get(opts, :enabled, @default_enabled) state = %__MODULE__{ state: :closed, failure_count: 0, last_failure_time: nil, threshold: threshold, timeout: timeout, enabled: enabled } {:ok, %{default: state}} end @impl true def handle_call({:call, key, fun}, _from, state) do circuit = Map.get(state, key, state.default) if circuit.enabled do handle_enabled_circuit(circuit, key, fun, state) else execute_without_circuit_breaker(fun, state) end end def handle_call({:get_state, key}, _from, state) do circuit = Map.get(state, key, state.default) {:reply, circuit.state, state} end defp execute_without_circuit_breaker(fun, state) do result = fun.() {:reply, {:ok, result}, state} rescue e -> {:reply, {:error, e}, state} catch :exit, reason -> {:reply, {:error, {:exit, reason}}, state} :throw, reason -> {:reply, {:error, {:throw, reason}}, state} end defp handle_enabled_circuit(circuit, key, fun, state) do case circuit.state do :closed -> handle_closed_circuit(circuit, key, fun, state) :open -> handle_open_circuit(circuit, key, fun, state) :half_open -> handle_half_open_circuit(circuit, key, fun, state) end end defp handle_closed_circuit(circuit, key, fun, state) do result = fun.() new_circuit = %{circuit | failure_count: 0} new_state = Map.put(state, key, new_circuit) {:reply, {:ok, result}, new_state} rescue e -> new_circuit = record_failure(circuit) new_state = Map.put(state, key, new_circuit) {:reply, {:error, e}, new_state} catch :exit, reason -> new_circuit = record_failure(circuit) new_state = Map.put(state, key, new_circuit) {:reply, {:error, {:exit, reason}}, new_state} :throw, reason -> new_circuit = record_failure(circuit) new_state = Map.put(state, key, new_circuit) {:reply, {:error, {:throw, reason}}, new_state} end defp handle_open_circuit(circuit, key, fun, state) do if should_transition_to_half_open?(circuit) do new_circuit = %{circuit | state: :half_open, failure_count: 0} new_state = Map.put(state, key, new_circuit) try do result = fun.() new_circuit = %{new_circuit | state: :closed, failure_count: 0} new_state = Map.put(new_state, key, new_circuit) {:reply, {:ok, result}, new_state} rescue e -> new_circuit = %{new_circuit | state: :open, last_failure_time: System.system_time(:millisecond)} new_state = Map.put(new_state, key, new_circuit) {:reply, {:error, e}, new_state} catch :exit, reason -> new_circuit = %{new_circuit | state: :open, last_failure_time: System.system_time(:millisecond)} new_state = Map.put(new_state, key, new_circuit) {:reply, {:error, {:exit, reason}}, new_state} :throw, reason -> new_circuit = %{new_circuit | state: :open, last_failure_time: System.system_time(:millisecond)} new_state = Map.put(new_state, key, new_circuit) {:reply, {:error, {:throw, reason}}, new_state} end else {:reply, {:error, :circuit_open}, state} end end defp handle_half_open_circuit(circuit, key, fun, state) do result = fun.() new_circuit = %{circuit | state: :closed, failure_count: 0} new_state = Map.put(state, key, new_circuit) {:reply, {:ok, result}, new_state} rescue e -> new_circuit = %{circuit | state: :open, last_failure_time: System.system_time(:millisecond)} new_state = Map.put(state, key, new_circuit) {:reply, {:error, e}, new_state} catch :exit, reason -> new_circuit = %{circuit | state: :open, last_failure_time: System.system_time(:millisecond)} new_state = Map.put(state, key, new_circuit) {:reply, {:error, {:exit, reason}}, new_state} :throw, reason -> new_circuit = %{circuit | state: :open, last_failure_time: System.system_time(:millisecond)} new_state = Map.put(state, key, new_circuit) {:reply, {:error, {:throw, reason}}, new_state} end @impl true def handle_cast({:success, key}, state) do circuit = Map.get(state, key, state.default) new_circuit = %{circuit | failure_count: 0, state: :closed} new_state = Map.put(state, key, new_circuit) {:noreply, new_state} end def handle_cast({:failure, key}, state) do circuit = Map.get(state, key, state.default) new_circuit = record_failure(circuit) new_state = Map.put(state, key, new_circuit) {:noreply, new_state} end def handle_cast({:reset, key}, state) do circuit = Map.get(state, key, state.default) new_circuit = %{circuit | state: :closed, failure_count: 0, last_failure_time: nil} new_state = Map.put(state, key, new_circuit) {:noreply, new_state} end defp record_failure(circuit) do new_count = circuit.failure_count + 1 new_state = if new_count >= circuit.threshold, do: :open, else: circuit.state %{ circuit | failure_count: new_count, last_failure_time: System.system_time(:millisecond), state: new_state } end defp should_transition_to_half_open?(circuit) do case circuit.last_failure_time do nil -> true last_time -> System.system_time(:millisecond) - last_time >= circuit.timeout end end end