Skip to main content

課金優先: write_response_body_failed の 500 は 課金されません

ゲートウェイが 500write_response_body_failed / connection reset by peer で返す場合、プラットフォーム側では すでに内部で 2〜3 回再試行済み であり、すべて失敗したあとにのみエラーを表示します。これらのリクエストには一切課金されません。そのため、ログがこれらのエラーで埋まっていても、請求書に対応する課金は表示されません。失敗に対して料金を支払っているわけではありません。別のケースは 課金されます(クライアントが早々に切断した場合)ので、下の「課金への影響」セクションを参照してください。
簡潔に言うと: 画像 API のレスポンスは通常 10 MB から数十 MB に及び、ほとんどの場合、問題は レスポンスのダウンロード中 に発生します(リクエスト本文が大きすぎる からではありません。プレーンな text-to-image でも同様に失敗します)。まずコンソールログがどのカテゴリに当たるかを確認し、その後で下の macOS / Linux / Node.js のセルフチェックを実行してください。

エラーの見え方

同じ根本原因でも、どちら側を見るかによって、まったく異なる2つの顔で現れます。

ゲートウェイ側から

クライアント側から

Node.js では「killed」と「closed」を区別してください: ECONNRESET は TCP RST が到達したことを意味し、経路上の何らかの要因によって接続がkillされたことを示します。ネットワーク上のどこかのホップに原因がある可能性が高いです。SocketError: other side closed / ERR_STREAM_PREMATURE_CLOSE はピアが正常に(FIN)切断したことを意味し、chunked の終端が欠けているなど、サーバー側の終端処理の問題を示します。これら2つはまったく異なる方向を指すので、1つの症状として扱わないでください。また、UND_ERR_* は undici(Node 18+ の built-in fetch のエンジン)からしか出てきません。一方で、read ECONNRESET は libuv の最上位の文言であり、axios / node-fetch / http モジュールが出力します。両方の種類が同時に出ている場合は、まずアプリに2つの異なる HTTP パスがないか確認してください。その場合、そもそも同じインシデントではありません。

まず方向を見極めます: 誰が切断したか

write_response_body_failed コードが最大の手がかりです。これは、ゲートウェイがレスポンス本文を呼び出し元へ書き戻す途中で失敗したことを意味します。つまり、上流のモデルエラーではなく、下流側の問題です。結果自体はすでに生成されており、あなたに送ろうとしている途中で接続が切れました。
これはリクエスト本文が大きすぎることが原因ではありません。 画像編集では参照画像をアップロードするため、アップロードサイズが疑われがちですが、リクエスト本文が数百バイトしかない単純な text-to-image でも、同じくらい頻繁に壊れます。壊れているのはレスポンスのダウンロードのほうです。画像ペイロードは 10MB から数十MB に及び、全体の経路の中で最も壊れやすい区間です。

下流側の切断

write_response_body_failed, connection reset by peer, クライアント側の SSL EOF。 ゲートウェイが本文を送っている最中に接続が消えました。プラットフォームがこれに対して 500 を返す場合、課金されません。詳しくは下の課金セクションを参照してください。

上流側の失敗(チャネル側)

上流側のタイムアウト、upstream_error、上流ペイロードを伴う 5xx、または異常な finishReason を伴う HTTP 200。 これらは本当のチャネル側の問題です。x-request-id をサポートに渡してください。

この呼び出しについて、コンソールログが示す最も強い手がかり

何か触る前に、まずコンソールの呼び出しログを確認してください。コストはかからず、クライアント側のどの手順よりも早く候補を絞れます。
ここから導ける結論は、よく逆に理解されます。UND_ERR_CONNECT_TIMEOUT のような接続段階の失敗では課金は発生しません。リクエストがゲートウェイに届いていないからです。したがって、「接続タイムアウトが大量にある」のに「課金も大量にある」のであれば、それらは同じリクエストではありません。1つの根本原因で全部を説明しようとせず、別々に調べてください。

切り分けの4手順

順番に確認してください。ほとんどは最初の2つで解決します。
1

アプリケーション層での読み違いを除外してください: 受け取っていたのに保持できなかった可能性があります

「画像を受け取れなかった」は、コードが例外を投げて「request failed」として捕捉された後に下された結論であることがほとんどです。つまり、バイトが届いていなかった証拠ではありません。最も典型的なのは、gpt-image-2-all が既定で b64_json を返すのに data: プレフィックスがないため、data[0].url を読むコードが undefined を受け取り、下流で例外を投げ、失敗と判定され、再試行が走るケースです。その結果、再び課金されます症状はネットワーク障害とまったく同じですが、ネットワーク障害である必要はありません。確認のために1行出力してください:
シリーズごとのフィールドとプレフィックスの違いは、base64 プレフィックス参照にあります。
2

