TLDR: If your error handler calls res.json() and then res.text() as a fallback, the fallback silently throws "Body has already been read" — and the real error disappears completely.

The Setup

I was building an engagement letter generator for a law firm client, a custom practice management app I'm building for my business partner, an attorney.

The flow: click a button on the matter page → call an internal Python renderer (a serverless function that fills a docxtpl (a Word-doc Jinja2 templating library) template) → show the result in a review modal.

Ship the button. Test it. It fails.

The Error That Made No Sense

The error coming back was "Body has already been read".

Not a Python renderer error. Not a status code. Not anything that pointed at the actual problem.

I went deep on the wrong things first — I'll be honest about that.

Was it the blueprint template context? Spent time there.

Was it the Jinja conditional blocks? Also there.

The Actual Bug

Here's the error handler that was in place — and it looks perfectly reasonable:

if (!res.ok) {
  let detail = "";
  try {
    const body = (await res.json()) as { error?: string };
    detail = body.error || "";
  } catch {
    detail = await res.text(); // ← the "fallback"
  }
  throw new Error(`Python renderer ${res.status}: ${detail.slice(0, 500)}`);
}

The logic seems solid: try to parse JSON for a structured error, fall back to raw text if that fails.

But here's the thing.

When you call res.json(), the fetch Response stream gets consumed — even if the JSON parse fails and throws.

So when Vercel's deployment-protected preview URL returned an HTML auth challenge instead of a JSON error, res.json() tried to parse that HTML, threw a SyntaxError... and the catch block tried res.text() on a body that was already gone.

"Body has already been read" is what you get. The real error — the Python renderer output, the actual status, all of it — completely vanished.

The Fix

Read the body exactly once. Then decide what to do with it:

if (!res.ok) {
  const text = await res.text(); // read once — no second chance
  let detail = text;
  try {
    detail = (JSON.parse(text) as { error?: string }).error || text;
  } catch { /* not JSON — keep raw text */ }
  throw new Error(`Python renderer ${res.status}: ${detail.slice(0, 500)}`);
}

res.text() first. JSON.parse(text) second. The stream is touched once. Now the actual renderer error surfaces.

And once it surfaced? Immediately obvious what was really wrong: the renderer was being called via a deployment-protected Vercel URL instead of the production domain. A second fix, same session. One commit each.

Why This Matters

The "try JSON, fallback to text" pattern is everywhere. It feels defensive. It feels right.

But res.json() doesn't just parse — it reads first. The fallback reads from an empty stream and throws a confusing native error that buries the real one.

If your API error messages ever come back nonsensical — "Body has already been read", undefined, empty string — check your error handler before you check anything else.

The bug was never in the Python renderer. It was in the code I wrote to watch for bugs in the Python renderer.