Next.js APIプロキシが長時間ML推論で502になる — undiciのタイムアウト沼
Next.js のフロントから、別プロセスの推論サービス(FastAPI)へ API ルート経由でリクエストを中継する——よくある BFF(Backend for Frontend)構成です。ところが、CPU で 44.1kHz モデルの音声生成を走らせると、数分〜数十分かかることがある。そして、ある時間を境にこうなりました。
生成が終わっていないのに、フロントには 502 が返ってくる。
推論サービス側のログを見ると、処理はまだ元気に動いている。落ちているのは推論ではなく、あいだに挟まっているプロキシのほうでした。この記事は、その犯人が Next.js のグローバル fetch(= undici)の既定タイムアウトで、それを Node 標準の http/https に書き直して回避した記録です。
症状:長い生成だけ502、しかも一定時間で切れる
切り分けのポイントはこうでした。
- 短い生成(数秒〜数十秒)では問題なし
- 長い生成だけ 502 になる
- しかも「毎回だいたい同じくらいの時間」で切れる
推論サービス自体は生き続けているのに、プロキシ層が先に見切りをつけている。「一定時間で切れる」というのは、どこかにハードコードされたタイムアウトがある兆候です。
原因:グローバル fetch(undici)の既定タイムアウト
Next.js の API ルートで素直に fetch() を使うと、その実体は Node に組み込まれた undici です。undici にはヘッダー受信までの既定タイムアウト(およそ300秒程度)があり、応答が返ってこないまま超過するとコネクションを切ります。推論が5分を超えると、まさにここで切られて 502 になっていました。
「なら undici のタイムアウトを無効化すればいい」——そう考えて Agent の headersTimeout/bodyTimeout を触ろうとしたのですが、この構成では undici を直接 import して差し込むのが難しいという別の壁がありました。グローバル fetch の内部実装に手を入れるのは筋が悪く、環境によっては差し替えも効きません。
症状を握りつぶすために「タイムアウトを長くする」だけでは、いつか別の長い生成でまた踏みます。応急処置ではなく、タイムアウトの前提が違うレイヤーで書き直すのが正解でした。
解決:Node標準の http/https でプロキシし直す
undici をやめて、Node 標準の http/https モジュールで自前のプロキシを書きます。標準モジュールなら、タイムアウトの挙動を自分で完全に制御できます。
設計の要点は「接続の確立は打ち切る、応答待ちは無制限」という切り分けです。
- 相手が生きているかどうか(TCP 接続)は短時間で判定したい → 接続確立は30秒で打ち切り
- 接続さえできれば、あとは何分かかろうと待つ → 応答待ちはタイムアウトなし
このタイムアウト設計がキモです。
// 応答待ちは無制限(長時間生成対応)。接続確立のみ30秒で打ち切る。
preq.setTimeout(0);
preq.on('socket', (s) => {
s.setTimeout(30_000, () => {
if (!s.destroyed && (s.connecting || !s.writable)) s.destroy(new Error('connect timeout'));
});
s.once('connect', () => s.setTimeout(0));
});
読み解くとこうです。
preq.setTimeout(0)— リクエスト全体のタイムアウトを無効化(応答をいつまでも待つ)。- ソケットに
setTimeout(30_000, ...)を張り、まだ接続中(connecting)か書き込み不能の状態でタイムアウトしたときだけdestroyする。つまり「相手につながらない」ケースだけを30秒で見切ります。 s.once('connect', ...)で接続できた瞬間にsetTimeout(0)に切り替え、以後は待ち続けます。
こうすると、「サービスが落ちている(つながらない)」は速やかに 502 で返しつつ、「つながったが処理が長い」は延々と待つ、という望ましい挙動になります。エラー時のフォールバックも入れておきます。
preq.on('error', (e) => {
if (!res.headersSent) res.status(502).json({ error: `backend API unreachable: ${String(e)}` });
resolve();
});
もうひとつの罠:Next 側の応答監視警告
タイムアウトを解いても、Next.js の API ルートには「ハンドラが応答を返すのが遅い」ことを検知する仕組みがあり、長時間処理では警告が出ます。これは externalResolver: true を宣言して、「このルートの応答は外部(プロキシ先)が解決するので Next は監視しなくてよい」と伝えることで抑止します。
export const config = {
api: {
bodyParser: { sizeLimit: '25mb' },
responseLimit: '25mb',
externalResolver: true, // 長時間処理でNextの応答監視警告を抑止
},
};
ついでに、音声データを扱うのでボディサイズ上限も引き上げています(既定のままだと大きな録音データで弾かれます)。
落とし穴・学び
fetchの実体は undici。API ルートで何気なく使っているfetchに既定タイムアウトがあることを忘れると、「サービスは動いているのにプロキシが502」という切り分けの難しいバグになります。- タイムアウトは一律に消すのではなく、意味で分ける。「接続確立」と「応答待ち」を分けたことで、“つながらない”は速く失敗させつつ“長い”は待てる、という両立ができました。全部無制限にすると、今度は死んだサービスへの接続で固まります。
- 標準モジュールに降りると制御が戻る。高機能なクライアントの内部設定と格闘するより、
http/httpsに降りて自分でソケットを握るほうが、この手の要件では確実で読みやすくなります。 - 長時間処理を返す API ルートでは
externalResolver: trueを忘れずに。
まとめ
- Next.js の API ルートで長時間ML推論をプロキシすると、グローバル
fetch(undici)の既定タイムアウト(≈300秒)で502になる - undici を直接 import して差し替えるのが難しい構成では、Node標準の
http/httpsでプロキシを書き直すのが確実 - タイムアウトは「接続確立は30秒で打ち切り、応答待ちは無制限(
setTimeout(0))」に分けるのが要 - 接続できた瞬間に
setTimeout(0)へ切り替えると、“つながらない”だけを速く失敗させられる - 長時間応答のルートでは
externalResolver: trueで Next の応答監視警告を抑止し、音声データ向けにボディ上限も調整する