すべてのチャネルとモデルが同時に失敗しているか確認してください

2つの異なるチャネルで2つの異なるモデルを動かしていて、同じ時間帯に同じエラーが出ているなら、チャネル固有の原因は実質的に除外できます。上流側がそんなにきれいに同時に失敗することはありません。
3

クライアントのランタイムを確認してください: Python の TLS スタック、または Node.js の undici タイムアウトです

Python: TLS スタックのバージョンを確認してください。Node.js: undici の 3 つのタイムアウトを確認してください。どちらも次の2つのセクションで扱います。実際にはこれが最も多い根本原因で、原因はすべて手元の環境にあります。1つのコマンドで確認できます。
4

同時実行数を下げ、逐次実行にして、ローカルプロキシを無効にしてください

同じリクエストを同時実行数 1-2 で、VPN またはプロキシを無効にして再実行してください。その方法では再現しないなら、問題はクライアントの接続処理、ローカルリソース(コネクションプール、ファイルディスクリプタ、メモリ)、またはネットワーク経路にあります。チャネル側ではありません。

最有力の容疑者: クライアントの TLS スタック(特に macOS)

macOS に同梱される Python(/usr/bin/python3)は、OpenSSL ではなく LibreSSL 2.8.3 にリンクしています。 これに urllib3 v2 を組み合わせると、大きなレスポンスボディの同時ダウンロード中に SSLEOFError が高い確率で発生します。クライアントが一方的に切断し、ゲートウェイは当然ながら connection reset by peer の大量発生を記録します。

確認するための1コマンド

requests を import したときにこの警告が出るなら、同じ兆候です:

修正: インタープリタを切り替える

urllib3 をダウングレードしないでください — 代わりに、適切な OpenSSL でビルドされた Python を使ってください:

計測比較(2026-07-29, UTC+8)

Nano Banana シリーズ(gemini-3-pro-image / gemini-3.1-flash-image)での2チャネル比較テストから得た実測値です: 結論は明白です: インタープリタを切り替えるだけで桁違いの差が出ており、その前に両チャネルが同時に失敗していた事実だけでも、チャネル起因ではないことはすでに証明されていました。

Linux サーバーで確認すべきこと(まったく別のリスト)

上の TLS スタックのチェックは、実質的にどの Linux ボックスでも通ります。ディストリビューションの Python は通常の OpenSSL にリンクしているため、LibreSSL の罠はそこにはありません。そのチェックだけで止めないでください。 サーバー側の問題は egress 経路コンテナの制限 にあり、ローカル開発とはまったく別世界です。

1. クラウド NAT ゲートウェイとロードバランサーのアイドルタイムアウト(サーバー側で最も多い原因)

これは本番環境での connection reset by peer の最大の原因です。たとえば AWS NAT Gateway は、固定で変更不可の 350 秒のアイドルタイムアウト を強制し、発火すると FIN ではなく RST を送ります。つまりクライアントからはまさに ECONNRESET に見えます。 厄介なのは、これが 連鎖する ことです。プールされた接続が 350 秒を超えてアイドル状態になると、それらはすべて死んでいるため、リクエストは最初の 1 本で RST を受け、クライアントは透過的に次のプール接続へ再試行します。しかしその接続も同じくアイドルで、やはり RST を受けます。 症状は「しばらく静かだったのに、その後いくつかの呼び出しが立て続けに失敗し、また何事もなかったように戻る」です。
これは Node.js セクションの「keep-alive が死んだ接続を再利用する」ケースと同じ仕組みです。サーバーでは、原因はたいていローカルのプロキシソフトではなく、クラウド事業者の NAT ゲートウェイ です。
対処法(どれでも可。最初の 2 つを推奨します):
  • TCP keepalive を 350 秒未満に設定 して、静かな時間帯でも通信が流れるようにする;
  • 接続がプール内でアイドルのままでいられる時間を制限 して、死んでいる可能性のあるものを破棄する(Node: new Agent({ keepAliveTimeout: 60_000 }); Python requests: HTTPAdapter でプールを設定する);
  • NAT ゲートウェイを完全に迂回する。たとえば VPC endpoints を使う。
