towerops/lib/towerops/redis_health_check.ex
Graham McIntire 83868a6028
fix: remove to_charlist conversion for Redix host option
Redix expects host to be a string, not a charlist. The to_charlist/1
conversion was causing FunctionClauseError in Redix.StartOptions.__validate_host__/1.

This matches how Phoenix PubSub Redis and Exq are configured, which both
use the string host directly without conversion.
2026-01-24 17:49:57 -06:00

118 lines
3.7 KiB
Elixir

defmodule Towerops.RedisHealthCheck do
@moduledoc """
Health check for Redis/Valkey connectivity.
Performs a simple PING command to verify Redis is reachable and responding.
Used by the health check endpoint to determine overall application health.
"""
require Logger
@doc """
Waits for Redis to become available with exponential backoff retries.
## Parameters
- `redis_config` - Keyword list with Redis connection config (host, port, password)
- `opts` - Options keyword list:
- `:max_attempts` - Maximum number of connection attempts (default: 10)
- `:backoff` - Initial backoff time in milliseconds (default: 500)
- `:timeout` - Connection timeout in milliseconds (default: 2000)
## Returns
- `:ok` if Redis becomes available
- `{:error, :max_attempts_exceeded}` if all attempts fail
"""
@spec wait_for_redis(keyword(), keyword()) :: :ok | {:error, :max_attempts_exceeded}
def wait_for_redis(redis_config, opts \\ []) do
max_attempts = Keyword.get(opts, :max_attempts, 10)
backoff = Keyword.get(opts, :backoff, 500)
timeout = Keyword.get(opts, :timeout, 2_000)
do_wait_for_redis(redis_config, timeout, max_attempts, backoff, 1)
end
defp do_wait_for_redis(_redis_config, _timeout, max_attempts, _backoff, attempt) when attempt > max_attempts do
Logger.warning("Redis connection failed after #{max_attempts} attempts")
{:error, :max_attempts_exceeded}
end
defp do_wait_for_redis(redis_config, timeout, max_attempts, backoff, attempt) do
case check_health(redis_config, timeout) do
:ok ->
if attempt > 1 do
Logger.info("Redis connection established after #{attempt} attempts")
end
:ok
{:error, reason} ->
wait_time = round(backoff * :math.pow(2, attempt - 1))
Logger.debug(
"Redis connection attempt #{attempt}/#{max_attempts} failed: #{inspect(reason)}, " <>
"retrying in #{wait_time}ms"
)
Process.sleep(wait_time)
do_wait_for_redis(redis_config, timeout, max_attempts, backoff, attempt + 1)
end
end
@doc """
Checks Redis health by attempting a PING command with a timeout.
## Parameters
- `redis_config` - Keyword list with Redis connection config (host, port, password)
- `timeout_ms` - Maximum time to wait for response in milliseconds
## Returns
- `:ok` if Redis responds to PONG
- `{:error, reason}` if connection fails or times out
"""
@spec check_health(keyword(), pos_integer()) :: :ok | {:error, term()}
def check_health(redis_config, timeout_ms \\ 2_000) do
host = Keyword.get(redis_config, :host, "localhost")
port = Keyword.get(redis_config, :port, 6379)
password = Keyword.get(redis_config, :password)
# Build connection options
conn_opts = [
host: host,
port: port,
timeout: timeout_ms,
socket_opts: [keepalive: true]
]
conn_opts =
if password && password != "" do
Keyword.put(conn_opts, :password, password)
else
conn_opts
end
# Start a temporary connection for health check
case Redix.start_link(conn_opts) do
{:ok, conn} ->
result =
case Redix.command(conn, ["PING"], timeout: timeout_ms) do
{:ok, "PONG"} -> :ok
{:error, reason} -> {:error, reason}
end
Redix.stop(conn)
result
{:error, reason} ->
Logger.debug("Redis health check connection failed: #{inspect(reason)}")
{:error, reason}
end
rescue
error ->
Logger.debug("Redis health check error: #{inspect(error)}")
{:error, error}
catch
:exit, reason ->
Logger.debug("Redis health check exit: #{inspect(reason)}")
{:error, reason}
end
end