Packages

An Elixir client for the Polymarket API.

Current section

Files

Jump to
ex_polymarket lib websocket websocket.ex
Raw

lib/websocket/websocket.ex

defmodule Polymarket.WebSocket do
@moduledoc """
A WebSocket client for the Polymarket CLOB market feed.
Wraps `Mint.WebSocket` in a `GenServer`, handling the HTTP upgrade,
periodic pings, and decoding of incoming text frames.
"""
use GenServer
use TypedStruct
alias Polymarket.WebSocket.Handlers.Noop
alias Polymarket.WebSocket.MessageHandler
require Logger
require Mint.HTTP
# The CLOB market feed lives at a single fixed endpoint.
@ws_host "ws-subscriptions-clob.polymarket.com"
@ws_port 443
@ws_path "/ws/market"
# `Mint.WebSocket.new/4`'s success typing is inferred as error-only (a known
# Dialyzer limitation: the `with` flow inside its private `do_new/4` hides the
# `{:ok, conn, websocket}` return), so the matching clause in `handle_responses/2`
# is flagged as unreachable even though it fires on every successful upgrade.
@dialyzer {:no_match, handle_responses: 2}
typedstruct do
field(:conn, Mint.HTTP.t())
field(:websocket, Mint.WebSocket.t())
field(:request_ref, Mint.Types.request_ref())
field(:caller, GenServer.from())
field(:status, pos_integer())
field(:resp_headers, Mint.Types.headers())
field(:closing?, boolean())
field(:timer_ref, :timer.tref())
field(:last_pong, DateTime.t())
field(:handler, module(), default: Noop)
end
# ---------------------------------------------------------------------------#
# API #
# ---------------------------------------------------------------------------#
@doc """
Starts a websocket connection.
## Options
* `:handler` — module implementing `Polymarket.WebSocket.Handler` that
receives every decoded event. Defaults to `Polymarket.WebSocket.Handlers.Noop`.
Any other options are ignored.
"""
@spec start_link(keyword()) :: {:ok, pid()} | {:error, term()}
def start_link(opts) do
handler = Keyword.get(opts, :handler, Noop)
# The socket process decodes every inbound frame, so it churns short-lived
# maps/structs/floats at a high rate. A larger starting heap avoids constant
# young-gen GC (measured ~99% fewer collections, ~1.5x faster parse); an
# off-heap mailbox keeps GC pauses bounded under burst backlog. Costs ~1.6 MB
# resident heap per connection — size `min_heap_size` against your shard count.
spawn_opt = [min_heap_size: 64_000, message_queue_data: :off_heap, fullsweep_after: 20]
case GenServer.start_link(__MODULE__, handler, spawn_opt: spawn_opt) do
{:ok, socket} ->
case GenServer.call(socket, :connect) do
{:ok, :connected} ->
message = ~S"""
{
"assets_ids": [],
"type": "market",
"custom_feature_enabled": true
}
"""
send_message(socket, message)
{:ok, socket}
{:error, reason} ->
# The process started but never connected; stop it so a failed
# connect does not leave it lingering under the caller/supervisor.
GenServer.stop(socket)
{:error, reason}
end
error ->
error
end
end
@doc """
Sends a string as a message over the websocket.
"""
@spec send_message(pid(), String.t()) :: :ok
def send_message(pid, text) do
GenServer.call(pid, {:send_text, text})
end
# ---------------------------------------------------------------------------#
# GenServer #
# ---------------------------------------------------------------------------#
@impl GenServer
def init(handler) do
{:ok, timer} = :timer.send_interval(to_timeout(second: 3), :send_ping)
{:ok, %__MODULE__{timer_ref: timer, handler: handler}}
end
@impl GenServer
def handle_call({:send_text, text}, _from, state) do
{:ok, state} = send_frame(state, {:text, text})
{:reply, :ok, state}
end
@impl GenServer
def handle_call(:connect, from, state) do
# `nodelay: true` disables Nagle's algorithm so small outbound frames (pings
# and, critically, order submissions sent in response to market data) are not
# held back by the TCP stack. Mint does not set this by default, so Nagle is
# otherwise on. User `transport_opts` survive Mint's option merge.
with {:ok, conn} <-
Mint.HTTP.connect(:https, @ws_host, @ws_port,
protocols: [:http1],
transport_opts: [nodelay: true]
),
{:ok, conn, ref} <- Mint.WebSocket.upgrade(:wss, conn, @ws_path, []) do
state = %{state | conn: conn, request_ref: ref, caller: from}
{:noreply, state}
else
{:error, reason} ->
{:reply, {:error, reason}, state}
{:error, conn, reason} ->
{:reply, {:error, reason}, put_in(state.conn, conn)}
end
end
@impl GenServer
def handle_info(:send_ping, state) do
{:ok, state} = send_frame(state, {:text, "PING"})
{:noreply, state}
end
def handle_info(message, state) do
case Mint.WebSocket.stream(state.conn, message) do
{:ok, conn, responses} ->
state =
put_in(state.conn, conn)
|> handle_responses(responses)
if state.closing? do
do_close(state)
else
{:noreply, state}
end
{:error, conn, reason, _responses} ->
Logger.debug("Error: #{inspect(binding())}")
state =
put_in(state.conn, conn)
|> reply({:error, reason})
{:stop, :server_disconnected, state}
:unknown ->
{:noreply, state}
end
end
# ---------------------------------------------------------------------------#
# Helpers #
# ---------------------------------------------------------------------------#
# ---------------------------------------------------------------------------
# Handle Response
@spec handle_responses(t(), [Mint.Types.response()]) :: t()
defp handle_responses(state, responses)
defp handle_responses(%{request_ref: ref} = state, [{:status, ref, status} | rest]) do
Logger.debug("Status: #{inspect(status)}")
put_in(state.status, status)
|> handle_responses(rest)
end
defp handle_responses(%{request_ref: ref} = state, [{:headers, ref, resp_headers} | rest]) do
Logger.debug("Headers: #{inspect(resp_headers)}")
put_in(state.resp_headers, resp_headers)
|> handle_responses(rest)
end
# executes
defp handle_responses(%{request_ref: ref, status: status, resp_headers: resp_headers} = state, [{:done, ref} | rest])
when is_integer(status) and is_list(resp_headers) do
Logger.debug("Done")
case Mint.WebSocket.new(state.conn, ref, status, resp_headers) do
{:ok, conn, websocket} ->
%{state | conn: conn, websocket: websocket, status: nil, resp_headers: nil}
|> reply({:ok, :connected})
|> handle_responses(rest)
{:error, conn, reason} ->
Logger.error("Error in response #{inspect(binding())}")
put_in(state.conn, conn)
|> reply({:error, reason})
end
end
defp handle_responses(%{request_ref: ref, websocket: websocket} = state, [{:data, ref, data} | rest])
when websocket != nil do
case Mint.WebSocket.decode(websocket, data) do
{:ok, websocket, frames} ->
put_in(state.websocket, websocket)
|> handle_frames(frames)
|> handle_responses(rest)
{:error, websocket, reason} ->
put_in(state.websocket, websocket)
|> reply({:error, reason})
end
end
defp handle_responses(state, [_response | rest]) do
handle_responses(state, rest)
end
defp handle_responses(state, []), do: state
# ---------------------------------------------------------------------------
# Send Frame
@spec send_frame(t(), Mint.WebSocket.frame() | Mint.WebSocket.shorthand_frame()) ::
{:ok, t()} | {:error, t(), term()}
defp send_frame(state, frame) do
case Mint.WebSocket.encode(state.websocket, frame) do
{:ok, websocket, data} ->
state = put_in(state.websocket, websocket)
case Mint.WebSocket.stream_request_body(state.conn, state.request_ref, data) do
{:ok, conn} -> {:ok, put_in(state.conn, conn)}
{:error, conn, reason} -> {:error, put_in(state.conn, conn), reason}
end
{:error, websocket, reason} ->
{:error, put_in(state.websocket, websocket), reason}
end
end
# ---------------------------------------------------------------------------
# Receive Frame
@spec handle_frames(t(), [Mint.WebSocket.frame()]) :: t()
defp handle_frames(state, frames) do
Enum.reduce(frames, state, fn
# reply to pings with pongs
{:ping, data}, state ->
Logger.debug("Received ping")
{:ok, state} = send_frame(state, {:pong, data})
state
{:close, _code, reason}, state ->
Logger.debug("Closing connection: #{inspect(reason)}")
%{state | closing?: true}
{:text, "PONG"}, state ->
Logger.debug("Received pong")
put_in(state.last_pong, DateTime.utc_now())
{:text, text}, state ->
handle_text(text, state)
frame, state ->
Logger.debug("Unexpected frame received: #{inspect(frame)}")
state
end)
end
@spec handle_text(String.t(), t()) :: t()
defp handle_text(text, state) do
# try and decode the message to json. the message can be a list of
# events, so deal with those too.
case decode_message(text) do
{:ok, messages} ->
messages
# Can be a single or a list of messages, so always turn it into a list.
|> List.wrap()
|> Enum.reduce(state, fn message, state ->
MessageHandler.handle_message(message, state)
|> process_result()
end)
{:error, err} ->
Logger.warning("failed to decode message #{text} #{inspect(err)}")
state
end
end
# ---------------------------------------------------------------------------
# Closing
@spec do_close(t()) :: {:stop, :normal, t()}
defp do_close(state) do
Logger.debug("Closing websocket #{inspect(state)}")
# Streaming a close frame may fail if the server has already closed
# for writing.
_ = send_frame(state, :close)
Mint.HTTP.close(state.conn)
{:stop, :normal, state}
end
# ---------------------------------------------------------------------------
# Reply
@spec reply(t(), term()) :: t()
defp reply(state, response) do
if state.caller, do: GenServer.reply(state.caller, response)
put_in(state.caller, nil)
end
# ---------------------------------------------------------------------------
# Decoding
@spec decode_message(String.t()) :: {:ok, term()} | {:error, Jason.DecodeError.t()}
defp decode_message(message), do: Jason.decode(message, keys: :atoms)
# ---------------------------------------------------------------------------
# Process response from handler
@spec process_result(
{:reply, {:text, String.t()}, t()}
| {:noreply, t()}
) :: t()
defp process_result({:reply, frame, state}) do
{:ok, state} = send_frame(state, frame)
state
end
defp process_result({:noreply, state}) do
state
end
end