Current section
Files
Jump to
Current section
Files
lib/ccxt/ws/generator/adapter.ex
defmodule CCXT.WS.Generator.Adapter do
@moduledoc """
Generates WebSocket Adapter GenServer modules at compile time.
The Adapter provides a managed WebSocket connection with:
- Automatic reconnection with exponential backoff
- Subscription tracking and restoration
- Authentication state machine (unauthenticated → authenticating → authenticated → expired)
- Process monitoring
## Generated Module
For each exchange with WS support, generates `CCXT.{Exchange}.WS.Adapter`:
defmodule CCXT.Bybit.WS.Adapter do
use GenServer
# ... generated code
end
## Usage
# Start the adapter
{:ok, adapter} = CCXT.Bybit.WS.Adapter.start_link(
name: :bybit_ws,
url_path: [:public, :spot],
handler: fn msg -> handle_message(msg) end
)
# Subscribe to channels
{:ok, sub} = CCXT.Bybit.WS.watch_ticker_subscription("BTC/USDT")
:ok = CCXT.Bybit.WS.Adapter.subscribe(adapter, sub)
# Reconnection happens automatically
# Subscriptions are restored on reconnect
"""
alias CCXT.Extract.WsHandlerMappings
@reconnect_delay_ms 5_000
@max_reconnect_attempts 10
@max_backoff_ms 60_000
@doc """
Generates the Adapter module AST for an exchange.
## Parameters
- `ws_module` - The WS subscription module (e.g., `CCXT.Bybit.WS`)
- `rest_module` - The REST module (e.g., `CCXT.Bybit`)
- `ws_config` - The WS configuration from spec
"""
@spec generate_adapter(module(), module(), map(), String.t(), keyword()) :: Macro.t()
def generate_adapter(ws_module, rest_module, _ws_config, spec_id, pipeline \\ []) do
normalizer = pipeline[:normalizer]
contract = pipeline[:contract]
# Guard: normalizer and contract must both be set or both be nil
if !!normalizer != !!contract do
raise ArgumentError,
"pipeline[:normalizer] and pipeline[:contract] must both be set or both be nil"
end
moduledoc = generate_adapter_moduledoc(rest_module)
quote do
@moduledoc unquote(moduledoc)
use GenServer
alias CCXT.WS.Auth
alias CCXT.WS.Auth.Expiry
alias CCXT.WS.Client, as: WSClient
alias CCXT.WS.Helpers
alias CCXT.WS.MessageRouter
require Logger
unquote(if contract, do: quote(do: alias(unquote(contract), as: Contract)))
unquote(if normalizer, do: quote(do: alias(unquote(normalizer), as: Normalizer)))
unquote(generate_module_attrs(rest_module, spec_id, normalizer))
unquote(generate_types())
unquote(generate_client_api(ws_module, normalizer))
unquote(generate_init_callback(rest_module, normalizer))
unquote(generate_handle_call_callbacks())
unquote(generate_handle_cast_callbacks())
unquote(generate_connect_info_ast(normalizer))
unquote(generate_monitor_info_ast())
unquote(generate_auth_info_ast())
unquote(generate_private_helpers(spec_id, normalizer, contract))
unquote(generate_initial_auth_ast())
unquote(generate_re_auth_ast())
unquote(generate_auth_expiry_ast())
end
end
# ===========================================================================
# AST Generation Helpers
# ===========================================================================
@doc false
# Generates module attributes for reconnection timing and WS normalization config
defp generate_module_attrs(rest_module, spec_id, normalizer) do
reconnect_delay = @reconnect_delay_ms
max_attempts = @max_reconnect_attempts
max_backoff = @max_backoff_ms
# Look up envelope pattern at compile time (nil if exchange not in W13 data)
envelope = WsHandlerMappings.envelope_pattern(spec_id)
quote do
@reconnect_delay_ms unquote(reconnect_delay)
@max_reconnect_attempts unquote(max_attempts)
@max_backoff_ms unquote(max_backoff)
@max_re_auth_attempts 3
@re_auth_base_delay_ms 2_000
# WS normalization config
@ws_exchange_id unquote(spec_id)
unquote(if normalizer, do: quote(do: @ws_exchange_module(unquote(rest_module))))
@ws_envelope unquote(Macro.escape(envelope))
end
end
@doc false
# Generates type definitions for subscription and state maps
defp generate_types do
quote do
@type subscription :: %{
channel: String.t() | [String.t()],
message: map(),
method: atom(),
auth_required: boolean()
}
@type auth_state :: :unauthenticated | :authenticating | :authenticated | :expired
@type state :: %{
client: WSClient.t() | nil,
monitor_ref: reference() | nil,
# {tag, monitor_ref} — correlates async connect results
connect_task: {reference(), reference()} | nil,
auth_state: auth_state(),
was_authenticated: boolean(),
auth_expires_at: integer() | nil,
auth_timer_ref: reference() | nil,
auth_context: map() | nil,
re_auth_attempts: non_neg_integer(),
subscriptions: [subscription()],
credentials: map() | nil,
spec: map(),
url_path: term(),
opts: keyword(),
handler: (term() -> any()) | nil,
reconnect_attempts: non_neg_integer()
}
end
end
@doc false
# Generates the public client API (start_link, subscribe, unsubscribe, etc.)
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_client_api(ws_module, normalizer) do
start_link_doc =
if normalizer do
"""
Starts the WebSocket adapter.
## Options
- `:name` - GenServer name (optional)
- `:url_path` - Path to WS URL in spec (e.g., `[:public, :spot]`)
- `:credentials` - API credentials map (optional, for authenticated endpoints)
- `:handler` - Message handler function `fn msg -> ... end`
- `:normalize` - Normalize WS payloads to typed structs (default: true)
- `:validate` - Validate normalized payloads against W12 contracts (default: false)
- `:sandbox` - Use testnet URLs (default: false)
- `:debug` - Enable debug logging (default: false)
## Examples
{:ok, adapter} = Adapter.start_link(
name: :bybit_public,
url_path: [:public, :spot],
handler: fn msg -> msg end
)
"""
else
"""
Starts the WebSocket adapter.
## Options
- `:name` - GenServer name (optional)
- `:url_path` - Path to WS URL in spec (e.g., `[:public, :spot]`)
- `:credentials` - API credentials map (optional, for authenticated endpoints)
- `:handler` - Message handler function `fn msg -> ... end`
- `:sandbox` - Use testnet URLs (default: false)
- `:debug` - Enable debug logging (default: false)
## Examples
{:ok, adapter} = Adapter.start_link(
name: :bybit_public,
url_path: [:public, :spot],
handler: fn msg -> msg end
)
"""
end
quote do
# =====================================================================
# Client API
# =====================================================================
@doc unquote(start_link_doc)
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
name = Keyword.get(opts, :name)
if name do
GenServer.start_link(__MODULE__, opts, name: name)
else
GenServer.start_link(__MODULE__, opts)
end
end
@doc """
Subscribes to a channel.
The subscription should come from the exchange's WS module:
{:ok, sub} = #{inspect(unquote(ws_module))}.watch_ticker_subscription("BTC/USDT")
:ok = Adapter.subscribe(adapter, sub)
"""
@spec subscribe(GenServer.server(), subscription()) :: :ok | {:error, term()}
def subscribe(adapter, subscription) do
GenServer.call(adapter, {:subscribe, subscription})
end
@doc """
Unsubscribes from a channel.
"""
@spec unsubscribe(GenServer.server(), subscription()) :: :ok | {:error, term()}
def unsubscribe(adapter, subscription) do
GenServer.call(adapter, {:unsubscribe, subscription})
end
@doc """
Authenticates the WebSocket connection.
Builds and sends an authentication message based on the exchange's
auth pattern (from spec.ws.auth). On success, the adapter is marked
as authenticated and will re-authenticate on reconnection.
## Returns
- `:ok` - Authentication successful
- `{:error, :no_auth_config}` - Exchange doesn't have WS auth configured
- `{:error, :no_credentials}` - No credentials provided at start_link
- `{:error, :not_connected}` - Not connected to WebSocket
- `{:error, :no_message}` - Auth pattern doesn't use WS messages (e.g., listen_key)
- `{:error, reason}` - Authentication failed
## Example
:ok = Adapter.authenticate(adapter)
:ok = Adapter.subscribe(adapter, watch_balance_subscription)
"""
@spec authenticate(GenServer.server()) :: :ok | {:error, term()}
def authenticate(adapter) do
GenServer.call(adapter, :authenticate, 30_000)
end
@doc """
Marks the adapter as authenticated (manual mode).
Use this after performing authentication externally (e.g., via REST
pre-auth for listen_key pattern). The adapter will track this state
and attempt to re-authenticate on reconnection.
"""
@spec mark_authenticated(GenServer.server()) :: :ok
def mark_authenticated(adapter) do
GenServer.cast(adapter, :mark_authenticated)
end
@doc """
Returns the current authentication state.
## Returns
- `:unauthenticated` - Not authenticated
- `:authenticating` - Authentication in progress
- `:authenticated` - Successfully authenticated
- `:expired` - Authentication expired (will auto re-auth for WS-native patterns)
"""
@spec auth_state(GenServer.server()) :: :unauthenticated | :authenticating | :authenticated | :expired
def auth_state(adapter) do
GenServer.call(adapter, :auth_state)
end
@doc """
Returns the current adapter state.
"""
@spec get_state(GenServer.server()) :: {:ok, map()}
def get_state(adapter) do
GenServer.call(adapter, :get_state)
end
@doc """
Returns the connection state without raising on timeout.
Unlike `connected?/1`, this function never crashes — if the GenServer
is busy (e.g., during a blocking connect), it returns `:connecting`
instead of raising a timeout error.
## Returns
- `:connected` - WebSocket is connected
- `:connecting` - Connection attempt in progress
- `:disconnected` - Not connected (or process dead)
"""
@connection_state_timeout_ms 200
@spec connection_state(GenServer.server(), non_neg_integer()) ::
:connected | :connecting | :disconnected
def connection_state(adapter, timeout \\ @connection_state_timeout_ms) do
GenServer.call(adapter, :get_connection_state, timeout)
catch
:exit, {:timeout, _} ->
if Process.alive?(adapter), do: :connecting, else: :disconnected
:exit, {:noproc, _} ->
:disconnected
:exit, reason ->
Logger.debug("[connection_state] Unexpected exit: #{inspect(reason)}")
:disconnected
end
@doc """
Returns the connection status.
Delegates to `connection_state/1` — never raises on timeout.
"""
@spec connected?(GenServer.server()) :: boolean()
def connected?(adapter) do
connection_state(adapter) == :connected
end
@doc """
Sends a raw message through the WebSocket.
"""
@spec send_message(GenServer.server(), map() | binary()) :: :ok | {:ok, map()} | {:error, term()}
def send_message(adapter, message) do
GenServer.call(adapter, {:send_message, message})
end
end
end
@doc false
# Generates GenServer init/1 callback with initial state setup
defp generate_init_callback(rest_module, normalizer) do
quote do
# =====================================================================
# GenServer Callbacks - Init
# =====================================================================
@impl true
def init(opts) do
spec = unquote(rest_module).__ccxt_spec__()
url_path = Keyword.fetch!(opts, :url_path)
state = %{
client: nil,
monitor_ref: nil,
connect_task: nil,
auth_state: :unauthenticated,
was_authenticated: false,
auth_expires_at: nil,
auth_timer_ref: nil,
auth_context: nil,
re_auth_attempts: 0,
subscriptions: [],
credentials: Keyword.get(opts, :credentials),
spec: spec,
url_path: url_path,
opts: opts,
handler: Keyword.get(opts, :handler),
reconnect_attempts: 0
}
unquote(
if normalizer do
quote do
state =
Map.merge(state, %{
normalize: Keyword.get(opts, :normalize, true),
validate: Keyword.get(opts, :validate, false)
})
end
end
)
# Connect async
send(self(), :connect)
{:ok, state}
end
end
end
@doc false
# Generates GenServer handle_call/3 callbacks for subscribe/unsubscribe/state queries
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_handle_call_callbacks do
quote do
# =====================================================================
# GenServer Callbacks - handle_call
# =====================================================================
@impl true
def handle_call({:subscribe, _subscription}, _from, %{client: nil} = state) do
{:reply, {:error, :not_connected}, state}
end
def handle_call({:subscribe, subscription}, _from, %{client: client} = state) do
# For inline_subscribe: merge auth data into subscribe message
enriched_sub = maybe_enrich_with_auth(subscription, state)
case WSClient.subscribe(client, enriched_sub) do
:ok ->
new_subs = [enriched_sub | state.subscriptions]
{:reply, :ok, %{state | subscriptions: new_subs}}
{:ok, _response} ->
new_subs = [enriched_sub | state.subscriptions]
{:reply, :ok, %{state | subscriptions: new_subs}}
{:error, _} = error ->
{:reply, error, state}
end
end
def handle_call({:unsubscribe, _subscription}, _from, %{client: nil} = state) do
{:reply, {:error, :not_connected}, state}
end
def handle_call({:unsubscribe, subscription}, _from, %{client: client} = state) do
case WSClient.unsubscribe(client, subscription) do
:ok ->
new_subs = Enum.reject(state.subscriptions, &(&1.channel == subscription.channel))
{:reply, :ok, %{state | subscriptions: new_subs}}
{:ok, _response} ->
new_subs = Enum.reject(state.subscriptions, &(&1.channel == subscription.channel))
{:reply, :ok, %{state | subscriptions: new_subs}}
{:error, _} = error ->
{:reply, error, state}
end
end
def handle_call(:get_state, _from, state) do
# Backward compat: derive authenticated boolean from auth_state
compat_state = Map.put(state, :authenticated, state.auth_state == :authenticated)
{:reply, {:ok, compat_state}, state}
end
def handle_call(:auth_state, _from, state) do
{:reply, state.auth_state, state}
end
def handle_call(:get_connection_state, _from, %{client: nil, connect_task: {_, _}} = state) do
{:reply, :connecting, state}
end
def handle_call(:get_connection_state, _from, %{client: nil} = state) do
{:reply, :disconnected, state}
end
def handle_call(:get_connection_state, _from, %{client: client} = state) do
{:reply, WSClient.get_state(client), state}
end
def handle_call({:send_message, _message}, _from, %{client: nil} = state) do
{:reply, {:error, :not_connected}, state}
end
def handle_call({:send_message, message}, _from, %{client: client} = state) do
{:reply, WSClient.send_message(client, message), state}
end
# W11: Authentication handler
# Already authenticated — no-op
def handle_call(:authenticate, _from, %{auth_state: :authenticated} = state) do
{:reply, :ok, state}
end
def handle_call(:authenticate, _from, %{client: nil} = state) do
{:reply, {:error, :not_connected}, state}
end
def handle_call(:authenticate, _from, %{credentials: nil} = state) do
{:reply, {:error, :no_credentials}, state}
end
def handle_call(:authenticate, _from, state) do
auth_config = get_in(state.spec.ws, [:auth])
case auth_config do
nil ->
{:reply, {:error, :no_auth_config}, state}
config ->
do_authenticate(config, state)
end
end
end
end
@doc false
# Generates initial authentication functions (do_authenticate, send_auth_message, etc.)
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_initial_auth_ast do
quote do
@doc false
defp do_authenticate(config, state) do
pattern = config[:pattern]
market_type = resolve_market_type(state)
opts = [market_type: market_type]
context = %{pattern: pattern, market_type: market_type}
# Step 1: Pre-auth (REST token/listen key metadata if needed)
case Auth.pre_auth(pattern, state.credentials, config, opts) do
{:ok, pre_auth_data} when pre_auth_data == %{} ->
# WS-native pattern (no external pre-auth needed)
state = %{state | auth_state: :authenticating}
case Auth.build_auth_message(pattern, state.credentials, config, opts) do
{:ok, message} -> send_auth_message(message, context, state)
:no_message -> mark_auth_success(state, context)
{:error, _} = error -> {:reply, error, %{state | auth_state: :unauthenticated}}
end
{:ok, pre_auth_data} ->
# External pre-auth required (listen_key, rest_token).
# State stays :authenticating until caller completes via mark_authenticated/1.
# If the caller never follows through, a subsequent authenticate/1 call
# or disconnect will reset state. No automatic timeout — caller owns the flow.
ctx = Map.put(context, :pre_auth, pre_auth_data)
state = %{state | auth_state: :authenticating, auth_context: ctx}
{:reply, {:error, {:pre_auth_required, pre_auth_data}}, state}
{:error, _} = error ->
{:reply, error, state}
end
end
@doc false
defp send_auth_message(message, context, state) do
case WSClient.send_message(state.client, Jason.encode!(message)) do
:ok ->
mark_auth_success(state, context)
{:ok, response} ->
handle_auth_response(response, context, state)
{:error, _} = error ->
{:reply, error, %{state | auth_state: :unauthenticated}}
end
end
@doc false
defp handle_auth_response(response, context, state) do
case Auth.handle_auth_response(context.pattern, response, state) do
:ok ->
mark_auth_success(state, context)
{:ok, auth_meta} ->
mark_auth_success(state, context, auth_meta)
{:error, _} = error ->
{:reply, error, %{state | auth_state: :unauthenticated}}
end
end
@doc false
defp mark_auth_success(state, context, auth_meta \\ nil) do
# Cancel any existing auth expiry timer
if state.auth_timer_ref, do: Process.cancel_timer(state.auth_timer_ref)
auth_config = get_in(state.spec.ws, [:auth])
{timer_ref, expires_at} = schedule_auth_expiry(auth_meta, auth_config)
new_state = %{
state
| auth_state: :authenticated,
was_authenticated: true,
auth_context: context,
re_auth_attempts: 0,
auth_timer_ref: timer_ref,
auth_expires_at: expires_at
}
{:reply, :ok, new_state}
end
end
end
@doc false
# Generates re-authentication functions (do_re_authenticate, send_re_auth_message, etc.)
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_re_auth_ast do
quote do
# For re-authentication (async from handle_info)
@doc false
defp do_re_authenticate(config, state) do
pattern = config[:pattern]
market_type = resolve_market_type(state)
opts = [market_type: market_type]
context = %{pattern: pattern, market_type: market_type}
case Auth.pre_auth(pattern, state.credentials, config, opts) do
{:ok, pre_auth_data} when pre_auth_data == %{} ->
# WS-native: build and send auth message
case Auth.build_auth_message(pattern, state.credentials, config, opts) do
{:ok, message} -> send_re_auth_message(message, context, state)
:no_message -> re_auth_success(state, context)
{:error, _} = error -> error
end
{:ok, _pre_auth_data} ->
# External pre-auth: can't re-auth automatically
{:error, :pre_auth_required}
{:error, _} = error ->
error
end
end
@doc false
defp send_re_auth_message(message, context, state) do
case WSClient.send_message(state.client, Jason.encode!(message)) do
:ok ->
re_auth_success(state, context)
{:ok, response} ->
handle_re_auth_response(response, context, state)
{:error, _} = error ->
error
end
end
@doc false
defp handle_re_auth_response(response, context, state) do
case Auth.handle_auth_response(context.pattern, response, state) do
:ok ->
re_auth_success(state, context)
{:ok, auth_meta} ->
re_auth_success(state, context, auth_meta)
{:error, _} = error ->
error
end
end
@doc false
defp re_auth_success(state, context, auth_meta \\ nil) do
# Cancel any existing auth expiry timer
if state.auth_timer_ref, do: Process.cancel_timer(state.auth_timer_ref)
auth_config = get_in(state.spec.ws, [:auth])
{timer_ref, expires_at} = schedule_auth_expiry(auth_meta, auth_config)
{:ok,
%{
state
| auth_state: :authenticated,
auth_context: context,
re_auth_attempts: 0,
auth_timer_ref: timer_ref,
auth_expires_at: expires_at
}}
end
end
end
@doc false
# Generates auth expiry scheduling function
defp generate_auth_expiry_ast do
quote do
@doc false
# Computes and schedules auth expiry timer via Expiry pure functions.
# Returns {timer_ref, expires_at} or {nil, nil} when no TTL available.
defp schedule_auth_expiry(auth_meta, auth_config) do
ttl_ms = Expiry.compute_ttl_ms(auth_meta, auth_config)
case Expiry.schedule_delay_ms(ttl_ms) do
nil ->
{nil, nil}
delay_ms ->
ref = Process.send_after(self(), :auth_expired, delay_ms)
{ref, System.monotonic_time(:millisecond) + delay_ms}
end
end
end
end
@doc false
# Generates GenServer handle_cast/2 callbacks for authentication marking
defp generate_handle_cast_callbacks do
quote do
# =====================================================================
# GenServer Callbacks - handle_cast
# =====================================================================
@impl true
# External pre-auth path (listen_key/rest_token) — expiry is caller-managed,
# so no auth_expired timer is scheduled here intentionally.
def handle_cast(:mark_authenticated, state) do
{:noreply, %{state | auth_state: :authenticated, was_authenticated: true, re_auth_attempts: 0}}
end
end
end
@doc false
# Generates handle_info callbacks for :connect and connect_result messages
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_connect_info_ast(normalizer) do
build_handler_ast =
if normalizer do
quote do: build_handler(state.handler, state.normalize, state.validate)
else
quote do: build_handler(state.handler)
end
quote do
# =====================================================================
# GenServer Callbacks - handle_info
# =====================================================================
@impl true
# Guard: already connecting — don't spawn a second task
def handle_info(:connect, %{connect_task: {_, _}} = state) do
{:noreply, state}
end
def handle_info(:connect, state) do
connect_opts = Keyword.put(state.opts, :handler, unquote(build_handler_ast))
parent = self()
# Non-blocking connect via spawn_monitor (no link — crash won't kill GenServer)
# Use a unique tag so the spawned fn can reference it (avoiding macro hygiene issues
# with the monitor ref which isn't available until spawn_monitor returns).
tag = make_ref()
{_pid, monitor_ref} =
spawn_monitor(fn ->
result = maybe_listen_key_connect(state, connect_opts)
send(parent, {:connect_result, tag, result})
end)
{:noreply, %{state | connect_task: {tag, monitor_ref}}}
end
# Listen-key connect completed: already authenticated via URL
def handle_info({:connect_result, tag, {:ok, {:listen_key_connected, client}}}, state) do
case state.connect_task do
{^tag, monitor_ref} ->
Process.demonitor(monitor_ref, [:flush])
zen_client = WSClient.get_zen_client(client)
ws_monitor_ref = Process.monitor(zen_client.server_pid)
new_state = %{
state
| client: client,
monitor_ref: ws_monitor_ref,
connect_task: nil,
reconnect_attempts: 0,
auth_state: :authenticated,
was_authenticated: true,
re_auth_attempts: 0
}
# Restore subscriptions (skip re_authenticate — already done via URL)
if state.subscriptions != [], do: send(self(), :restore_subscriptions)
{:noreply, new_state}
_ ->
{:noreply, state}
end
end
# Async connect completed successfully
def handle_info({:connect_result, tag, {:ok, client}}, state) do
case state.connect_task do
{^tag, monitor_ref} ->
Process.demonitor(monitor_ref, [:flush])
zen_client = WSClient.get_zen_client(client)
ws_monitor_ref = Process.monitor(zen_client.server_pid)
new_state = %{
state
| client: client,
monitor_ref: ws_monitor_ref,
connect_task: nil,
reconnect_attempts: 0
}
# W11: Re-authenticate if was previously authenticated
if state.was_authenticated, do: send(self(), :re_authenticate)
# Restore subscriptions if any
if state.subscriptions != [], do: send(self(), :restore_subscriptions)
{:noreply, new_state}
_ ->
# Stale result from a previous connect attempt — ignore
{:noreply, state}
end
end
# Async connect returned an error
def handle_info({:connect_result, tag, {:error, reason}}, state) do
case state.connect_task do
{^tag, monitor_ref} ->
Process.demonitor(monitor_ref, [:flush])
Logger.warning("[#{inspect(__MODULE__)}] Connect failed: #{inspect(reason)}")
schedule_reconnect(state)
{:noreply, %{state | client: nil, monitor_ref: nil, connect_task: nil}}
_ ->
{:noreply, state}
end
end
end
end
@doc false
# Generates handle_info callbacks for :DOWN, :restore_subscriptions, :reconnect
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_monitor_info_ast do
quote do
# Connect worker crashed before sending result
def handle_info({:DOWN, down_ref, :process, _pid, reason}, %{connect_task: {_tag, down_ref}} = state) do
Logger.warning("[#{inspect(__MODULE__)}] Connect worker crashed: #{inspect(reason)}")
schedule_reconnect(state)
{:noreply, %{state | connect_task: nil}}
end
# WS client process died (monitored zen_client)
def handle_info({:DOWN, ref, :process, _pid, reason}, %{monitor_ref: ref} = state) do
Logger.warning("[#{inspect(__MODULE__)}] Client died: #{inspect(reason)}")
# Cancel auth expiry timer if active
if state.auth_timer_ref, do: Process.cancel_timer(state.auth_timer_ref)
new_state = %{
state
| client: nil,
monitor_ref: nil,
auth_state: :unauthenticated,
auth_timer_ref: nil,
auth_expires_at: nil
}
schedule_reconnect(new_state)
{:noreply, new_state}
end
def handle_info(:restore_subscriptions, %{client: nil} = state) do
# Not connected yet, will be called again after connect
{:noreply, state}
end
def handle_info(:restore_subscriptions, %{client: client, subscriptions: subs} = state) do
case WSClient.restore_subscriptions(client, subs) do
:ok ->
Logger.debug("[#{inspect(__MODULE__)}] Restored #{Enum.count(subs)} subscriptions")
{:error, reason} ->
Logger.warning("[#{inspect(__MODULE__)}] Failed to restore subscriptions: #{inspect(reason)}")
end
{:noreply, state}
end
def handle_info(:reconnect, state) do
if state.reconnect_attempts < @max_reconnect_attempts do
Logger.info("[#{inspect(__MODULE__)}] Reconnecting (attempt #{state.reconnect_attempts + 1})")
send(self(), :connect)
{:noreply, %{state | reconnect_attempts: state.reconnect_attempts + 1}}
else
Logger.error("[#{inspect(__MODULE__)}] Max reconnection attempts reached")
{:stop, :max_reconnection_attempts, state}
end
end
end
end
@doc false
# Generates handle_info callbacks for :re_authenticate, :auth_expired, catch-all
defp generate_auth_info_ast do
quote do
# W11: Re-authenticate after reconnect
def handle_info(:re_authenticate, %{client: nil} = state) do
# Not connected yet, will be triggered again on next connect
{:noreply, state}
end
def handle_info(:re_authenticate, state) do
auth_config = get_in(state.spec.ws, [:auth])
case auth_config do
nil ->
Logger.warning("[#{inspect(__MODULE__)}] Re-auth requested but no auth config")
{:noreply, state}
config ->
state = %{state | auth_state: :authenticating}
case do_re_authenticate(config, state) do
{:ok, new_state} ->
Logger.debug("[#{inspect(__MODULE__)}] Re-authenticated successfully")
{:noreply, new_state}
{:error, reason} ->
Logger.warning("[#{inspect(__MODULE__)}] Re-auth failed: #{inspect(reason)}")
schedule_re_auth_retry(state)
end
end
end
# Auth expiry handler — transitions to :expired, triggers re-auth
def handle_info(:auth_expired, state) do
Logger.info("[#{inspect(__MODULE__)}] Auth expired, triggering re-authentication")
new_state = %{state | auth_state: :expired, auth_timer_ref: nil, auth_expires_at: nil}
send(self(), :re_authenticate)
{:noreply, new_state}
end
def handle_info(_msg, state) do
{:noreply, state}
end
end
end
@doc false
# Assembles private helper functions from sub-generators.
# Conditionally generates deliver_message/N based on envelope config.
defp generate_private_helpers(spec_id, normalizer, _contract) do
envelope = WsHandlerMappings.envelope_pattern(spec_id)
deliver_message_ast = generate_deliver_message_ast(envelope, normalizer)
quote do
unquote(generate_reconnect_helpers_ast())
unquote(generate_market_type_helpers_ast())
unquote(generate_listen_key_helpers_ast())
unquote(generate_auth_enrichment_ast())
unquote(deliver_message_ast)
unquote(generate_build_handler_ast(normalizer))
end
end
@doc false
# Generates the appropriate deliver_message/N variant based on envelope and normalizer config
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_deliver_message_ast(envelope, normalizer) do
case {envelope != nil, normalizer != nil} do
{true, true} ->
# Full routing + normalization + validation pipeline
quote do
@doc false
# Bypass normalization when normalize=false — raw passthrough
defp deliver_message(decoded, user_handler, false, _validate) do
user_handler.({:raw, decoded})
end
defp deliver_message(decoded, user_handler, true, validate) do
case MessageRouter.route(decoded, @ws_envelope, @ws_exchange_id) do
{:routed, family, payload, channel} ->
normalize_and_deliver(family, payload, user_handler, validate, channel)
{:system, _msg} ->
user_handler.({:system, decoded})
{:unknown, _msg} ->
user_handler.({:raw, decoded})
end
end
@doc false
defp normalize_and_deliver(family, payload, user_handler, validate, channel) do
case Normalizer.normalize(family, payload, @ws_exchange_module, channel) do
{:ok, normalized} ->
maybe_validate(family, normalized, validate)
user_handler.({family, normalized})
{:error, _reason} ->
user_handler.({family, payload})
end
end
@doc false
defp maybe_validate(_family, _normalized, false), do: :ok
defp maybe_validate(family, normalized, true) do
case Contract.validate(family, normalized) do
{:ok, _} ->
:ok
{:error, violations} ->
Logger.warning("[WS.Adapter] Contract violation for #{family}: #{inspect(violations)}")
end
end
end
{true, false} ->
# Routing only, raw family+payload delivery (current ccxt_ex default)
quote do
@doc false
# Routes via MessageRouter when envelope config is available
defp deliver_message(decoded, user_handler) do
case MessageRouter.route(decoded, @ws_envelope, @ws_exchange_id) do
{:routed, family, payload, _channel} ->
user_handler.({family, payload})
{:system, _msg} ->
user_handler.({:system, decoded})
{:unknown, _msg} ->
user_handler.({:raw, decoded})
end
end
end
{false, true} ->
# No envelope, but normalizer present — passthrough with /4 arity
quote do
@doc false
defp deliver_message(decoded, user_handler, _normalize, _validate) do
user_handler.({:raw, decoded})
end
end
{false, false} ->
# No envelope, no normalizer — simple passthrough
quote do
@doc false
# No envelope config — pass raw decoded messages directly
defp deliver_message(decoded, user_handler) do
user_handler.({:raw, decoded})
end
end
end
end
@doc false
# Generates schedule_reconnect and schedule_re_auth_retry helpers
defp generate_reconnect_helpers_ast do
quote do
# =====================================================================
# Private Helpers
# =====================================================================
@doc false
# Schedules a reconnection attempt with exponential backoff
defp schedule_reconnect(state) do
# Exponential backoff: delay * 2^attempts, capped at max_backoff_ms
delay = min(@reconnect_delay_ms * :math.pow(2, state.reconnect_attempts), @max_backoff_ms)
Process.send_after(self(), :reconnect, trunc(delay))
end
@doc false
# Schedules a re-auth retry with exponential backoff, or gives up after max attempts
defp schedule_re_auth_retry(state) do
attempts = state.re_auth_attempts + 1
if attempts > @max_re_auth_attempts do
Logger.error("[#{inspect(__MODULE__)}] Max re-auth attempts (#{@max_re_auth_attempts}) reached")
{:noreply, %{state | auth_state: :unauthenticated, re_auth_attempts: attempts}}
else
delay = min(@re_auth_base_delay_ms * :math.pow(2, attempts - 1), @max_backoff_ms)
Process.send_after(self(), :re_authenticate, trunc(delay))
{:noreply, %{state | auth_state: :unauthenticated, re_auth_attempts: attempts}}
end
end
end
end
@doc false
# Generates resolve_market_type and derive_market_type_from_url_path helpers
defp generate_market_type_helpers_ast do
quote do
@doc false
# Resolves market type from auth_context, url_path derivation, or :spot default
defp resolve_market_type(state) do
(state.auth_context && state.auth_context[:market_type]) ||
derive_market_type_from_url_path(state.url_path) ||
:spot
end
@doc false
defp derive_market_type_from_url_path(path) when is_list(path) do
Enum.find(path, fn
t when t in [:spot, :linear, :inverse, :option, :swap, :future, :contract] -> true
_ -> false
end)
end
defp derive_market_type_from_url_path(_), do: nil
end
end
@doc false
# Generates listen key acquisition functions (maybe_listen_key_connect, etc.)
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_listen_key_helpers_ast do
quote do
@doc false
# Checks if auth pattern is :listen_key with credentials, and if so,
# acquires listen key via REST before connecting. Otherwise, normal connect.
defp maybe_listen_key_connect(state, connect_opts) do
auth_config = get_in(state.spec.ws, [:auth])
if auth_config[:pattern] == :listen_key && state.credentials do
acquire_listen_key_and_connect(state, connect_opts, auth_config)
else
WSClient.connect(state.spec, state.url_path, connect_opts)
end
end
@doc false
# Acquires a listen key via REST, appends it to the WS URL, and connects.
# Returns {:ok, {:listen_key_connected, client}} on success.
defp acquire_listen_key_and_connect(state, connect_opts, auth_config) do
market_type = resolve_market_type(state)
with {:ok, pre_auth_data} <-
Auth.pre_auth(:listen_key, state.credentials, auth_config, market_type: market_type),
{:ok, listen_key} <- fetch_listen_key(pre_auth_data, state),
{:ok, ws_base_url} <- Helpers.resolve_url(state.spec, state.url_path, connect_opts),
ws_url = ws_base_url <> "/" <> listen_key,
{:ok, client} <-
WSClient.connect(state.spec, state.url_path, Keyword.put(connect_opts, :url, ws_url)) do
{:ok, {:listen_key_connected, client}}
end
end
@doc false
# Makes the REST call to acquire a listen key from Binance-style endpoints.
# Requires api_section and path in pre_auth_data (from enriched extractor).
defp fetch_listen_key(pre_auth_data, state) do
with {:ok, url} <- build_listen_key_url(pre_auth_data, state) do
# TODO: Header is Binance-specific. If another exchange adopts :listen_key,
# extract header name to spec metadata.
headers = [{"X-MBX-APIKEY", state.credentials.api_key}]
do_fetch_listen_key(url, headers)
end
end
@doc false
defp build_listen_key_url(pre_auth_data, state) do
api_section = pre_auth_data[:api_section]
path = pre_auth_data[:path]
if is_nil(api_section) or is_nil(path) do
{:error,
{:listen_key_missing_config,
%{api_section: api_section, path: path, hint: "Spec may need re-sync: mix ccxt.sync binance --force"}}}
else
sandbox? = state.credentials.sandbox == true
base_url = CCXT.Spec.rest_api_url(state.spec, api_section, sandbox?)
if base_url,
do: {:ok, base_url <> path},
else: {:error, {:listen_key_no_base_url, %{api_section: api_section, sandbox: sandbox?}}}
end
end
@doc false
defp do_fetch_listen_key(url, headers) do
case CCXT.HTTP.Client.raw_request(:post, url, headers, "", []) do
{:ok, %{status: 200, body: body}} -> extract_listen_key(body)
{:ok, %{status: status, body: body}} -> {:error, {:listen_key_http_error, %{status: status, body: body}}}
{:error, reason} -> {:error, {:listen_key_request_failed, reason}}
end
end
@doc false
defp extract_listen_key(body) when is_map(body) do
case body["listenKey"] do
key when is_binary(key) -> {:ok, key}
_ -> {:error, {:listen_key_missing_in_response, body}}
end
end
defp extract_listen_key(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, %{"listenKey" => key}} -> {:ok, key}
_ -> {:error, {:listen_key_parse_error, body}}
end
end
end
end
@doc false
# Generates maybe_enrich_with_auth for inline_subscribe pattern
defp generate_auth_enrichment_ast do
quote do
@doc false
# For inline_subscribe pattern: merge auth data into subscribe message
# when the subscription requires authentication.
defp maybe_enrich_with_auth(%{auth_required: true, message: message} = sub, state) do
auth_config = get_in(state.spec.ws, [:auth])
pattern = auth_config && auth_config[:pattern]
if pattern == :inline_subscribe && state.credentials do
case Auth.build_subscribe_auth(pattern, state.credentials, auth_config, nil, nil) do
nil -> sub
auth_data -> %{sub | message: Map.merge(message, auth_data)}
end
else
sub
end
end
defp maybe_enrich_with_auth(sub, _state), do: sub
end
end
@doc false
# Generates build_handler and safe_decode_and_deliver, conditioned on normalizer
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp generate_build_handler_ast(normalizer) do
if normalizer do
quote do
@doc false
defp build_handler(nil, _normalize, _validate), do: nil
defp build_handler(user_handler, normalize, validate)
when is_function(user_handler, 1) do
# Wrap the user handler to decode, optionally route+normalize, then deliver
fn
{:message, {:text, data}} ->
safe_decode_and_deliver(data, user_handler, normalize, validate)
{:message, {:binary, data}} ->
user_handler.(data)
{:message, data} when is_binary(data) ->
safe_decode_and_deliver(data, user_handler, normalize, validate)
{:message, data} when is_map(data) ->
deliver_message(data, user_handler, normalize, validate)
other ->
user_handler.(other)
end
end
@doc false
# Decodes JSON safely, falling back to raw delivery on malformed data
defp safe_decode_and_deliver(data, user_handler, normalize, validate) do
case Jason.decode(data) do
{:ok, decoded} ->
deliver_message(decoded, user_handler, normalize, validate)
{:error, _reason} ->
Logger.warning("[#{inspect(__MODULE__)}] Failed to decode WS message as JSON")
user_handler.({:raw, data})
end
end
end
else
quote do
@doc false
defp build_handler(nil), do: nil
defp build_handler(user_handler) when is_function(user_handler, 1) do
# Wrap the user handler to decode, route via MessageRouter, then deliver
fn
{:message, {:text, data}} ->
safe_decode_and_deliver(data, user_handler)
{:message, {:binary, data}} ->
user_handler.(data)
{:message, data} when is_binary(data) ->
safe_decode_and_deliver(data, user_handler)
{:message, data} when is_map(data) ->
deliver_message(data, user_handler)
other ->
user_handler.(other)
end
end
@doc false
# Decodes JSON safely, falling back to raw delivery on malformed data
defp safe_decode_and_deliver(data, user_handler) do
case Jason.decode(data) do
{:ok, decoded} ->
deliver_message(decoded, user_handler)
{:error, _reason} ->
Logger.warning("[#{inspect(__MODULE__)}] Failed to decode WS message as JSON")
user_handler.({:raw, data})
end
end
end
end
end
@doc false
# Generates the @moduledoc string for the adapter module with usage examples
@spec generate_adapter_moduledoc(module()) :: String.t()
defp generate_adapter_moduledoc(rest_module) do
"""
Managed WebSocket adapter for #{inspect(rest_module)}.
This adapter provides:
- Automatic reconnection with exponential backoff
- Subscription tracking and restoration
- Authentication state tracking
## Usage
# Start the adapter
{:ok, adapter} = Adapter.start_link(
name: :my_ws,
url_path: [:public, :spot],
handler: fn msg -> handle_message(msg) end
)
# Subscribe to channels
:ok = Adapter.subscribe(adapter, subscription)
# Check connection status
if Adapter.connected?(adapter) do
# Send message
Adapter.send_message(adapter, %{"action" => "ping"})
end
## Authentication
For private endpoints, authentication logic is implemented separately.
After authenticating, call `mark_authenticated/1` so the adapter knows
to re-authenticate on reconnection.
"""
end
end