This venue's WebSocket connection — internal. A consumer never sees this module, never holds this pid, and cannot tell from the facade that it exists.
The venue dials its own socket
This used to be injected: shared code opened the connection and handed the adapter an
open/subscribe pair, because the connection machinery lived in a boundary the
adapters could not reference. That constraint was an artefact of one application's
module layout, and it cost more than it saved — shared code was making transport
decisions with information only the venue has.
The venue keeps the policy either way: how many connections, which channels, how many pairs each carries, in what order and at what pace. What changed is that it now also owns the mechanism, so there is no seam for the two to disagree across.
Public channels take no JWT, and attaching one is actively harmful
Coinbase answers a bogus token with {"type":"error","message":"authentication failure"} — measured 2026-08-07. An earlier version attached a token to every channel
on the theory that it could not hurt. It could: the token was a stub returning the raw
API key, so level2 produced nothing while ticker, which is public, worked fine.
A venue half-delivering looks like a quiet market rather than a broken credential.
Authenticated channels get a real JWT from DpExchange.Coinbase.Auth, built fresh per
subscribe rather than cached — its window is two minutes, and a token that outlives it
fails the same silent way.
Every frame goes through FrameSender
Never WebSockex.send_frame/2 directly. See that module for why; the short version is
that it exits rather than returning, and the exit kills this connection.
level2 deltas are passed straight through, never accumulated
A snapshot event carries the venue's whole book as of subscribe time; an update
event carries only the price levels that changed, with new_quantity: "0" meaning
the level at that price ceased to exist — not a price of zero.
This socket used to hold that state itself: one ordered structure of price →
quantity per side per symbol, rebuilt into a DpExchange.Core.Types.OrderBook on
every frame including an update that touched a single row. Measured at the book
size DpCryptoManagement reported live for BTC-USD (~22,800 bid / ~21,100 ask
levels, issue #22): 65–110 ms per frame before an ordered-structure fix, 6.6 ms
after it — cost paid on the same single-threaded process responsible for
WebSockex.send_frame/2, so a socket busy rebuilding a book it was never asked to
keep could not service its own sends, which is the :send_timeout behind issue #22.
See dp_exchange_core's docs/design/closed/2026-09-06_stop-maintaining-books-in-packages.md:
holding market state here was never this socket's job, and making that work cheaper
was treating the symptom rather than removing the cause.
It holds none of that now. A snapshot decodes straight into
DpExchange.Core.Types.OrderBook — bids and asks sorted once, because sorting a
single frame's own rows is decode work, not the maintenance this module no longer
does. An update decodes straight into DpExchange.Core.Types.OrderBookDelta — the
venue's own changed rows, in the venue's own order, both sides interleaved exactly as
the frame carried them: not re-sorted, not split into two lists beyond what the
venue's own side field already says, not folded into anything held here. A zero
new_quantity is carried through exactly as the venue sent it; resolving it —
dropping the row, treating it as "no size" — would be state-keeping wearing a
smaller shape, and state-keeping is exactly what this module stopped doing.
Why a caller still cannot mistake a delta for the whole book
The reason the maintained book existed in the first place is real, and the failure it
prevented is worth keeping on record rather than only the fact that a guard once
stood here: a caller reading a single l2_data delta as though it were the whole book
would see a handful of prices and nothing else — a book with everything but a
couple of levels simply missing, not a partial update honestly labelled as one.
What changed is how that is prevented. A distinct type is the fix, not accumulated
state: %DpExchange.Core.Types.OrderBookDelta{} is not
%DpExchange.Core.Types.OrderBook{}, so a caller cannot read one as the other — the
struct itself says which one it is holding, at compile time and at a glance. The
original defect was a snapshot-shaped value carrying delta content; a delta with its
own type has nothing snapshot-shaped left to be mistaken for.
A reconnect has nothing to wipe, and the gap that follows is now the host's problem
A reconnect used to lose the maintained book, because the venue's own session went
with it — handle_disconnect/2 cleared every symbol's book, and the next snapshot
rebuilt it fresh. There is no book to lose now, so handle_disconnect/2 clears
nothing beyond what it always cleared for delivery bookkeeping.
What was true then is still true, and now visible instead of silently absorbed:
deltas delivered after a reconnect are not contiguous with deltas delivered
before it. handle_connect/2's :link_up notice and handle_disconnect/2's
:link_down notice bracket where that gap may fall; :sequence on both
DpExchange.Core.Types.OrderBook and DpExchange.Core.Types.OrderBookDelta is the
other tool where a venue publishes one (Coinbase's l2_data channel does not, so
this socket always sends nil there — see decode_book_event/3). Neither tool
reconstructs a missing delta; nothing does. The correct response to :link_up is to
re-pull get_order_book/2 (unaffected by any of this) or accept the venue's own
fresh snapshot on resubscribe, not to keep applying deltas across a gap and hope
they still line up. See DpExchange.Core.Types.OrderBookDelta's own moduledoc and
this family's usage-rules/feeds.md for the full account of why those two signals
are sufficient.
The connect timeouts are chosen against Feed's call budget, not inherited by accident
WebSockex.start_link/4 opens a raw TCP connection and then waits for the HTTP
upgrade response, and each half has its own timeout — :socket_connect_timeout and
:socket_recv_timeout. Leave them unset and WebSockex supplies its own defaults:
measured in the vendored dependency, deps/websockex/lib/websockex/conn.ex:10-11,
@socket_connect_timeout_default 6000 and @socket_recv_timeout_default 5000. Nobody
chose those two numbers for this package; they are whatever the dependency happened to
ship.
That matters here specifically because of where start_link/1 gets called from.
Feed's open_shard/5 synchronous branch calls it from inside a handle_call/3,
and Feed's own @call_timeout is @frame_window_ms * 3 = 15_000 ms. The inherited
defaults alone — 6_000 + 5_000 = 11_000 ms — would burn roughly three-quarters of
that budget on the TCP connect and the handshake recv alone, before a single
subscribe frame is sent. Feed is a named, shared process, so every other consumer's
subscribe/2, unsubscribe/2, update_symbols/2 and coverage/1 call queues behind
that one handle_call/3 for the whole window whenever the venue is unreachable or
black-holing the connection.
@socket_connect_timeout_ms and @socket_recv_timeout_ms below total 6_000 ms
instead — deliberately, against that same 15_000 ms budget, leaving roughly 9_000
ms of the same call for the socket to actually send at least one subscribe frame
(itself capped at Feed's @frame_window_ms, 5_000 ms) plus ordinary GenServer
overhead, rather than have the connect attempt alone threaten to exhaust the caller's
patience. This changes no failure semantics: start_link/1 still returns
{:error, reason} synchronously either way, exactly as the dependency's own defaults
did — only the margin the caller gets to work with after a slow or absent venue
changes. A caller passing either key explicitly overrides it.
Summary
Functions
Starts a connection.
Options
:subscriber— the process events are delivered to. Required.:credentials— only needed for authenticated channels.:url— override the endpoint, for tests that stand up a local socket.:socket_connect_timeout— ms to wait for the TCP connect. Defaults to3000— see the moduledoc for why that is not WebSockex's own default.:socket_recv_timeout— ms to wait for the HTTP upgrade response. Defaults to3000, same reasoning.
@spec subscribe( pid(), String.t(), [String.t()], DpExchange.Coinbase.Credentials.t() | nil ) :: :ok | {:error, term()}
Subscribes symbols on channel.
Returns {:error, :send_timeout} rather than dying when the socket is too busy to
accept the frame — see DpExchange.Coinbase.FrameSender.
Unsubscribes symbols from channel.