他のクラウドや自前運用のロードバランサーでは別のアイドル値を使いますが、アプローチは同じです。経路上で最も短いアイドルタイムアウトを見つけ、その値より下に keepalive を設定してください。

2. TCP keepalive のデフォルトは実質的に「オフ」です

Linux ではデフォルトで tcp_keepalive_time7200 秒(2 時間) に設定されており、上記のどのアイドルタイムアウトよりもはるかに長いため、実運用では役に立ちません:
HTTP クライアント自体で keepalive を有効にする方がより堅牢です。コンテナではカーネルパラメータを変更できないことが多いためです。

3. コンテナネットワークの MTU

Docker / Kubernetes のオーバーレイネットワーク(flannel VXLAN など)は、一般に MTU を 1500 ではなく 1450 にしています。これに経路上の PMTUD ブラックホールが組み合わさると、典型的な「小さいリクエストは常に問題なし、大きいレスポンスは常に詰まる」が発生します:

4. コンテナのメモリ制限 → プロセスが OOMKilled される

4K の base64 ペイロード 1 つで 20〜30MB に達することがあります。これを resp.json() で丸ごと読み込み、さらに同時実行数があるとコンテナのメモリ制限を簡単に超え、カーネルがプロセスを kill します。これもまた 「接続が切れただけ」に見えます:
対処法は下の「レスポンスを丸ごと読み込まずにストリーミングする」を参照してください。

5. プロキシ環境変数(サーバーではいちばん気づきにくいもの)

サーバーには、/etc/environment、systemd ユニット、Dockerfile に設定されたグローバルな HTTP_PROXY / HTTPS_PROXY / NO_PROXY の値が残っていることがよくあります。設定した本人ですら長い間忘れていることが多いです。さらに厄介なのは、言語ごとにそれらを尊重するかどうかが一致しないことです。 この不一致は本当に紛らわしい結果を生みます。あるマシンでは curl と Python はプロキシを通るのに、Node は直接接続する(またはその逆)ため、両者の結果が食い違い、トラブルシューティングでも矛盾した結論になります。まず確認してください:
APIYI は直接到達可能なので、サーバーでは通常 api.apiyi.comNO_PROXY に設定します。あるいは、そもそもプロキシ変数を使わないようにします。

サーバー側の一発セルフチェック

Node.js: SDK の timeout では届かない 3 つの独立したタイムアウト

Node 18+ の組み込み fetch は undici 上で動作し、リクエストの 3 つの段階をカバーする 3 つの独立したタイムアウト があります。「でもタイムアウトを 5 分に設定したのに」という場合、たいていはそのどれでもない 4 つ目の値を変更しただけです:
openai-node の timeout オプションは AbortController ベースのリクエスト全体のタイムアウトであり、上記 3 つのいずれにも伝播しません。 timeout を 60 秒から 300 秒に増やしても、connectTimeout は 10 秒のままです。素の fetch() における AbortSignal.timeout() も同様です。「タイムアウトを大きくしたのに、まだタイムアウトする」の最もよくある原因はこれです。調整したのが間違ったレイヤーだったのです。

正しい設定

undici の 3 つのタイムアウトを広げるには、グローバルまたはリクエストごとに Agent が必要です:

maxRetries はデフォルトで 2 で、接続エラーを自動再試行します

openai-node は デフォルトで maxRetries: 2 になっており、接続エラーとタイムアウトの両方がその自動再試行の対象です。そのため、1 回の論理的な呼び出しで、コードに再試行ロジックが一切なくても 実際のリクエストが 3 回 発生しえます(それぞれが課金対象かどうかは、「課金への影響」カテゴリのどれに属するかで決まります)。 画像エンドポイントは高コストな同期長時間リクエストなので、maxRetries: 0 を必ず明示的に設定し、再試行ロジックは自分で管理してください。独自のバックオフと試行上限を使ってください。課金ルールは 再試行戦略 にあります。
まず、実際にどのスタック上にいるのかを確認してください: node -v, npm ls openai undici axios node-fetchUND_ERR_* コードが示すのは、その下で undici が使われていることだけです — OpenAI SDK を使っていることの証明にはなりません。素の fetch() でも同じコードが発生し、素の fetch() には maxRetries がまったくありません。

すでに切断済みの接続を keep-alive で再利用する場合

undici はデフォルトで keep-alive によるコネクションプーリングを有効にします。VPN、NAT、またはプロキシがアイドル状態の接続を黙って回収しても、クライアントはそれに気づかず、次のリクエストでもその接続をプールから取り出します — 書き込みは即座に RST を受け取り、read ECONNRESET として表面化します 呼び出しの間隔が空くとき、これは ECONNRESET の最も一般的な原因です。また、「エラーが 1 つの時間帯に集中する」ことと「最初の再試行ですら失敗する」ことの両方を説明します。再利用を無効にして確認してください:

