Skip to main content
The one-line answer: the image data is complete and decodes into a perfectly good image. What hangs is the very last step of the HTTP transfer — telling the client “that’s all.” So the right fix is not a longer timeout and not a retry, but finishing the response yourself once the data has arrived, and using the image you already have.

This is compatibility, not replacement

The code below adds a protective layer around your existing call logic. It is not a different way to integrate:
  • You do not need to change the endpoint, switch models, swap SDKs, or adjust any request parameter;
  • Healthy requests still follow exactly the path they do today — behavior is unchanged. This compatibility logic never even triggers on a healthy request;
  • It only steps in when the data has fully arrived but the connection refuses to end, and hands you the image you already received.
In short: with it, the bad case is recoverable; without it, the bad case can only end in a timeout error. Everything else stays as it is.

Symptom

When calling the native image generation endpoint (POST /v1beta/models/{model}:generateContent), you may see this combination:
  • The dashboard log shows the request succeeded and it was billed;
  • The client hangs anyway, failing only when its own read timeout fires;
  • Errors look like Read timed out, ETIMEDOUT, or UND_ERR_BODY_TIMEOUT.
It feels like “the dashboard says it finished in 30 seconds, but I still don’t have the image 5 minutes later.”
The same code used to work fine, and now it hangs at this final step. This scenario is recent — it is not a long-standing flaw in how you integrated. So there is no need to second-guess your call pattern; you just need to add the compatibility layer described below.

It arrives in windows

This matters, because it determines how you reproduce it and how you interpret what you see:
  • Inside a window: consecutive calls hang, all of them, without exception;
  • Outside a window: dozens of consecutive calls run perfectly, with not a single occurrence.
So it is neither “always reproducible” nor “a rare random glitch.” If your test happens to miss the window, everything looks 100% healthy and it is easy to wrongly conclude “it’s fixed.” If you happen to land inside one, it looks like everything is broken. Both impressions are real — just don’t draw a long-term conclusion from either one alone.
Streaming requests (:streamGenerateContent) and text-only models are generally unaffected. This page is about non-streaming image generation, where the response body is large — the JSON body for a 2K image is on the order of 13 MB.

Cause

Image responses are sent with Transfer-Encoding: chunked. Per the HTTP/1.1 spec, once the server has sent the last data chunk it must send a terminating chunk (a zero-length chunk) to tell the client “this is the end.” That is the step that fails: every data chunk arrives, but the terminating chunk is never sent and the connection is never closed. The client is left holding a complete, usable JSON document (the image base64-decodes fine), with no way to know the body is finished. So it keeps waiting — until its own read timeout fires. An analogy: the parcel is already on your doorstep, but the courier forgot to tap “delivered.” You sit watching the tracking page for an update while the package is right outside.
Within certain time windows the route is not performing this final step on the response. We are continuing to push for a server-side fix; what this page describes is the client-side safety net to use in the meantime.That safety net has independent value, and you do not need to roll it back once the server side is fixed: when the terminating signal is present it never triggers at all, so it goes silent by itself — zero overhead, zero maintenance burden.
Three conclusions that directly determine how to handle it:

The data is complete

Not packet loss, not a network quality issue, and not a transfer cut off halfway. The bytes you have parse cleanly and the image is fully usable.

Waiting does not help

Once it hangs, the server sends not one more byte. Verified by waiting 330 seconds with no change. Raising the timeout to several hundred seconds only delays detection.

Not tied to one machine

Inside a window, multiple points of presence fail at the same time and recover at the same time. Switching domains or entry points does not route around it — it has to be handled client-side.

How to identify it

If all three of these hold, this is almost certainly what you are hitting:
1

The response carries Transfer-Encoding: chunked and no Content-Length

Meaning the body length was never declared up front, so the client can only rely on the terminating chunk to know it is done.
2

The bytes received so far already parse as complete JSON

Run json.loads over what you have — it succeeds, and the inlineData.data inside base64-decodes to a complete, usable image.
3

