Amiverse

The Long, Winding Hunt for an SSE Stream Bug

This bug dragged on for days. The cause turned out to be one line in our Dockerfile: apt-get install -y nodejs npm.

Our team's app runs on an internal k8s platform and has a file-export feature: after a chat with the AI, users can export the conversation as Word, PDF, Excel, or PPT. The overall path looks roughly like this:

Browser → Python FastAPI → Node.js child process → LLM API (SSE stream)

The Node.js child process exists because we needed to embed an agent framework that doesn't support Python, so that part runs on Node.js. Python spawns the child process with asyncio.create_subprocess_exec; the child process makes streaming requests to the LLM through the OpenAI SDK and passes the results back to Python over stdout. This all started right after a holiday. I dragged myself back to the office, opened the team group chat, and found it flooded with bug reports from customers...

The symptom: the user clicks export, the AI starts responding, and reply chunks stream back one by one. Then the moment the agent starts reading the skills files, the frontend throws an error:

LLM stream error: Premature close, retrying...
LLM stream error: Premature close, retrying...
LLM stream error: Premature close, retrying...

After a dozen or so retries the task fails for good, and the UI finally shows:

The task was interrupted due to a server restart.

First guesses were the usual suspects: LLM API unreachable? Network timeout? Or a server restart cutting the task short?

It only happens in some environments

I looked over the deployments in each environment, and the pattern was odd:

  • main branch: fails
  • test branch: fails
  • dev branch: fine
  • local dev machine: fine

Same code, same LLM config, different outcomes.

Network was the first suspect. In a K8s cluster, different pods can land on different physical nodes, so could some nodes have broken egress? Then came another possibility: the proxy. When launching the Node.js child process, we pass it a clean env dict that inherits nothing from the parent, so HTTPS_PROXY and friends might simply get lost. That became the first fix attempt: pass the proxy variables from the parent process through to the child:

for _proxy_key in ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", ...):
    if _proxy_key in os.environ:
        env[_proxy_key] = os.environ[_proxy_key]

Deploy, wait, test... still failing.

Logs, logs, and more logs

With so little information, debugging was mostly guesswork, so logging came first. Three additions in total.

The first went into Node's stream error handling. The original code printed only the message, no stack:

} catch (e: unknown) {
  emit("thinking", { chunk: `[LLM stream error: ${e.message}, retrying...]` });
  continue;
}

Changed to also write the full stack to stderr:

} catch (e: unknown) {
  streamError = e instanceof Error ? e : new Error(String(e));
  const stack = streamError.stack ?? streamError.message;
  process.stderr.write(`[LLM stream error] turn=${turn} message=${streamError.message}\n${stack}\n`);
  emit("thinking", { chunk: `\n[LLM stream error: ${streamError.message}, retrying...]\n` });
  continue;
}

The second was in Python. The original code read stderr in one pass only after the child process exited, losing most of the real-time logs. It became a background task that drains stderr as it arrives:

async def _drain_stderr() -> None:
    async for raw in proc.stderr:
        line = raw.decode(errors="replace").rstrip()
        stderr_lines.append(line)
        logger.error(f"agent stderr: {line}")

stderr_task = asyncio.create_task(_drain_stderr())

The third was at Node.js startup. A logging function added at the main() entry point prints the Node.js version, OpenAI SDK version, proxy settings, and DNS resolution results:

async function log(): Promise<void> {
  const write = (msg: string) => process.stderr.write(`[diag] ${msg}\n`);
  write(`node_version=${process.version}`);
  write(`openai_sdk_version=...`);
  write(`https_proxy=${...}`);
  const ips = await resolve4(hostname);
  write(`llm_resolved_ips=${ips.join(",")}`);
}

Deploying brought a new problem: no [diag] output anywhere in the server logs. The change didn't take effect? A quick check jogged my memory: agent.ts is TypeScript, and it has to be compiled to dist/agent.js first, so every .ts edit requires one more step:

cd src/backend/... && npm run build

Why was the AI chat fine?

By then the K8s platform team had replied: curl to the LLM API from their machines works, and they suggested we check network reachability. That made it more confusing. Is the LLM API reachable or not?

There was a ready-made counterexample sitting right there: the AI chat had always been fine. Users can chat with the AI for minutes at a stretch, with long replies and long-lived connections, and it never dropped once. AI chat rides the same SSE long connection to the same LLM endpoint. If this were a network problem, how would it escape? Besides, the export itself does get AI reply chunks at the start; it only blows up when the agent begins reading the skills files. How could that be the network...

