Turned on :error_handling, :underspecs, and :unmatched_returns in mix.exs dialyzer config. The 97 warnings this surfaced were fixed in place rather than suppressed: - unmatched_return (79): explicit discard with `_ = ...` for fire-and-forget side effects (Process.cancel_timer, :ets.new, send/2), and pattern-matched `:ok = ...` for control-plane Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future return-shape change fails loud. - contract_supertype (18): tightened @spec arg and return types on data_builder, historical_loader, url_params, packet_utils, encoding_utils, aprs_symbol, weather_controller, packet_replay to match each function's actual success typing. No behavioural change. mix compile clean, 1008 tests pass, dialyzer count is now 0.
249 lines
6.5 KiB
Elixir
249 lines
6.5 KiB
Elixir
defmodule Aprsme.CircuitBreaker do
|
|
@moduledoc """
|
|
Circuit breaker implementation for external service calls to prevent cascading failures
|
|
"""
|
|
use GenServer
|
|
|
|
alias Aprsme.LogSanitizer
|
|
|
|
require Logger
|
|
|
|
@type state :: :closed | :open | :half_open
|
|
@type service_state :: %{
|
|
state: state(),
|
|
failure_count: non_neg_integer(),
|
|
failure_threshold: non_neg_integer(),
|
|
timeout: non_neg_integer(),
|
|
last_failure_time: DateTime.t() | nil,
|
|
recovery_timeout: non_neg_integer()
|
|
}
|
|
|
|
@default_failure_threshold 5
|
|
@default_timeout 5000
|
|
@default_recovery_timeout 30_000
|
|
|
|
# Public API
|
|
|
|
@doc """
|
|
Start the circuit breaker GenServer
|
|
"""
|
|
def start_link(opts \\ []) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Execute a function with circuit breaker protection
|
|
"""
|
|
@spec call(atom(), function(), non_neg_integer()) :: {:ok, any()} | {:error, :circuit_open | :timeout | any()}
|
|
def call(service_name, fun, timeout \\ @default_timeout) when is_function(fun, 0) do
|
|
case get_state(service_name) do
|
|
:open ->
|
|
Logger.warning("Circuit breaker open for service",
|
|
circuit_breaker:
|
|
LogSanitizer.log_data(
|
|
service: service_name,
|
|
state: :open,
|
|
action: :rejected
|
|
)
|
|
)
|
|
|
|
{:error, :circuit_open}
|
|
|
|
state when state in [:closed, :half_open] ->
|
|
attempt_call(service_name, fun, timeout)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Get the current state of a service circuit breaker
|
|
"""
|
|
@spec get_state(atom()) :: state()
|
|
def get_state(service_name) do
|
|
GenServer.call(__MODULE__, {:get_state, service_name})
|
|
end
|
|
|
|
@doc """
|
|
Get statistics for a service circuit breaker
|
|
"""
|
|
@spec get_stats(atom()) :: service_state()
|
|
def get_stats(service_name) do
|
|
GenServer.call(__MODULE__, {:get_stats, service_name})
|
|
end
|
|
|
|
@doc """
|
|
Manually reset a circuit breaker
|
|
"""
|
|
@spec reset(atom()) :: :ok
|
|
def reset(service_name) do
|
|
GenServer.call(__MODULE__, {:reset, service_name})
|
|
end
|
|
|
|
# GenServer implementation
|
|
|
|
@impl true
|
|
def init(_opts) do
|
|
{:ok, %{}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:get_state, service_name}, _from, services) do
|
|
service_state = get_service_state(services, service_name)
|
|
current_state = calculate_current_state(service_state)
|
|
|
|
# Persist state transitions (e.g., :open -> :half_open) so that
|
|
# concurrent callers see the same state and don't all independently
|
|
# enter half_open.
|
|
services =
|
|
if current_state == service_state.state do
|
|
services
|
|
else
|
|
updated_state = %{service_state | state: current_state}
|
|
Map.put(services, service_name, updated_state)
|
|
end
|
|
|
|
{:reply, current_state, services}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:get_stats, service_name}, _from, services) do
|
|
service_state = get_service_state(services, service_name)
|
|
{:reply, service_state, services}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:reset, service_name}, _from, services) do
|
|
default_state = default_service_state()
|
|
new_services = Map.put(services, service_name, default_state)
|
|
|
|
Logger.info("Circuit breaker reset",
|
|
circuit_breaker:
|
|
LogSanitizer.log_data(
|
|
service: service_name,
|
|
action: :manual_reset
|
|
)
|
|
)
|
|
|
|
{:reply, :ok, new_services}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:record_success, service_name}, _from, services) do
|
|
service_state = get_service_state(services, service_name)
|
|
updated_state = %{service_state | failure_count: 0, state: :closed}
|
|
new_services = Map.put(services, service_name, updated_state)
|
|
{:reply, :ok, new_services}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:record_failure, service_name}, _from, services) do
|
|
service_state = get_service_state(services, service_name)
|
|
new_failure_count = service_state.failure_count + 1
|
|
|
|
new_state =
|
|
if new_failure_count >= service_state.failure_threshold do
|
|
:open
|
|
else
|
|
service_state.state
|
|
end
|
|
|
|
updated_state = %{
|
|
service_state
|
|
| failure_count: new_failure_count,
|
|
state: new_state,
|
|
last_failure_time: DateTime.utc_now()
|
|
}
|
|
|
|
if new_state == :open do
|
|
Logger.warning("Circuit breaker opened due to failure threshold",
|
|
circuit_breaker:
|
|
LogSanitizer.log_data(
|
|
service: service_name,
|
|
failure_count: new_failure_count,
|
|
threshold: service_state.failure_threshold,
|
|
state: :open
|
|
)
|
|
)
|
|
end
|
|
|
|
new_services = Map.put(services, service_name, updated_state)
|
|
{:reply, :ok, new_services}
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp attempt_call(service_name, fun, timeout) do
|
|
task = Task.async(fn -> fun.() end)
|
|
|
|
try do
|
|
result = Task.await(task, timeout)
|
|
record_success(service_name)
|
|
{:ok, result}
|
|
catch
|
|
:exit, {:timeout, _} ->
|
|
_ = Task.shutdown(task, :brutal_kill)
|
|
record_failure(service_name)
|
|
|
|
Logger.warning("Service call timed out",
|
|
circuit_breaker:
|
|
LogSanitizer.log_data(
|
|
service: service_name,
|
|
timeout_ms: timeout,
|
|
action: :timeout_failure
|
|
)
|
|
)
|
|
|
|
{:error, :timeout}
|
|
|
|
kind, error ->
|
|
record_failure(service_name)
|
|
|
|
Logger.error("Service call failed",
|
|
circuit_breaker:
|
|
LogSanitizer.log_data(
|
|
service: service_name,
|
|
error_kind: kind,
|
|
error: LogSanitizer.sanitize(error)
|
|
)
|
|
)
|
|
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
defp record_success(service_name) do
|
|
GenServer.call(__MODULE__, {:record_success, service_name})
|
|
end
|
|
|
|
defp record_failure(service_name) do
|
|
GenServer.call(__MODULE__, {:record_failure, service_name})
|
|
end
|
|
|
|
defp get_service_state(services, service_name) do
|
|
Map.get(services, service_name, default_service_state())
|
|
end
|
|
|
|
defp default_service_state do
|
|
%{
|
|
state: :closed,
|
|
failure_count: 0,
|
|
failure_threshold: @default_failure_threshold,
|
|
timeout: @default_timeout,
|
|
last_failure_time: nil,
|
|
recovery_timeout: @default_recovery_timeout
|
|
}
|
|
end
|
|
|
|
defp calculate_current_state(%{
|
|
state: :open,
|
|
last_failure_time: last_failure_time,
|
|
recovery_timeout: recovery_timeout
|
|
}) do
|
|
if last_failure_time && DateTime.diff(DateTime.utc_now(), last_failure_time, :millisecond) >= recovery_timeout do
|
|
:half_open
|
|
else
|
|
:open
|
|
end
|
|
end
|
|
|
|
defp calculate_current_state(%{state: state}), do: state
|
|
end
|