> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oddspapi.io/llms.txt
> Use this file to discover all available pages before exploring further.

# WebSocket恢复和重放 - 连接恢复

> 优雅地处理WebSocket断开连接。使用serverEpoch和entryId游标恢复数据流而不产生间隙。自动重放错过的更新。

## 为何这对交易而言是安全的

重放窗口（`resumeWindowMs`，例如 `60000`）**并非**一个超过即丢失数据的脆弱时间限制 —— 它是实现**零丢失快速恢复**的缓冲区。恢复是一个分两级的、经过设计的故障转移机制：

1. **在窗口内** —— 重新连接并从游标重放错过的更新，**无间隙**。这涵盖了绝大多数现实世界中的断连（网络抖动、重新部署），它们都会在数秒内解决。
2. **在窗口外** —— 网关发出 `snapshot_required`，您通过**单次快速 REST 快照**恢复完整的当前状态。这是一条明确的、确定性的路径，而非错误。

窗口被有意设置得较短，因为对于实时交易而言，长时间间隙的正确恢复方式是获取**当前市场的全新快照**，而不是缓慢重放过时的价格。最坏情况只是一次 REST 调用 —— 有界且可预测，绝不会是无限期的中断。参见[可靠性与运维](/zh/api-reference/reliability)。

***

## "恢复"意味着什么

成功登录后，服务器在`login_ok`中返回一个`resume`块：

```json theme={null}
{
  "resume": {
    "serverEpoch": "0804ab61513c4681a3afd8afc1fb2f75",
    "resumeWindowMs": 60000,
    "replayChannels": ["fixtures", "scores"],
    "serverEntryIds": {
      "fixtures": "1766414833582-2542",
      "scores": "1766418736962-198"
    }
  }
}
```

这告诉您：

* `serverEpoch` — 标识当前网关会话（重启时更改）
* `resumeWindowMs` — 可重放数据缓冲的时间长度
* `replayChannels` — 符合重放条件的频道
* `serverEntryIds` — 服务器每个频道的最新游标

***

## 客户端必须持久化的内容

为安全恢复，请持久化：

1. `serverEpoch`
2. **每个频道**您处理的最新`entryId`

```json theme={null}
{
  "scores": "1766418736962-198",
  "fixtures": "1766414833582-2542"
}
```

***

## entryId格式

每条流式消息包含一个`entryId`：

```
<ts_ms>-<seq>
```

* `ts_ms` — 服务器时间戳（UTC，毫秒）
* `seq` — 每个频道的单调序列

重要：

* `entryId`是一个**游标**，不是交付保证
* 间隙是正常的（上游行为、合并、背压）

***

## 恢复登录示例

重新连接时，发送相同的`serverEpoch`和您存储的游标：

```json theme={null}
{
  "type": "login",
  "apiKey": "YOUR_API_KEY",
  "lang": "zh",
  "channels": ["fixtures", "scores", "odds"],
  "serverEpoch": "0804ab61513c4681a3afd8afc1fb2f75",
  "lastSeenId": {
    "scores": "1766418736962-198"
  }
}
```

如果重放成功，服务器发送：

```json theme={null}
{
  "type": "resume_complete",
  "serverEpoch": "0804ab61513c4681a3afd8afc1fb2f75"
}
```

***

## snapshot\_required（当重放不可能时）

有时无法安全地进行重放。在这种情况下，服务器发送：

```json theme={null}
{
  "type": "snapshot_required",
  "reason": "resume_window_exceeded",
  "channels": ["scores"],
  "serverEpoch": "0804ab61513c4681a3afd8afc1fb2f75",
  "resumeWindowMs": 60000,
  "serverEntryIds": {
    "scores": "1766418738000-220"
  }
}
```

### 可能的原因

* `server_restarted` — 网关重启，游标无效
* `resume_window_exceeded` — 您的游标比重放缓冲区更旧
* `client_backpressure` — 您的客户端无法足够快地消费重放

### 重要细节

重放资格取决于**游标年龄**，而不是断开连接持续时间：

```
(now_ms - last_seen_entry_ts_ms) > resumeWindowMs
⇒ snapshot_required可能
```

如果您最后处理的消息已经很旧，即使非常短的断开连接也可能超过窗口。

***

## 客户端应如何处理snapshot\_required

当您收到`snapshot_required`时：

1. 通过REST为列出的频道获取新快照
   （例如`/fixtures`、`/fixtures/odds`、`/futures`）
2. 清除这些频道的`lastSeenId`
3. 继续处理实时更新

> 网关在`snapshot_required`后继续流式传输。
> 此消息是您的信号，表明**本地状态必须重建**。

***

## Python重新连接示例（完整模板）

此示例：

* 持久化`serverEpoch`和每个频道的`lastSeenId`
* **仅为可重放频道**发送游标
* 处理`snapshot_required`
* 失败时自动重新连接

```python theme={null}
import asyncio
import json
import time
import websockets

WS_URL = "wss://v5.oddspapi.io/ws"
API_KEY = "YOUR_API_KEY"

server_epoch = None
replay_channels = None
last_seen = {}

async def run_once():
    global server_epoch, replay_channels, last_seen

    async with websockets.connect(
        WS_URL,
        ping_interval=20,
        ping_timeout=20,
        max_size=4194304
    ) as ws:
        login = {
            "type": "login",
            "apiKey": API_KEY,
            "channels": ["fixtures", "scores", "odds"],
            "receiveType": "json",
            "lang": "zh",
        }

        # 恢复模式
        if server_epoch:
            login["serverEpoch"] = server_epoch

        # 仅为可重放频道发送游标
        if replay_channels:
            cursors = {
                ch: eid
                for ch, eid in last_seen.items()
                if ch in replay_channels
            }
        else:
            cursors = dict(last_seen)

        if cursors:
            login["lastSeenId"] = cursors

        await ws.send(json.dumps(login))

        async for raw in ws:
            if isinstance(raw, (bytes, bytearray)):
                raw = raw.decode("utf-8", errors="replace")

            msg = json.loads(raw)
            msg_type = msg.get("type")

            if msg_type == "login_ok":
                resume = msg.get("resume") or {}
                server_epoch = resume.get("serverEpoch") or server_epoch

                rc = resume.get("replayChannels")
                if isinstance(rc, list):
                    replay_channels = set(map(str, rc))

                continue

            if msg_type == "snapshot_required":
                channels = msg.get("channels") or []
                for ch in channels:
                    last_seen.pop(ch, None)

                # 在此触发REST快照刷新
                print("需要快照：", channels)
                continue

            if msg_type == "resume_complete":
                print("恢复完成")
                continue

            # 数据消息
            channel = msg.get("channel")
            entry_id = msg.get("entryId")

            if isinstance(channel, str) and isinstance(entry_id, str):
                last_seen[channel] = entry_id

async def main():
    while True:
        try:
            await run_once()
        except Exception as e:
            print("断开连接：", e)
            await asyncio.sleep(1)

asyncio.run(main())
```
