Skip to main content

Billing first: a write_response_body_failed 500 is not charged

When the gateway returns 500 with write_response_body_failed / connection reset by peer, the platform has already retried internally 2-3 times and only surfaces the error after all of them failed. These requests incur no charge whatsoever.So even if your logs fill up with these errors, no matching charge appears on your bill — you are not paying for failures. A different case is billed (your client walking away early); see the “Billing impact” section below.
Short answer: image API responses routinely run from ten to several dozen MB, and the break almost always happens while downloading the response (not because the request body is too large — plain text-to-image breaks too). Start by checking which category the console log falls into, then run the macOS / Linux / Node.js self-checks below.

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

On Node.js, distinguish “killed” from “closed”: ECONNRESET means a TCP RST arrived — the connection was killed by something on the path, pointing at a network hop. SocketError: other side closed / ERR_STREAM_PREMATURE_CLOSE means the peer closed gracefully (FIN), pointing at a server-side finalization problem such as a missing chunked terminator. The two point in completely different directions; do not treat them as one symptom.Also, UND_ERR_* can only come from undici (the engine behind Node 18+‘s built-in fetch), whereas read ECONNRESET is libuv’s top-level wording, which axios / node-fetch / the http module all produce. If both kinds appear together, first check whether your app has two different HTTP paths — in that case they are not the same incident at all.

First establish the direction: who hung up

The write_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.
This is not caused by an oversized request body. Image editing uploads reference images, which makes people suspect upload size — but plain text-to-image, whose request body is a few hundred bytes, breaks just as often. The break is in downloading the response: image payloads run from ten to several dozen MB and are the most fragile leg of the whole path.

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 timeouts, 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:
This yields a conclusion people frequently get backwards: connection-stage failures such as UND_ERR_CONNECT_TIMEOUT cannot produce a charge, because the request never reached the gateway. So if you see “lots of connect timeouts” and “lots of charges”, those are definitely not the same requests — investigate them separately rather than forcing one root cause to explain everything.

Four steps to clear yourself

Work through them in order; most cases resolve in the first two:
1

Rule out an application-layer misread: you may have received it and failed to keep it

“No image received” is usually the conclusion after code threw and got caught as “request failed” — not evidence that no bytes arrived. The most common instance: 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:
Field and prefix differences per series are in the base64 prefix reference.
2

Check whether every channel and model is failing at once

If two different channels running two different models are all throwing the same error inside the same time window, channel-specific causes are essentially ruled out — upstreams do not fail that neatly in unison.
3

Check your client runtime: TLS stack (Python) or undici timeouts (Node.js)

Python: check the TLS stack version. Node.js: check undici’s three timeouts. Both are covered in the next two sections. This is the most frequent root cause in practice, it lives entirely on your machine, and one command confirms it.
4

Drop concurrency, go serial, and turn off the local proxy

Rerun the same requests at concurrency 1-2 with the VPN or proxy disabled. If it never reproduces that way, the problem is in your client’s connection handling, local resources (connection pool, file descriptors, memory), or network path — not the channel.

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

Seeing this warning when you import 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): The conclusion is unambiguous: an order-of-magnitude difference across the interpreter switch, and the fact that both channels failed together beforehand already proved it was not channel-related.

What to check on a Linux server (a completely different list)

The TLS-stack check above passes on essentially every Linux box — distro Python links against a normal OpenSSL, so the LibreSSL trap does not exist there. Do not stop at that check. Server-side problems live in the egress path and in container limits, which is a different world from local development.

1. Idle timeouts on cloud NAT gateways and load balancers (the top server-side cause)

This is the number one source of connection 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”.
This is the same mechanism as the “keep-alive reusing a dead connection” case in the Node.js section — on a server the culprit is usually the cloud provider’s NAT gateway rather than local proxy software.
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 }); Python requests: configure the pool via HTTPAdapter);
  • Route around the NAT gateway entirely, e.g. via VPC endpoints.
Other clouds and self-hosted load balancers use different idle values, but the approach is identical: find the shortest idle timeout on the path and set keepalive below it.

2. The TCP keepalive default is effectively “off”

Linux defaults tcp_keepalive_time to 7200 seconds (2 hours), far longer than any of the idle timeouts above, which makes it useless in practice:
Enabling keepalive on the HTTP client itself is the more robust route, since containers often cannot change kernel parameters.

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 with resp.json() under concurrency easily exceeds the container memory limit and the kernel kills the process — which again presents as “the connection just dropped”:
See “Stream the response instead of loading it whole” below for the fix.

5. Proxy environment variables (the sneakiest one on servers)

