Packages
A robust WebSocket client library for Elixir, built on Gun transport for production-grade reliability. Designed for financial APIs with automatic reconnection, comprehensive error handling, and real-world testing.
Current section
Files
Jump to
Current section
Files
lib/zen_websocket/request_correlator.ex
defmodule ZenWebsocket.RequestCorrelator do
@moduledoc """
Manages request/response correlation for JSON-RPC WebSocket connections.
Pure functional module - state ownership stays with Client GenServer.
Tracks pending requests with timeouts and matches responses by ID.
## Telemetry Events
The following telemetry events are emitted:
* `[:zen_websocket, :request_correlator, :track]` - Emitted when a request is tracked.
* Measurements: `%{count: 1}`
* Metadata: `%{id: id, timeout_ms: timeout}`
* `[:zen_websocket, :request_correlator, :resolve]` - Emitted when a response is matched.
* Measurements: `%{count: 1, round_trip_ms: milliseconds}`
* Metadata: `%{id: id}`
* `[:zen_websocket, :request_correlator, :timeout]` - Emitted when a request times out.
* Measurements: `%{count: 1}`
* Metadata: `%{id: id}`
* `[:zen_websocket, :request_correlator, :fail_all]` - Emitted for each pending request
failed via `fail_all/2` (e.g., on connection loss).
* Measurements: `%{count: 1}`
* Metadata: `%{id: id, reason: reason}`
"""
use Descripex, namespace: "/correlation"
@typedoc "Client state map containing pending_requests field (subset of Client.state)"
@type state :: %{
:pending_requests => %{optional(term()) => {GenServer.from(), reference(), integer()}},
optional(atom()) => term()
}
api(:extract_id, "Extract the request ID from a JSON message.",
params: [message: [kind: :value, description: "Raw JSON binary message"]],
returns: %{type: "{:ok, term()} | :no_id", description: "Extracted ID or :no_id if absent/invalid"}
)
@doc """
Extracts the request ID from a JSON message.
Returns `{:ok, id}` if the message contains a non-nil ID field,
or `:no_id` if no ID is present, the message is not valid JSON,
or the ID is nil.
"""
@spec extract_id(binary()) :: {:ok, term()} | :no_id
def extract_id(message) when is_binary(message) do
case Jason.decode(message) do
{:ok, %{"id" => id}} when not is_nil(id) -> {:ok, id}
_ -> :no_id
end
end
def extract_id(_), do: :no_id
api(:track, "Track a pending request with a timeout timer.",
params: [
state: [kind: :value, description: "Client state containing pending_requests"],
id: [kind: :value, description: "Request ID to track"],
from: [kind: :value, description: "GenServer caller reference"],
timeout_ms: [kind: :value, description: "Timeout in milliseconds"]
],
returns: %{
type: "{:ok, state()} | {:error, :duplicate_id, state()}",
description: "Updated state on success, or unchanged state on duplicate ID collision"
}
)
@doc """
Tracks a pending request with a timeout timer.
Creates a timer that will send `{:timeout, timeout_ref, {:correlation_timeout, id}}`
to `self()` after the specified timeout. Must be called from within a
GenServer context.
Returns `{:ok, new_state}` on success. If `id` already has a pending entry,
returns `{:error, :duplicate_id, state}` with `state` unchanged — the
existing caller's `from` and timeout timer are preserved. No timer is
started and no `:track` telemetry is emitted on the duplicate path.
"""
@spec track(state(), term(), GenServer.from(), pos_integer()) ::
{:ok, state()} | {:error, :duplicate_id, state()}
def track(state, id, from, timeout_ms) do
if Map.has_key?(state.pending_requests, id) do
{:error, :duplicate_id, state}
else
timeout_ref = :erlang.start_timer(timeout_ms, self(), {:correlation_timeout, id})
start_time = System.monotonic_time(:millisecond)
pending = Map.put(state.pending_requests, id, {from, timeout_ref, start_time})
:telemetry.execute(
[:zen_websocket, :request_correlator, :track],
%{count: 1},
%{id: id, timeout_ms: timeout_ms}
)
{:ok, %{state | pending_requests: pending}}
end
end
api(:resolve, "Resolve a pending request by ID, returning the caller info.",
params: [
state: [kind: :value, description: "Client state containing pending_requests"],
id: [kind: :value, description: "Request ID to resolve"]
],
returns: %{
type: "{{GenServer.from(), reference(), integer()} | nil, state()}",
description: "Entry tuple (or nil) and updated state"
}
)
@doc """
Resolves a pending request by ID, returning the caller info.
Cancels the timeout timer and removes the request from pending.
Returns `{entry, new_state}` where entry is `{from, timeout_ref, start_time}` or `nil`.
"""
@spec resolve(state(), term()) :: {{GenServer.from(), reference(), integer()} | nil, state()}
def resolve(state, id) do
case Map.pop(state.pending_requests, id) do
{nil, _} ->
{nil, state}
{{_from, timeout_ref, start_time} = entry, new_pending} ->
Process.cancel_timer(timeout_ref, async: false, info: false)
round_trip_ms = System.monotonic_time(:millisecond) - start_time
:telemetry.execute(
[:zen_websocket, :request_correlator, :resolve],
%{count: 1, round_trip_ms: round_trip_ms},
%{id: id}
)
{entry, %{state | pending_requests: new_pending}}
end
end
api(:timeout, "Handle a timeout for a pending request.",
params: [
state: [kind: :value, description: "Client state containing pending_requests"],
id: [kind: :value, description: "Request ID that timed out"]
],
returns: %{
type: "{{GenServer.from(), reference(), integer()} | nil, state()}",
description: "Entry tuple (or nil) and updated state"
}
)
@doc """
Handles a timeout for a pending request.
Removes the request from pending and returns the caller info.
Returns `{entry, new_state}` where entry is `{from, timeout_ref, start_time}` or `nil`.
"""
@spec timeout(state(), term()) :: {{GenServer.from(), reference(), integer()} | nil, state()}
def timeout(state, id) do
case Map.pop(state.pending_requests, id) do
{nil, _} ->
{nil, state}
{entry, new_pending} ->
:telemetry.execute(
[:zen_websocket, :request_correlator, :timeout],
%{count: 1},
%{id: id}
)
{entry, %{state | pending_requests: new_pending}}
end
end
api(:fail_all, "Fail every pending request with the given reason.",
params: [
state: [kind: :value, description: "Client state containing pending_requests"],
reason: [kind: :value, description: "Error reason delivered to each caller (e.g., :disconnected)"]
],
returns: %{type: "state()", description: "State with pending_requests cleared"}
)
@doc """
Fails every pending request with `{:error, reason}` and cancels their timeout timers.
Used when the connection drops and in-flight correlated responses can no longer
arrive. Callers blocked on `GenServer.call` receive the reply immediately
instead of waiting for their per-call timeout.
"""
@spec fail_all(state(), term()) :: state()
def fail_all(state, reason) do
Enum.each(state.pending_requests, fn {id, {from, timeout_ref, _start_time}} ->
Process.cancel_timer(timeout_ref, async: false, info: false)
GenServer.reply(from, {:error, reason})
:telemetry.execute(
[:zen_websocket, :request_correlator, :fail_all],
%{count: 1},
%{id: id, reason: reason}
)
end)
%{state | pending_requests: %{}}
end
api(:pending_count, "Return the count of pending requests.",
params: [state: [kind: :value, description: "Client state containing pending_requests"]],
returns: %{type: "non_neg_integer()", description: "Number of requests awaiting response"}
)
@doc """
Returns the count of pending requests.
"""
@spec pending_count(state()) :: non_neg_integer()
def pending_count(state) do
map_size(state.pending_requests)
end
end