ローカルプロキシ / VPN: 画像エンドポイントが最初に露出するホップ

APIYI は中国本土内から直接到達でき、プロキシや VPN は不要ですAPI を使うのにプロキシは必要ですか? を参照)。そのため、プロキシをオフにして再テストすることが、試せる中で最も安く、情報量の多い単独ステップになります。ただし、はっきりさせておくと、プロキシはあくまで最も疑わしい変数であり、確定した根本原因ではありません。下のマトリクスが、実際に障害箇所を特定します。
画像エンドポイントがテキストよりはるかに影響を受けやすい理由は 2 つあります。生成中に 30〜60 秒にわたってバイトがまったく流れないことと、MB 規模のボディが一気に送られることです。chat エンドポイントは問題ないのに画像エンドポイントだけ失敗する場合、たいていこのどちらかです。

fake-ip / ルーティングルールの見落とし

プロキシの fake-ip モードでは、ルールにマッチしないと 198.18.x.x のような到達不能アドレスに振り分けられ、ちょうど 10 秒の接続タイムアウトが発生します。これは「接続が遅い」わけではなく、そもそも経路がないので、connect.timeout を上げても助かりません。実際に到達した remote_ip を必ず記録してください。

生成中にアイドル接続として回収される

リクエスト送信後 30〜60 秒のあいだバイトが 1 つも流れず、プロキシがアイドルポリシーに従って接続を回収します。特徴は、画像サイズに関係なく 失敗時刻がきりのいい数字に収まる ことです。30 / 60 / 120 秒などです。

MTU / PMTUD のブラックホール

トンネル MTU が経路 MTU を下回っている一方で、ICMP の「fragmentation needed」が破棄されると、PMTUD が壊れます。典型的な特徴は、小さいリクエストは常に正常で、大きいレスポンスは常に詰まる ことです。受信バイト数は数 KB から数十 KB で止まります。トンネル MTU を 1400 前後まで下げると、たいてい解決します。

MITM 復号とフルバッファリング

HTTPS 復号を有効にしたプロキシは、大きなボディを丸ごとバッファリングしてサイズ上限に達することがよくあります。あるいは、chunked を Content-Length に書き換えて長さを誤り、RST を発生させます。これも MB 規模の画像レスポンスにだけ起き、テキスト呼び出しには起きません。

診断マトリクス

ここがこの節の核心です。軸は 2 つだけです。失敗が最初のバイトより前に起きたか後に起きたか、そして何バイト到着したかです。 最後の行は最も価値の高い単独テストです。ボディを数 MB から約 1KB まで縮められます。URL モードが一貫して成功し、base64 モードが一貫して失敗するなら、問題は転送量に比例しているため、最初の 2 列はこの時点で除外できます。

完全なタイミングプロファイル用の 1 コマンド

マトリクスに照らして読み取ってください。connect の値がなければ → 接続段階。ttfb がなく、total がきりのいい数字なら → アイドル回収。bytes が完全で、total ≈ ttfb + 300 に加えて curl: (18) があるなら → 終端子欠落。bytes が数十 KB で止まるなら → MTU。
proxy-on と proxy-off を A/B テストするときは、実行を交互に行ってください — まとめて実行しないでください。 プロキシ経由の 5 回を続けてから直接接続の 5 回を続けると、時間窓障害 が結果を汚染して、まったく誤った結論になります。実際に、すべて失敗し、数分後にはすべて成功し、また失敗する、という区間を計測しています。代わりに proxy → direct → proxy → direct を実行し、そのたびに remote_ip を記録してください。

その他の一般的なトリガー

実行中の手動中断

デバッグ中の Ctrl+C、プロセスの再起動、ホットリロード、実行中スクリプトの強制終了 — 送信途中の大きなレスポンスはどれも、ゲートウェイに write_response_body_failed を残します。これは、最もよく「チャネルが不安定だ」と誤解される誤警報です。

外側のタイムアウトが先に発火する

タスクキューのワーカーのタイムアウト、Serverless の実行制限、ゲートウェイ/CDN のオリジンタイムアウト(通常はデフォルトで 60 秒)です。生成時間より短いどのレイヤーでも、先に接続を切断します — 必読事項とベストプラクティス を参照してください。