A network timeout doesn't explain this, and neither does an HTTP/2 vs HTTP/1.1 protocol difference; nodes don't vary in that way, and the LLM API itself is reachable. So between AI chat (Python calling the LLM directly) and file export (Python → Node.js → LLM), what's the only difference? The Node.js sitting in the middle.

The stream breaks at the end

Going back over the error logs, one detail stood out: the AI did produce output. The body content and the tool call JSON parameters all printed before the error. The LLM's response had actually arrived complete, and what failed was the very last moment of the stream. The next question to chase was how Node.js closes the stream.

The culprit: undici v5

The diagnostic logs ran once on the server and once locally, and the results sat side by side:

Field Server (failing) Local (working)
node_version v20.19.2 v24.18.0
openai_sdk_version 4.104.0 4.104.0
llm_model claude-opus-4-6 claude-opus-4-6
llm_resolved_ips 10.215.xx.x 10.215.xx.x
https_proxy (not set) (not set)

Everything matched except the Node.js version. Same resolved IPs, no proxy on either side, same SDK version. The only difference was v20 vs v24.

Following the version lead, the cause became clear quickly. When the OpenAI Node.js SDK makes streaming requests, the HTTP client underneath is undici: Node.js v20 bundles undici v5.x, while v22 and later bundle v6.x. undici v5 has a known bug: after the server pushes the last SSE event and closes the TCP connection, undici v5 throws Premature close upon reading EOF, even when every byte has already arrived.

Every odd observation lined up now. The content did arrive, and what failed was the stream termination. AI chat uses Python's httpx and never goes through undici, so it was fine. Locally it was fine because my machine runs v24. The bug is fixed in undici v6, which ships with Node.js v22.

The fix

The Dockerfile used to read:

# Install Node.js LTS
RUN apt-get install -y nodejs npm

That line installs Node.js from Debian 12's apt repository, which yields v20. The fix was to install a pinned version via NodeSource's script. One small detail: the original nodejs install came before curl, so the order had to be adjusted:

# Install curl first
RUN apt-get install -y --reinstall lsb-release curl

# Then install Node.js v22 LTS from NodeSource
# undici v6 fixed the Premature close bug at the end of SSE streams
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
    && apt-get install -y nodejs

Rebuild, deploy: [diag] reports node_version=v22.x.x, the export feature works again, and Premature close never came back.

A bonus find: the image name collision

The investigation also dug up a hidden problem. On k8s, multiple branches of the same app all used the same image names:

# All branches share these names
name: backend
name: frontend

Whenever any branch builds a new image, it overwrites the shared name. The frontend image bakes in ROOT_PATH (the route prefix), which differs per branch, so after an overwrite, other branches' frontend assets start returning 404. The fix is easy: give each branch a unique image name:

name: backend-main
name: frontend-main

Takeaways

Looking back, what finally pinned it to the Node.js version was comparing the [diag] logs from both environments line by line. When a bug only shows up in some environments, systematically collecting the environment differences beats guessing by feel, and the more complex the environment, the truer that is.

There were detours. Time went into suspecting network unreachability, but the counterexample, a working AI chat, should have killed that idea much earlier. When a hypothesis can't explain a counterexample, drop it immediately. The Premature close message itself says almost nothing. What actually helped was the other observation: the content had arrived, and the error happened at the end. That alone cut the search space in half. Where and when an error happens often matters more than what the message says.

One more thing that stuck with me: when the code hasn't changed, the config hasn't changed, and the API hasn't changed, yet the behavior differs, the runtime environment is the prime suspect. The undici v5 bug is well documented in GitHub issues, but without knowing to look in that direction, you may never find it. apt-get install -y nodejs looks like a harmless tool install, yet with the version uncontrolled it buried the whole problem. Runtimes like Node.js should have their versions pinned and managed like application code; don't count on whatever the system repository defaults to.

Finally, the log() added for this investigation turned out not to be a one-off. On every deploy since, a glance at [diag] confirms the runtime environment is right, sparing a lot of why-is-it-different-again confusion. Printing an environment snapshot at key infrastructure entry points is worth making a standard practice.

One remaining mystery

A question is still unsolved: why was the dev environment immune while main and test had the problem? Before this fix, dev, main, and test all used exactly the same build script, all with apt-get install -y nodejs. My guess is that images built at different times picked up different default versions from the system repository. Or maybe dev happened to land on a pod with a different default environment. But in theory every app builds its own independent Docker image and shouldn't feel the host machine at all... still a mystery to me.