切れたSSEをすぐ止めた

SSEの接続が切れると、ついタイムアウトを長くしたくなる。 でも今回の問題は、待っても成功しない要求を「一時的な切断」に見せていたことだった。
/api/mcp のGETがSSEを開いた直後に閉じ、クライアントが2秒ごとに接続を作り直していた。
接続自体は作れるので、監視上は一見すると動いているように見える。実際には、成功しない経路が実行時間だけを使い続けていた。
接続できたことを成功にしない
SSEでは、ソケットが開いたことと、要求を処理し続けられることが別だ。 今回は未対応のGETがストリームを開いてから閉じていたため、クライアントは一時的な障害だと解釈した。
先に切り分けたのは3点だけだった。
- 接続を閉じたのはクライアントか、サーバか
- 再試行の間隔は何秒か
- そのGETを継続処理できる仕様が本当にあるか
この順に見ると、「SSEが不安定」という大きすぎる説明を、未対応の要求を受け付けていたという小さな原因に戻せる。
続けられない要求は405で終える
対応は、GETをSSEとして受け入れず405で返すことにした。 速く失敗させるのは冷たい挙動ではない。成功できない要求に対して、再接続すれば回復するという誤った合図を送らないための応答だ。
修正後は、拒否される経路の応答時間が短くなり、接続を作って閉じるループが止まった。正しい接続経路は別に残し、未対応の要求だけを狭く拒否した。
検証は3つに分ける
「405が返った」だけでは足りない。次の3つを分けて確認した。
- 未対応GETが405になること
- 正しい接続経路を壊していないこと
- 拒否経路が長時間の実行を残さないこと
この分け方は、RLSやデプロイでも同じだ。1本の成功ログを完了証拠にせず、拒否すべき側と続ける側を並べる。RLSとアプリ側の絞り込みでも、見える側と見えてはいけない側を別々に確かめている。
公開された状態まで確認する手順は、公開されたか確かめるに残した。作ったものの一覧はプロダクトから見られる。
次に同じ負荷を作らないために
ストリームを実装するときは、開始できるかだけでなく、続けられない要求をどう終えるかも仕様に入れる。 接続後に黙って切るより、接続前に理由を返す。その1行の境界が、再試行の積み上がりを防ぐ。