コネクションプールと過剰な同時実行数

コネクションプールの上限、ローカルのファイルディスクリプタ制限、NAT/ファイアウォールが長時間接続を静かに回収してしまうこと。大きなレスポンスははるかに長く接続が開いたままになるため、テキストエンドポイントよりもこれらの制限にずっと頻繁に達します。

レスポンスボディがメモリを使い切る

1 つの 4K base64 ペイロードだけで 20-30MB に達することがあります。resp.json() を同時実行数下で一度にすべて読み込むと、コンテナメモリを使い切ってプロセスが OOM-killed され、これもまた「理由もなく接続が切れた」ように見えます。

課金への影響: どのケースが課金され、どのケースが課金されないか

この 2 つの状況は常に混同されますが、課金のされ方は正反対です:

プラットフォームが 500 write_response_body_failed を返した場合 — 課金されません

このエラーは、ゲートウェイが画像データをあなたに書き戻している最中に接続が切れたことを意味します。プラットフォームは内部で自動的に 2〜3 回再試行しますが、すべて失敗した場合にのみ 500 を返します。このケースでは課金は発生しません。 同じリクエストでこのエラーが何度続いても、請求書に対応する課金は残りません — この失敗に対して料金を支払うことはありません。

あなたのクライアントが先に切断した場合 — 通常どおり課金されます

もう一方のケースは、あなた側が先に切断したままゲートウェイの配信が完了する場合です。つまり、クライアントのタイムアウト発火、デバッグ中の Ctrl+C、プロセスの再起動、または OOM kill などです。サーバー側と上流での生成はすでに完了しているため、これらは通常どおり課金されます — 「画像を受け取れなかった」ことは「課金されなかった」ことを意味しません。大きな画像リクエストをトラブルシューティングで何度も投げると、実際の請求が積み上がります。
両者を見分ける方法は、まさに上の表のとおりです: コンソールに通常の呼び出しが記録されたか、500 write_response_body_failed が記録されたかを確認してください したがって、再試行ポリシーもそれに合わせて節度を保つべきです: トランスポートレベルの失敗は再試行する価値がありますが、再試行のたびに別個に課金される呼び出しになる可能性があります(どちらのカテゴリに当たるかによります)。無制限の再試行ループは決して書かないでください。

正しく再試行する方法

基本ルールは、トランスポート層の例外だけを再試行し、HTTPレベルのエラーは絶対に再試行しないことです。4xx を 1万回再送しても、4xx のままであり、時間の無駄です。
各試行を個別に記録してください(上のattemptsリスト)。そうしないと、クライアント側の再試行が成功した場合でも、ログにはきれいな 200 しか残らず、トランスポートが実際に何回失敗したのか分からなくなります。そのデータはチャネル品質を評価するうえで不可欠であり、自分の再試行をチャネルの挙動だと誤読してしまうのを防ぎます。

レスポンスは全体を読み込むのではなくストリーミングしてください

大きなボディでは、stream=Trueを使ってチャンクごとに読み取ってください。ピークメモリを抑えられ、転送のどの段階で失敗したのかを正確に把握できます。

本当にサポートに問い合わせる価値がある場合

自己切り分けが済んだら、以下のいずれかに該当する場合はエスカレーションしてください。
  • 正しい OpenSSL インタープリタに切り替え、実行をシリアルにしても、まだ安定して再現する;
  • 同じ時間帯に他は正常なのに、特定の 1 つのチャネルまたはモデルだけ が失敗する;
  • レスポンスボディが 完全に到着している(バイト数が Content-Length と一致する)のに、接続がタイムアウトするまで閉じない — これは上流で chunked の終端が欠けていることを意味し、チャネル側の問題です;
  • エラーが明確に上流向け(upstream_error、生の上流 5xx)である。
チケットには、x-request-id、呼び出し時刻(タイムゾーン付き、例: 2026-07-29 14:32 (UTC+8))、モデル名、imageSize のような主要パラメータ、生のクライアント例外、そしてすでに完了した自己確認手順を含めてください。

関連ドキュメント

必読事項とベストプラクティス

同期呼び出し、モデルごとのタイムアウト、base64 の処理、切断時の課金

独自の非同期キューを構築する

同期呼び出しをタスクキューでラップし、リトライで時折のドロップを吸収する

プロキシは必要ですか?

APIYI はプロキシなしで直接接続します。証明書と DNS の問題をセルフチェックします

Gemini 画像エラー処理

Gemini 画像生成におけるエラーコードと finishReason の処理