症状
STT プロバイダに Gladia を使っています。アプリケーション定義のクローズコード(4000-4999 の範囲)で WebSocket が閉じたあと、同じ wsUrl に再接続し続ける状態になりました。再接続は毎回成功したように見えます。その直後にまた閉じられ、これが延々と繰り返されます。
原因
原因は二つあり、組み合わさるとループになります。
一つ目は Gladia 側の挙動です。すでに終了しているライブジョブの wsUrl に接続しても、WebSocket のハンドシェイクは受け付けられます。その直後に 4403 でクローズされます。
二つ目はクライアント側の実装です。ソケットが open した時点で再接続のバックオフをリセットしていました。open 自体は本物なので、リセットは毎回走ります。クローズはその直後に来ます。結果としてバックオフは毎回初期値から始まり、上限に達することがありません。待ち時間が伸びないまま、死んだジョブへの接続を繰り返すことになります。
同じ URL に何度つないでも、ジョブはもう生きていません。この状況から抜けるには、POST /v2/live で新しいライブジョブを作り直す必要があります。
対処の方針
次の四つのルールにしました。
- バックオフのリセットは、WebSocket が 5 秒間開いたままだった場合に限る。open しただけではリセットしない。
- クローズコードが 4000-4999 の場合は再プロビジョニングする。新しく
POST /v2/liveを呼び、同じ URL には再接続しない。 - 再プロビジョニングは 5 分間に最大 2 回まで。
- ブラウザ側のクライアントがすでに切断している場合は、上流の接続を閉じる。再プロビジョニングもしない。
4000-4999 以外のクローズでは、従来どおり同じ URL へバックオフ付きで再接続します。ルール 1 があるので、すぐ閉じられる接続が続けばバックオフはきちんと伸びていきます。ルール 3 は、作り直したジョブでもすぐ 4xxx で閉じられる場合に POST /v2/live が連打されるのを防ぐための上限です。
実装
判定ロジックだけを抜き出すと次のようになります。
const STABLE_OPEN_MS = 5_000;
const REPROVISION_WINDOW_MS = 5 * 60_000;
const MAX_REPROVISIONS_PER_WINDOW = 2;
type Action = "reconnect_same_url" | "reprovision" | "stop";
interface UpstreamState {
clientDetached: boolean;
openedAt: number | null; // 上流ソケットが open した時刻
backoffAttempt: number;
reprovisionedAt: number[]; // POST /v2/live を呼んだ時刻
}
// ルール4: クライアントが切断したら上流を閉じる
function onClientDetach(s: UpstreamState, closeUpstream: () => void): void {
s.clientDetached = true;
closeUpstream();
}
function onUpstreamClose(s: UpstreamState, code: number, now: number): Action {
// ルール1: 5秒以上開いていた場合だけバックオフをリセット
if (s.openedAt !== null && now - s.openedAt >= STABLE_OPEN_MS) {
s.backoffAttempt = 0;
}
s.openedAt = null;
// ルール4: 切断済みのクライアントのために作り直さない
if (s.clientDetached) return "stop";
// ルール2: 4000-4999 は新しい POST /v2/live
if (code >= 4000 && code <= 4999) {
// ルール3: 5分間に最大2回
s.reprovisionedAt = s.reprovisionedAt.filter(
(t) => now - t < REPROVISION_WINDOW_MS,
);
if (s.reprovisionedAt.length >= MAX_REPROVISIONS_PER_WINDOW) return "stop";
s.reprovisionedAt.push(now);
return "reprovision";
}
// それ以外は同じ URL へバックオフ付きで再接続
s.backoffAttempt += 1;
return "reconnect_same_url";
}
open ハンドラでは openedAt を記録するだけにします。バックオフの判定はクローズ時に開いていた時間で行います。これで「open は成功するが直後に閉じられる」接続を、安定した接続と区別できます。
この対処は、リアルタイムの文字起こしをもとに会話中の提案を表示する LiveSuggest の上流接続で入れたものです。