# 再接続とエラーの扱い

WebSocket のクライアントは、接続が切れるものとして作ってください。文字おこしくんのサーバーの再起動・ネットワークの揺れ・プロキシのアイドル切断・クライアントの受信の遅れで、つながっている接続は切れます。

## 判断の表

エラーは `{"type":"error","error":{"code":"…","message":"…","fatal":true|false}}` で届きます。`fatal: true` なら、文字おこしくんはこの後に接続を閉じます。

| 起きたこと | どうするか |
|---|---|
| `fatal: false` の `error` | 接続を使い続ける（Ingest なら送った内容を直す） |
| `unauthorized` | **再接続しない**。新しいトークンを取ってからやり直す |
| `protocol_version_unsupported` | **再接続しない**。クライアントを更新する |
| `session_ended`（エラーでもメッセージでも） | **再接続しない**。そのセッションは終わった |
| `superseded` | **再接続しない**（Ingest）。同じセッションに別の送り手がつないだ |
| `quota_exceeded` | **再接続しない**（Ingest）。利用上限に達し、セッションも終わっている。次の UTC 0 時の後に、新しいセッションを作る |
| fatal な `invalid_message` | **再接続しない**。文字おこしくんが `hello` を拒んだので、同じ `hello` を送り直しても通らない |
| fatal な `rate_limited` | 上限の待ち時間（30 秒）をおいて再接続する |
| fatal な `internal`・知らないコード | バックオフで再接続する |
| `error` を受け取らずに切れた | バックオフで再接続する |
| 非 fatal な `error` の直後に切れた（hello のタイムアウト・受信の遅いクライアントへの `rate_limited` など） | バックオフで再接続する |
| 接続するときに、文字おこしくんが HTTP 503 / 429（`too_many_connections`）で断った | バックオフで再接続する |

- 再接続するかどうかを、WebSocket の close code で判断しないでください（文字おこしくんは close code を規定していません）。あなたのクライアントは、直前に受け取った `error.code` で判断してください。
- **知らないコードも受け入れてください。** 文字おこしくんは、互換性のある変更として、エラーコードを追加します。あなたのクライアントは、`fatal` に従い、fatal な知らないコードを `internal` と同じに扱ってください。
- 一覧と機械可読な版: [エラーコード](errors/index.md)・[errors.json](errors.json)

## バックオフ

```text
待ち時間 = min(30 秒, 0.5 秒 × 2^試行回数) × (1 − 0.5 × random())
```

- クライアントは、待ち時間を、1 回目は 0.25〜0.5 秒、2 回目は 0.5〜1 秒 … と倍々に延ばし、30 秒で頭打ちにします。
- ジッター（`random()`）は必ず入れてください。ジッターを入れると、文字おこしくんのサーバーの再起動のあとに、全員のクライアントが同時につなぎ直すことを避けられます。
- `welcome` を受け取ってから 10 秒以上つながっていた接続が切れたら、クライアントは、試行回数を 0 に戻します。
- `session_ended` と再接続せずに終えるべきエラーの後は、クライアントは、再接続のタイマーと生存確認（`ping`）のタイマーを止め、接続も閉じます。

## 生存確認

ブラウザは WebSocket の ping / pong フレームを扱えないので、クライアントは、接続が生きているかを、プロトコルの JSON で確かめます。

- クライアントは、`welcome` の後、15 秒ごとに `{"type":"ping"}` を送る。文字おこしくんは `{"type":"pong"}` を返す。
- `ping` を送ってから 10 秒ほど**何も**届かなければ（`pong` に限らない）、クライアントは、半開きの接続とみなして接続を自分で閉じ、再接続する。
- 15 秒ごとの `ping` で、途中のプロキシ（Cloudflare など）のアイドル切断も防げます。

## 再接続したとき

**Subscribe**

