DpExchange.Coinbase.Socket (DpExchangeCoinbase v0.1.27)

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

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()], map() | 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.