After that parse succeeds, no new bytes arrive for a long time

No terminating chunk, and the connection is not closed either. It simply stays open.

How it differs from two similar cases

All three produce similar-looking errors, but the root causes and the fixes are completely different. Do not apply one set of criteria to all of them: If your error is ECONNRESET, that is a different class of problem; see Connection Drops.

Compatibility layer: finish the response on the client side

The idea is simple: do not wait for the connection to end — finish as soon as the bytes you already have parse as complete JSON.

Critical: keep a grace period

Do not finish the instant parsing succeeds. In the normal case the terminating chunk is usually in the very next TCP segment, only milliseconds away. Cutting off as soon as parsing succeeds would misclassify “the terminator was a few milliseconds late” as “the server never sent it.” The correct approach: once parsing succeeds, wait a short while longer (3–5 seconds is a good default). If any bytes arrive during that window, carry on normally. Only if nothing arrives do you declare it stalled and finish the response yourself.
This is not an optional refinement. Skipping the grace period makes the detection completely useless — every healthy request gets misreported as a failure. Our first implementation hit exactly this: an entire batch of healthy requests was flagged as broken.

The checks to clear before finishing a response

Ordered from cheapest to most expensive. If any one of them fails, keep waiting — do not finish the response: Checks 6 and 7 together drive the false-positive rate to near zero: the response is a single JSON object, so parsing necessarily fails while data is still missing. In other words, you can only finish a response that genuinely arrived in full.

Python

Read the stream on a background thread and implement the grace period with a queue timeout on the main thread:
Why the extra thread? Because requests has a single read timeout governing both “waiting for the first byte” and “waiting between chunks.” When the response stalls, the read loop blocks on the next read and the grace period never gets a chance to run — a straightforward for chunk in ... version with a timer does not fire in the very case it is meant to catch.Reading on a background thread and calling q.get(timeout=term_grace) on the main thread is what actually separates the two timeouts. We hit this ourselves: one timeout covering two different things merges “slow generation” and “never finished” into a single indistinguishable failure.
This version parses once, when the grace period expires, rather than after every chunk — which satisfies check 5 above for free. A body of a dozen-plus MB never gets parsed repeatedly.

Node.js

Node.js needs no extra thread — reader.read() is already a promise, so Promise.race can cap how long you wait for the next chunk:
Note the healthy path comment in both snippets: when the server finishes the response properly, the loop exits naturally via done or the end of iteration, and the grace-period branch is never entered. That is what “compatibility rather than replacement” means in practice — your existing success path is byte-for-byte unchanged.

Choosing timeouts

The easiest mistake here is using one timeout value for two different things: “waiting for the upstream to generate the image” and “the silence between two chunks after the first byte.” Their normal durations differ by an order of magnitude. Merge them into one value and you either kill slow generation as if it were a failure, or leave genuinely stalled requests waiting for minutes.

✅ Recommended

Set the two separately: leave room for generation before the first byte, then keep inter-chunk silence down to seconds and let the client-side completion above catch the rest. Failures surface in seconds and no healthy request is harmed.

❌ Avoid

One 300-second timeout covering everything “just in case.” When the response hangs, the server sends nothing further, so a longer wait changes nothing and only delays detection.
If your product includes slower tiers such as 4K: the total timeout can be longer (the model really does need that time), but the inter-chunk silence threshold should not grow with it — they are two different things, so do not scale them together.
Node.js users: undici (the engine behind the built-in fetch in Node 18+) has three independent timeouts, and an SDK’s timeout option does not reach any of them. See the “Node.js: three independent timeouts” section of Connection Drops for the correct configuration.

Retries and billing

Once you have determined the server never finished the response, work through this order:
1

Use the image you already have — this ends it in almost every case

The data is complete and the image is fully usable, so no retry is needed. This is both the cheapest path and the one that avoids being billed twice.
2

Retry only if parsing genuinely failed

If the bytes you have really cannot be parsed into complete JSON, then retry. Use a fresh connection and leave 2–3 seconds between attempts.
3