- クライアントは、もう一度 `hello` から始め、`welcome` で状態を**丸ごと置き換えます**。
- 文字おこしくんには、切断中の差分を取り戻す仕組みはありません。クライアントは、代わりに `welcome.recent`（直近の発話の最新の改訂）で状態を作り直します。記録を取りこぼしたくないなら、セッションが終わった後に記録を HTTP で書き出して、受け取った発話と比べてください（[記録と書き出し](guides/archive.md)）。
- 再接続を待っている間は、表示を薄くするなどして、表示が古いかもしれないことを示すとよいでしょう。発声中の表示（`speakingTrackIds`）も古いままです。

**Ingest** で送り直すもの（話者・トラック・切断中の音声）と順番は、[音声を送る](ingest.md#state_synced-と再接続)にあります。

## トークンの期限 {#token-expiry}

- 文字おこしくんは、トークンを**接続するときだけ**確かめます。つながっている接続は、トークンの期限が来ても切れません。
- 期限の後に再接続すると、文字おこしくんは `unauthorized`（再接続せずに終えるべきエラー）を返します。長く動かすプログラムは、期限の前（例: 有効期間の 8 割が過ぎたら）に新しいトークンを取り、次の再接続から使ってください。
- ブラウザ・OBS で使う URL のトークンが切れたら、Discord で `/link` を実行して、リンクを取り直します。

## 疑似コード

```js
const STOP = new Set(["unauthorized", "protocol_version_unsupported", "session_ended", "superseded", "quota_exceeded"]);
let attempt = 0, stopped = false, openedAt = 0;

function connect() {
  const ws = new WebSocket(url);
  ws.onopen = () => ws.send(JSON.stringify({ type: "hello", protocolVersion: 1, token: currentToken() }));
  ws.onmessage = ({ data }) => {
    const m = JSON.parse(data);
    if (m.type === "welcome") openedAt = Date.now();
    if (m.type === "session_ended") stopped = true;
    if (m.type === "error") {
      const { code, fatal } = m.error;
      if (STOP.has(code) || (fatal && code === "invalid_message")) stopped = true;
      else if (fatal && code === "rate_limited") attempt = 6; // 次は上限の 30 秒
    }
    handle(m);
  };
  ws.onclose = () => {
    if (stopped) return;
    if (openedAt !== 0 && Date.now() - openedAt > 10_000) attempt = 0;
    openedAt = 0;
    const delay = Math.min(30_000, 500 * 2 ** attempt++) * (1 - 0.5 * Math.random());
    setTimeout(connect, delay);
  };
}
```

参照実装 [examples/mojiokoshi-subscribe.js](examples/mojiokoshi-subscribe.js) に、疑似コードを完成させたものがあります。

## コーディングエージェント向けのプロンプト

```text
文字おこしくんの WebSocket クライアント（Subscribe / Ingest）に、再接続とエラーの扱いを実装してください。
仕様: https://moji.kaitoranma.com/docs/reconnection.md と errors.json
（https://moji.kaitoranma.com/docs/errors.json）。

- 予期しない切断では min(30s, 0.5s × 2^n) × (1 − 0.5 × random) の指数バックオフで再接続する。welcome から 10 秒以上続いた接続の後は n を 0 に戻す。
- unauthorized / protocol_version_unsupported / session_ended / superseded / quota_exceeded と fatal な invalid_message、session_ended メッセージでは再接続しない。fatal な rate_limited は 30 秒待つ。知らないコードは fatal に従い、fatal なら internal と同じく再接続する。
- close code では判断しない。直前の error.code で判断する。
- welcome の後 15 秒ごとに {"type":"ping"} を送り、10 秒ほど何も届かなければ自分で閉じて再接続する。
- 再接続では hello からやり直し、Subscribe は welcome で状態を丸ごと置き換える。トークンは再接続のたびに有効なものを使う。
- 偽の WebSocket と偽のタイマーで、上の分岐をすべてテストする（実サーバーには依存しない）。
```
