DEV Community

Taylor Wang
Taylor Wang

Posted on

The Model Sent Perfect JSON. My Parser Only Accepted Naked JSON.

A few nights ago my side-project bot started crashing every few minutes. The logs all pointed at one line: a JSON.parse call that suddenly refused the model's output. The HTTP status was 200, the content field was full, and yet the parser threw before the bot could use a single word. My first instinct was to blame the model, and that instinct was completely wrong.

I run this bot on MonkeyCode's free server, and its model calls go through MonkeyCode's free model access. Disclosure: This article was prepared as part of MonkeyCode's product outreach. That setup is exactly where you cannot afford to waste tokens on blind debugging. So I slowed down, captured the raw response, and found a bug that had nothing to do with the model.

The Symptom That Woke Me Up

The error log looked like this, repeated across a dozen messages:

SyntaxError: Unexpected token '`', ..."```

json\n{ ... }"... is not valid JSON
    at JSON.parse (<anonymous>)
    at parseSummary (bot.js:42)


Enter fullscreen mode Exit fullscreen mode

Every failure pointed at the same line, and every failure happened after a successful HTTP exchange. The bot called the model, the model answered, and then my parser crashed while turning that answer into an object. How many times have I blamed a model for a bug that actually lived in my own code?

Step One: Stop Guessing and Capture the Raw Response

The first rule I keep relearning is that you cannot debug a parser by staring at the parser. I wrote a tiny reproduction script that called the same endpoint. It saved the raw response body to a file and printed it without any transformation.


javascript
// repro.mjs
import fs from "node:fs";

const res = await fetch(process.env.MODEL_URL, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: process.env.MODEL_NAME,
    messages: [{ role: "user", content: "Return a JSON summary." }]
  })
});

const raw = await res.text(); // no .json() yet
console.log("HTTP", res.status);
console.log(raw);
fs.writeFileSync("fixture.txt", raw);


Enter fullscreen mode Exit fullscreen mode

Three minutes later the answer was obvious. The model had done exactly what I asked: it produced valid JSON. Then it wrapped that JSON in a Markdown code fence, which is what chat models love to do. My parser demanded naked JSON, so it rejected a perfectly good answer.


`text
HTTP 200
```json
{"title":"Why parsers fail","bullets":["trust the status code","ignore the shape"]}
```
`

Enter fullscreen mode Exit fullscreen mode

Root Cause: I Trusted response.ok and Skipped Validation

Here is the part that hurt. The HTTP status was 200, the choices[0].message.content field was populated, and my code treated those two facts as proof of a usable answer. Neither fact says anything about whether the content is parseable JSON, let alone whether it contains the fields my bot needs.


javascript
// before: the fragile version
const data = await res.json();
const content = data.choices[0].message.content;
const summary = JSON.parse(content); // boom


Enter fullscreen mode Exit fullscreen mode

The real bug was not the code fence; it was the assumption that a successful HTTP response guarantees a successful application-level outcome. That assumption made the parser brittle, and it hid the problem until a real user hit it. A status code only tells you the server accepted the request; it says nothing about the meaning of the payload.

The Fix: Extract, Then Validate, Then Fail Loudly

The repair had three layers, and only the first one touched the parser. First, I wrote a small extractor that tolerates fences and stray prose around the JSON object. Second, I added a shape check so a valid-but-wrong object cannot pass silently. Third, I let the failure surface loudly instead of swallowing it.


javascript
// after: tolerant extraction
function extractJson(text) {
  if (!text) throw new Error("empty model output");

  const fenced = text.match(/

```(?:json)?\s*([\s\S]*?)```

/);
  const candidate = fenced ? fenced[1] : text;

  const start = candidate.indexOf("{");
  const end = candidate.lastIndexOf("}");
  if (start === -1 || end <= start) {
    throw new Error("no JSON object found in model output");
  }

  return JSON.parse(candidate.slice(start, end + 1));
}


Enter fullscreen mode Exit fullscreen mode

javascript
// after: shape validation
function parseSummary(content) {
  const parsed = extractJson(content);

  if (typeof parsed.title !== "string" || parsed.title.length === 0) {
    throw new Error("summary is missing a non-empty title");
  }
  if (!Array.isArray(parsed.bullets) || parsed.bullets.length === 0) {
    throw new Error("summary is missing bullets");
  }

  return parsed;
}


Enter fullscreen mode Exit fullscreen mode

The extractor finds the first { and the last }, which handles fences, preamble text, and trailing commentary in one pass. The validator then checks the fields the rest of the bot depends on. A syntactically valid object with the wrong shape now fails fast instead of producing nonsense downstream.

The Regression Test Plan That Keeps It Fixed

I saved the original failing response as a fixture and wrote a small test matrix. The real bug was not the fence; it was my assumption that the model would always output one exact shape. Here is the matrix that now runs in the bot's CI:


`text
# fixture cases (input -> expected)
{"title":"x","bullets":["a"]}                       -> parsed
```json\n{"title":"x","bullets":["a"]}\n```         -> parsed
Here you go:\n{"title":"x","bullets":["a"]}         -> parsed
{"title":"x","bullets":["a"]}\nHope this helps!    -> parsed
{"bullets":["a"]}                                   -> rejected: missing title
{"title":"x"}                                       -> rejected: missing bullets
{"title":"x","bullets":["a"]                        -> rejected: truncated JSON
Sorry, I cannot do that.                            -> rejected: no JSON found
`

Enter fullscreen mode Exit fullscreen mode

Each row maps to a failure mode I have seen in real model output, and the test file is now part of the bot's CI. If the parser ever breaks again, it breaks in the test run at noon, not in the bot at 3 AM.

The Reusable Debugging Workflow

The specific bug was small, but the workflow that found it is worth keeping:

  1. Capture the raw response before you parse it. Save it to a file; never re-run the model to debug a parser, because the next response will be different.
  2. Check the shape, not just the status code. HTTP 200 plus non-empty content is not a contract, and treating it as one will hide real bugs.
  3. Reproduce with the smallest script possible. My repro was about fifteen lines, and it found the root cause in three minutes.
  4. Turn the failure into a fixture. The bug that bit you once becomes the test that protects you forever.

Limitations and Who Should Not Use This

Tolerant parsing is a band-aid, not a contract. If your application needs a guaranteed schema, use structured output or function calling instead. Do not ask the model for JSON in prose and hope for the best.

This extractor also assumes an object, not an array; adjust the delimiters if your use case differs. When the extractor throws, you still need a retry policy or a human fallback, because no parser can fix genuinely broken model output. If you are building a payment flow or any system where a wrong field is worse than a crash, fail loudly rather than guessing. A tolerant parser in the wrong place can turn a visible error into silent data corruption.

The next time a model call misbehaves, print the raw response before you blame anyone. That one habit has saved me more debugging hours than any other trick this year. It will probably save yours too.

Top comments (0)