WebSocket
建立低延迟连接,检测并补拉缺失成交。
WebSocket
连接后会推送账户已订阅实体的成交,无需发送订阅消息。服务端忽略客户端发来的内容。 这是低延迟通道,跟单机器人通常使用此通道:一条长连接,不用每笔事件重做 DNS 和 TLS 握手, 也不要求你自己有公网 HTTPS 地址。本站的我的推送也使用此数据流。
import WebSocket from "ws"; import { randomBytes } from "node:crypto"; // One id per running client, the same on every reconnect: it is how the server tells // "this client coming back after a network drop" from "a second client". const clientId = randomBytes(16).toString("base64url"); const takeover = false; // true = replace whichever API client holds this account's stream function connect(first) { const ws = new WebSocket(`wss://api.pmwallets.com/v1/ws?clientId=${clientId}${first && takeover ? "&takeover=1" : ""}`, { headers: { "x-api-key": process.env.PMW_KEY, // a handshake cannot set Authorization }, }); ws.on("close", (code) => { if (code === 4411) return stop("another API client holds this account's stream"); // refused: do not retry if (code === 1000) return stop("replaced by another client's takeover"); // do not fight back setTimeout(() => connect(false), code === 1012 ? 0 : backoff()); // 1012 = we are restarting: come right back }); return ws; } const ws = connect(true);
socket 不重试。帧是尽力投递:交给打开的 socket 就算发出, 没有 socket 就丢弃,两者都不会重发。丢帧后可保存游标,用 GET /v1/account/fills 补拉缺失成交,详见下一节。 如需服务端主动推送重试,可同时接入 Webhook。两种方式都按 eventId 去重。
每个账户 1 条连接,API 客户端优先。用 API key 建立的连接会替换本站浏览器 标签页的连接(标签页以 4410 关闭);API 客户端在线时,浏览器标签页无法替换它, 我的推送会显示连接不可用,关闭码为 4409。
两条 API 连接之间,先连上的保留这条流。同一账户换一把 key 连接,或重复启动机器人时,第二条连接 会收到 {"type":"blocked","reason":"api_seat_taken"} 帧并以 4411 关闭, 正在推送的那条不受影响。如需替换现有连接,带上 ?takeover=1 连接:原来那条以 1000 关闭, 原客户端应停止重连,否则会重新占用连接。请带上 clientId(8–64 位 [A-Za-z0-9_-],每个运行中的客户端一个,每次重连都相同):客户端使用自己的 id 重连时会替换原连接,不需要 takeover,避免网络闪断后原连接仍占用 名额。服务端每 15 秒 ping 一次,无应答时断开连接。
我们重启推送服务时,打开的连接会以 1012(服务重启)关闭。接替进程正在接管,请立即重连,无需退避, 然后按下文补拉空档。官方 SDK 已处理这些步骤。
检测丢帧并补拉
每一帧都带 session 和 seq。同一个 session 内 序号是连续的,所以序号跳了就说明我们丢了帧。消费端处理不及时时,服务端会故意丢帧, 但保持连接,仅通过序号体现丢帧。session 变化说明发生过重连。这两种变化都可用于检测空档。
// Persist these across reconnects — comparing against a session you just overwrote // can never detect anything. let session = load("session"), seq = load("seq") ?? 0; let lastBlock = load("block") ?? 0, lastLogIndex = load("logIndex") ?? 0; // One message at a time: a replay is awaited, and a second frame arriving meanwhile // would advance the cursor past the gap being repaired. Note the catch — chaining // onto a rejected promise skips every later .then(), so a single failed replay would // leave the socket open and silently processing nothing at all. let queue = Promise.resolve(); ws.onmessage = (e) => { queue = queue .then(() => handleFrame(JSON.parse(e.data))) .catch((err) => { report(err); ws.close(); }); // reconnect and replay rather than go quiet }; async function handleFrame(m) { if (m.type === "hello") { // A NEW session means frames were missed while the socket was down. Replay BEFORE // adopting it — overwriting session here is what silently swallows the outage. // a failing replay must not be swallowed: leave the cursor where it is and throw, so the // handler above closes the socket and the next connection tries again from the same place if (session !== null && m.session !== session) await replayFrom(lastBlock, lastLogIndex); session = m.session; seq = m.seq; save(); return; } if (m.type !== "fill") return; if (m.session !== session || m.seq !== seq + 1) { // dropped frame, or a session we never saw await replayFrom(lastBlock, lastLogIndex); session = m.session; } seq = m.seq; if (!seen.has(m.data.eventId)) { seen.add(m.data.eventId); handle(m.data); } lastBlock = m.data.block; lastLogIndex = m.data.logIndex; save(); }
检测到空档后补拉成交。GET /v1/account/fills 返回游标之后所有已订阅实体的 成交,按时间正序、keyset 分页。返回行与推送相同,使用相同的 eventId。按它去重即可安全处理重复拉取。只覆盖生效中的订阅, 且只返回各订阅 fromBlock 之后的成交:恢复订阅会把它移到链头,所以暂停期间的成交在这里拉不到。
// walk forward until the page is short; every row carries the same eventId as the stream async function replayFrom(block, logIndex) { for (;;) { const r = await fetch( `https://api.pmwallets.com/v1/account/fills?sinceBlock=${block}&sinceLogIndex=${logIndex}&limit=500`, { headers: { "x-api-key": PMW_KEY } }, ); if (!r.ok) throw new Error(`replay failed: HTTP ${r.status}`); // do NOT advance the cursor const page = await r.json(); // fills are kept for 60 days: an older gap is gone, not empty if (page.truncated) console.warn(`fills before block ${page.retainedFrom} are no longer kept`); for (const row of page.rows) if (!seen.has(row.eventId)) { seen.add(row.eventId); handle(row); } if (!page.next) return; ({ sinceBlock: block, sinceLogIndex: logIndex } = page.next); } }