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 is a maintained book, not a series of standalone facts
Unlike ticker, one l2_data frame does not carry enough to answer "what does the
book look like right now" — a snapshot event seeds it and update events carry only
the price levels that changed, with new_quantity: "0" meaning the level is gone.
This socket holds that state, one map of price → quantity per side per symbol, and
every Core.Types.OrderBook delivered is built from the maintained state, never from a
single frame's rows alone — a caller reading one delta as the whole book would see a
handful of prices and nothing else, which is a book with everything but two levels
simply missing rather than a partial update.
A reconnect loses this state, because the venue's own session is gone with it —
handle_disconnect/2 clears every symbol's book, and the next snapshot this socket
receives after resubscribing rebuilds it from what the venue sends fresh. There is no
way to reconcile a stale local book against a venue that has moved on.
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.
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.