DpExchange.Coinbase.Socket (DpExchangeCoinbase v0.2.24)

Copy Markdown View Source

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.OrderBookbids 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.

Unsubscribes symbols from channel.

Functions

start_link(opts)

@spec start_link(keyword()) :: {:ok, pid()} | {:error, term()}

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 to 3000 — see the moduledoc for why that is not WebSockex's own default.
  • :socket_recv_timeout — ms to wait for the HTTP upgrade response. Defaults to 3000, same reasoning.

subscribe(socket, channel, symbols, credentials \\ nil)

@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.

unsubscribe(socket, channel, symbols)

@spec unsubscribe(pid(), String.t(), [String.t()]) :: :ok | {:error, term()}

Unsubscribes symbols from channel.