Back off after repeated failures instead of retrying tightly

Because the problem arrives in windows, retrying immediately is likely to land in the same window. If three attempts in a row hang, back off for 30 seconds before trying again.
Billing: for these requests the upstream already generated the image and started sending it back, so delivery counts as complete and the request is billed normally. “My client timed out” does not mean “I wasn’t charged” — which is exactly why step one matters most: you already paid for that image, so throwing it away is the real waste.For the full breakdown of which connection drops are billed and which are not, see the “Billing impact” section of Connection Drops.

Rollout notes

These are language- and framework-agnostic, drawn from our own rollout:
  • Put it at a single network-layer entry point, not scattered across call sites. Make it part of the “send a request” action itself. That covers every image path at once, leaves business code untouched, and means there is only one place to change once the server side is fixed.
  • What you actually need is incremental read access. The prerequisite is being able to see what has arrived before the response ends. Nearly every HTTP client offers this (streaming reads, chunk callbacks, progress events), but it is usually not the default — the default “just give me the whole body” is precisely the path that hangs. This is where most of the work is.
  • Time from the last chunk received, not from the start of the request. Reset the grace-period timer on every chunk. That way you neither punish slow networks nor miss the “nothing is moving at all” state.
  • Put it behind a switch. Keep the behavior behind a flag you can turn off at any time. If anything unexpected shows up right after release, flip it off to restore the old behavior — no emergency deploy needed.
  • Add telemetry. Log every time the completion path fires (timestamp, byte count, wait duration). It serves three purposes: quantifying how often this actually happens, confirming the layer is doing its job, and confirming the counter drops to zero after the server-side fix — which is the only objective basis for deciding the layer can be retired.
  • A bonus improvement while you are in there. Once you have incremental reads, you can surface real progress to users (“receiving data, X.X MB”). That long download was previously a complete black box on their side.

How we deployed it ourselves

We have already completed and verified this work on our own AI image studio (imagen.apiyi.com). Using a mock service that reproduces the fault (sends the full body, then neither signals the end nor closes the connection), we ran this comparison: Conclusion: zero impact on healthy requests, while failing requests go from “wait for the timeout, then fail” to “get the image within seconds.”

Common questions

No. Finishing requires that the bytes received parse as a complete JSON document — parsing necessarily fails while data is still missing. Add the 3–5 second grace period on top and healthy requests are not misclassified. The first two rows of the comparison table above are exactly these two cases side by side.
No. What is checked is the integrity of the whole response body, not the image itself. If the JSON parses, the image data is complete — half an image corresponds to a parse failure, and that never triggers completion.
No. It does not replace the server-side fix. It delivers a result that has already been produced and already been billed into the user’s hands, while avoiding the double billing that blind retries cause. The telemetry it produces also helps characterize when the fault occurs.
There is no rush. When the terminating signal is present this logic never fires, so it costs nothing. Wait until the telemetry has been at zero for a sustained period, then consider cleaning it up.

When to contact support

If, after adding the compatibility layer above, any of the following still holds, gather your materials and contact support:
  • The bytes you have never parse into complete JSON (this is not the scenario on this page — the transfer really was cut short);
  • Even with client-side completion, you receive no response headers at all for a long time (that means the upstream has not started sending yet — slow generation or an upstream fault, not a completion problem);
  • The stall rate stays consistently high rather than clustering in time windows, reproducing steadily over a long period.
When filing a ticket, include: x-request-id, the call time (with timezone, e.g. 2026-08-03 13:15 (UTC+8)), the model name and key parameters such as imageSize, the raw client-side exception, and how many bytes you had received when it hung.

Connection Drops

ECONNRESET, SSL EOF, undici’s three timeouts, and the local-proxy diagnostic matrix

Must-Read & Best Practices

Synchronous calls, timeout tiers, base64 handling, and billing on dropped connections

Build Your Own Async Queue

Wrap synchronous calls in a task queue, absorbing occasional failures with retries and persistence