What the error looks like
The same root cause shows up with two completely different faces, depending on which side you are reading.From the gateway
From the client
First establish the direction: who hung up
Thewrite_response_body_failed code is the key clue — it means the gateway failed while writing the response body back to the caller. That is the downstream direction, not an upstream model error. The result had already been generated; the connection died while it was being pushed to you.
Downstream drop
write_response_body_failed, connection reset by peer, client-side SSL EOF.
The connection vanished while the gateway was pushing the body. When the platform returns a 500 for this, it is not billed — see the billing section below.Upstream failure (channel side)
upstream_error, 5xx carrying the upstream payload, or HTTP 200 with an abnormal finishReason.
These are genuine channel issues — take the x-request-id to support.The strongest signal: what the console log says about this call
Check the call log in the console before touching anything else. It costs nothing and narrows the field faster than any client-side step:Four steps to clear yourself
Work through them in order; most cases resolve in the first two:Rule out an application-layer misread: you may have received it and failed to keep it
gpt-image-2-all returns b64_json by default with no data: prefix, so code that reads data[0].url gets undefined, throws downstream, is judged a failure, and triggers a retry — which bills again.The symptoms are identical to a network fault, yet no network fault is required. Print one line to check:Check whether every channel and model is failing at once
Check your client runtime: TLS stack (Python) or undici timeouts (Node.js)
Drop concurrency, go serial, and turn off the local proxy
Prime suspect: the client TLS stack (especially on macOS)
The Python that ships with macOS (/usr/bin/python3) links against LibreSSL 2.8.3, not OpenSSL. Combined with urllib3 v2, that pairing reliably throws SSLEOFError during concurrent downloads of large response bodies. The client hangs up unilaterally, and the gateway duly logs a wall of connection reset by peer.
One command to check
requests is the same signal:
Fix: switch interpreters
Do not downgrade urllib3 — just use a Python built against a proper OpenSSL:Measured comparison (2026-07-29, UTC+8)
Real numbers from a two-channel comparison test on the Nano Banana series (gemini-3-pro-image / gemini-3.1-flash-image):
What to check on a Linux server (a completely different list)
1. Idle timeouts on cloud NAT gateways and load balancers (the top server-side cause)
This is the number one source ofconnection reset by peer in production. Take AWS NAT Gateway: it enforces a fixed, non-configurable 350-second idle timeout, and when it fires it sends an RST rather than a FIN — so what your client sees is exactly ECONNRESET.
The nasty part is that it cascades: once pooled connections have been idle past 350 seconds they are all dead, so your request gets RST on the first one, the client transparently retries on the next pooled connection — which was equally idle and also gets RST. The signature is “quiet for a while, then several calls fail back-to-back, then everything is fine again”.
Fixes (any of these; the first two are preferred):
- Set TCP keepalive below 350 seconds so traffic flows even during quiet periods;
- Cap how long connections may sit idle in the pool so possibly-dead ones get discarded (Node:
new Agent({ keepAliveTimeout: 60_000 }); Pythonrequests: configure the pool viaHTTPAdapter); - Route around the NAT gateway entirely, e.g. via VPC endpoints.
2. The TCP keepalive default is effectively “off”
Linux defaultstcp_keepalive_time to 7200 seconds (2 hours), far longer than any of the idle timeouts above, which makes it useless in practice:
3. Container network MTU
Docker / Kubernetes overlay networks (flannel VXLAN and friends) commonly run an MTU of 1450 rather than 1500. Combine that with a PMTUD black hole on the path and you get the classic “small requests always fine, large responses always stuck”:4. Container memory limits → the process gets OOMKilled
A single 4K base64 payload can reach 20-30MB; loading it whole withresp.json() under concurrency easily exceeds the container memory limit and the kernel kills the process — which again presents as “the connection just dropped”:
5. Proxy environment variables (the sneakiest one on servers)
Servers frequently carry globalHTTP_PROXY / HTTPS_PROXY / NO_PROXY values set in /etc/environment, a systemd unit, or a Dockerfile — long forgotten by whoever set them. Worse, languages disagree about honouring them:
api.apiyi.com in NO_PROXY — or simply no proxy variables at all.
Server-side one-shot self-check
Node.js: three independent timeouts the SDK’s timeout cannot reach
Node 18+‘s built-in fetch runs on undici, which has three independent timeouts covering three stages of a request. “But I set my timeout to 5 minutes” usually means you changed a fourth value that is none of them:
The correct configuration
Widening undici’s three timeouts requires anAgent, either globally or per request:
maxRetries defaults to 2 and auto-retries connection errors
openai-node defaults to maxRetries: 2, and both connection errors and timeouts are in scope for those automatic retries. One logical call can therefore produce three actual requests even when your code contains no retry logic at all (whether each one is billed depends on which “Billing impact” category it falls into).
Image endpoints are expensive synchronous long requests, so always set maxRetries: 0 explicitly and own the retry logic yourself, with your own backoff and attempt ceiling. Billing rules are in Retry Strategy.
keep-alive reusing an already-dead connection
undici enables connection pooling with keep-alive by default. When a VPN, NAT, or proxy silently reclaims an idle connection, the client never finds out and still pulls that connection from the pool for the next request — the write immediately receives an RST, surfacing asread ECONNRESET.
This is the most common source of ECONNRESET when calls are spaced apart, and it explains both “errors cluster inside one time window” and “even the first retry fails”. Verify by disabling reuse:
Local proxy / VPN: the hop image endpoints expose first
fake-ip / routing rule miss
198.18.x.x, producing a precisely 10-second connect timeout. Note this is not “slow to connect” — there is no route at all, so raising connect.timeout will not save it. Always record the remote_ip actually reached.Reclaimed as an idle connection during generation
MTU / PMTUD black hole
MITM decryption plus full buffering
Content-Length and get the length wrong, producing an RST. Again this only hits MB-scale image responses, never text calls.The diagnostic matrix
This is the core of the section. There are only two axes: did the failure happen before or after the first byte, and how many bytes arrived.One command for the full timing profile
connect value → connection stage; no ttfb and a round total → idle reclaim; full bytes but total ≈ ttfb + 300 plus curl: (18) → missing terminator; bytes frozen in the tens of KB → MTU.
Other common triggers
Manual interruption mid-flight
write_response_body_failed on the gateway. This is the false alarm most often misread as “the channel is unstable”.An outer timeout fires first
Connection pool and excessive concurrency
Response body exhausts memory
resp.json() under concurrency can exhaust container memory and get the process OOM-killed — which again presents as “the connection dropped for no reason”.Billing impact: which drops cost money and which do not
These two situations get conflated constantly, yet they bill in opposite ways:Platform returns a 500 write_response_body_failed — not billed
This error means the connection died while the gateway was writing image data back to you. The platform automatically retries internally 2-3 times, and only raises the 500 once all of them have failed.No charge is produced in this case. Even a long run of these errors on the same request leaves no corresponding charge on your bill — you are never paying for these failures.write_response_body_failed.
Retry policy should stay disciplined accordingly: transport-level failures are worth retrying, but each retry may be a separately billed call (depending on which of the two categories it lands in). Never write an unbounded retry loop.
How to retry correctly
The core rule: retry only transport-level exceptions, never HTTP-level errors. A 4xx resent ten thousand times is still a 4xx, and it wastes time.Stream the response instead of loading it whole
For large bodies, read chunk by chunk withstream=True. It keeps peak memory down and shows you exactly which stage of the transfer broke:
When it really is worth contacting support
Once you have cleared yourself, escalate if any of the following holds:- It still reproduces reliably after switching to a proper OpenSSL interpreter and dropping to serial execution;
- Only one specific channel or model is failing while others are healthy in the same window;
- The response body arrived complete (byte count matches
Content-Length) but the connection never closes until it times out — that is a missing chunked terminator upstream, a channel-side issue; - The error is unambiguously upstream-facing (
upstream_error, a raw upstream 5xx).
x-request-id, the call time (with timezone, e.g. 2026-07-29 14:32 (UTC+8)), the model name, key parameters such as imageSize, the raw client exception, and the self-check steps you already completed.