Servers frequently carry global HTTP_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: That inconsistency produces genuinely confusing results: on one machine curl and Python go through the proxy while Node connects directly (or vice versa), so the two disagree and troubleshooting yields contradictory conclusions. Check first:
APIYI is directly reachable, so on a server you generally want 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:
openai-node’s timeout option is an AbortController-based total-request timeout and does not propagate to any of the three above. Raising timeout from 60 to 300 seconds leaves connectTimeout at 10 seconds. The same is true of AbortSignal.timeout() on a bare fetch().This is the most common reason for “my timeout is huge and it still times out” — the wrong layer was adjusted.

The correct configuration

Widening undici’s three timeouts requires an Agent, 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.
Confirm which stack you are actually on first: node -v, npm ls openai undici axios node-fetch. A UND_ERR_* code only proves undici is underneath — it does not prove you are using the OpenAI SDK. A bare fetch() throws the same codes, and a bare fetch() has no maxRetries at all.

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 as read 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

APIYI is directly reachable inside mainland China and needs no proxy or VPN (see Do I need a proxy to use the API?). That makes turning the proxy off and retesting the cheapest, highest-information single step available to you.To be clear though: a proxy is only the largest suspect variable, not an established root cause. The matrix below is what actually localises the fault.
Two properties make image endpoints far more sensitive than text ones: 30-60 seconds of zero bytes flowing during generation, and an MB-scale body delivered as one burst. When chat endpoints work fine but image endpoints fail, it is usually one of these two.

fake-ip / routing rule miss

In a proxy’s fake-ip mode, a rule miss routes you to an unroutable address like 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

Zero bytes flow for 30-60 seconds after the request goes out, and the proxy reclaims the connection under its idle policy. The signature is a failure time landing on a round number — 30 / 60 / 120 seconds — independent of image size.

MTU / PMTUD black hole

The tunnel MTU is below the path MTU while ICMP “fragmentation needed” is dropped, breaking PMTUD. The classic signature is small requests always fine, large responses always stuck, with received bytes frozen at a few KB to a few dozen KB. Lowering the tunnel MTU to around 1400 usually fixes it.

MITM decryption plus full buffering

Proxies with HTTPS decryption enabled often buffer large bodies whole and may hit a size ceiling, or rewrite chunked into 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. That last row is the highest-value single test: it shrinks the body from several MB to about 1KB. If URL mode succeeds consistently while base64 mode fails consistently, the problem scales with transfer volume, which eliminates the first two columns outright.

One command for the full timing profile

Read it against the matrix: no 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.
When A/B testing proxy-on versus proxy-off, alternate the runs — never batch them. Five proxied runs followed by five direct runs lets a time-window fault contaminate the result into a completely wrong conclusion; we have measured stretches where everything failed, then everything worked minutes later, then failed again. Run proxy → direct → proxy → direct instead, recording the remote_ip each time.

Other common triggers

Manual interruption mid-flight

Ctrl+C while debugging, restarting a process, hot reload, killing a running script — every large response still in transit leaves a 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

Task-queue worker timeouts, Serverless execution limits, gateway/CDN origin timeouts (commonly 60 seconds by default). Any layer shorter than the generation time cuts the connection first — see Must-Read & Best Practices.

Connection pool and excessive concurrency

Connection pool ceilings, local file-descriptor limits, NAT/firewall silently reclaiming long-lived connections. Large responses stay open far longer, so they hit these limits much more often than text endpoints.

Response body exhausts memory

A single 4K base64 payload can reach 20-30MB. Loading it all at once with 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_failednot 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.

Your client walked away early — billed as normal

The other case is the gateway completing delivery while your side hung up first: a client timeout firing, Ctrl+C during debugging, a process restart, or an OOM kill.Generation on the server and upstream has already completed, so these are billed as normal — “I never got the image” does not mean “I was not charged”. Hammering large image requests while troubleshooting runs up a very real bill.
Telling them apart is exactly the table above: check whether the console logged a normal call or a 500 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.
Record every attempt separately (the attempts list above). Otherwise a successful client retry leaves nothing but a clean 200 in your logs, and you will never see how many times the transport actually broke. That data is essential when assessing channel quality, and it stops you from misreading your own retries as channel behaviour.

Stream the response instead of loading it whole

For large bodies, read chunk by chunk with stream=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).
Include in your ticket: the 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.

Must-Read & Best Practices

Synchronous calls, per-model timeouts, base64 handling, disconnection billing

Build Your Own Async Queue

Wrap synchronous calls in a task queue and absorb occasional drops with retries

Do I Need a Proxy?

APIYI connects directly with no proxy; self-checks for certificate and DNS issues

Gemini Image Error Handling

Error codes and finishReason handling for Gemini image generation