DEV Community

Alex Chen
Alex Chen

Posted on

Name the Failure Before You Repair the JSON

{"cards": [{"front": "What is a hash table", "back": "A map from keys to
Enter fullscreen mode Exit fullscreen mode

That truncated blob is what a hosted model hands back when it runs out of output budget mid-object. It is not your parser's fault, and no amount of "please return valid JSON" will finish the sentence. Beginners usually respond by retrying, which spends tokens on the same wall.

Here is the scenario, and it is hypothetical on purpose: a student project turns lecture transcripts into flashcard JSON, one call per transcript chunk. Assume a failure rate high enough to be annoying — that premise is invented, not measured, because measured rates depend on your prompt and your provider. One learning question holds the rest together: does a repair loop need to know why parsing failed, or is "retry with the parser message" good enough?

The short answer is that a repair loop without labels is a doctor prescribing before diagnosing. Two of the four failure classes below should never trigger a model call at all.

Prerequisites and the honest boundary

Python 3.10 or newer, standard library only, and any OpenAI-compatible chat endpoint. How you point at one — base URL, key, model identifier — comes from that provider's documentation; the field names in the sketch below are the common ones, not a promise about any specific service.

MonkeyCode is one option here because it currently advertises free model access and a free server option, which makes a learning experiment cheaper to run than a paid key would. Quotas and free allotments move constantly, so check the number on the official site the day you start rather than trusting a figure quoted in a blog post.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Four failures, two of which you should not repair

Truncation. The response stopped at the output cap. The braces never closed because the generation never finished. Repairing this costs a full call and usually fails again, because the missing tokens are gone.

Wrapper noise. The model wrote "Sure, here's your JSON" and fenced the payload. The JSON is fine; your extractor was too literal. Strip it locally and move on.

Structural damage. A missing brace, a trailing comma, a single-quoted key. Some of it is fixable with string normalisation; whatever survives normalisation needs one targeted repair call carrying the parser's exact message.

Semantic mismatch. Valid JSON, wrong shape: cards is a list of objects with term instead of front. Parsing succeeded, meaning failed. This one is worth a call, because the model can only fix a schema it is told about.

Fixture finish_reason Class Model calls allowed
{"cards": [{"front": "What is a length truncation 0 — raise the cap or split the input
fenced JSON with a chatty preamble stop wrapper noise 0 — strip locally
{"cards": [{"front": "Q", "back": "A" stop structural 1 — send the parser message
{"cards": [{"term": "hash table"}]} stop semantic 1 — send the schema errors

The classifier

The classifier is the whole article. Everything else is bookkeeping around it. Note that finish_reason is checked before the JSON parser, because a truncated object will happily produce a misleading "unexpected end of input" error.

# harness.py — illustrative sketch, not executed while writing this.
from __future__ import annotations
import json, re

TRUNCATED, NOISY, BROKEN, MISMATCH = "truncated", "wrapper_noise", "structural", "semantic"

def strip_envelope(raw: str) -> str:
    fenced = re.search(r"```

(?:json)?\s*(.+?)

```", raw, re.S)
    return (fenced.group(1) if fenced else raw).strip()

def soften(text: str) -> str:
    text = re.sub(r",\s*([}\]])", r"\1", text)  # trailing commas
    return text.replace("'", '"')                 # single-quoted keys

def classify(raw: str, finish_reason: str | None) -> tuple[str, str]:
    if finish_reason == "length":
        return TRUNCATED, "hit the output cap; a repair call cannot add missing tokens"
    text = strip_envelope(raw)
    try:
        json.loads(text)
    except json.JSONDecodeError as exc:
        try:
            json.loads(soften(text))
        except json.JSONDecodeError:
            return BROKEN, exc.msg
        return NOISY, "recoverable without a model call"
    return MISMATCH, "parsed; the schema decides"

def schema_errors(obj) -> list[str]:
    if not isinstance(obj, dict):
        return ["expected an object with a 'cards' list"]
    cards = obj.get("cards")
    if not isinstance(cards, list):
        return ["'cards' must be a list"]
    return [f"cards[{i}] needs string 'front' and 'back'"
            for i, c in enumerate(cards)
            if not (isinstance(c, dict) and isinstance(c.get("front"), str)
                    and isinstance(c.get("back"), str))]
Enter fullscreen mode Exit fullscreen mode

A loop with a budget instead of a loop with hope

An unbounded while True around a paid endpoint is a wallet with a bug. Give each class a call allowance, and let truncation raise instead of retrying. The loop below also refuses to re-enter the schema branch after its single allowance is spent, which keeps a stubborn model from burning your free tokens in a quiet circle.

CALL_BUDGET = {TRUNCATED: 0, NOISY: 0, BROKEN: 1, MISMATCH: 1}

def repair_prompt(cls: str, detail: str) -> str:
    if cls == BROKEN:
        return f"The JSON parser said: {detail}. Return only the corrected JSON."
    return f"The JSON parsed but the shape is wrong: {detail}. Return only corrected JSON."

def generate(call_model, transcript: str):
    raw, finish = call_model(transcript)
    spent = 0
    while True:
        cls, detail = classify(raw, finish)
        if cls == MISMATCH:
            obj = json.loads(strip_envelope(raw))
            errors = schema_errors(obj)
            if not errors:
                return obj, spent
            detail = "; ".join(errors)
        if cls == TRUNCATED:
            raise SystemExit("raise the output cap or split the transcript; do not retry")
        if spent >= CALL_BUDGET[cls]:
            raise SystemExit(f"{cls}: budget spent after {spent} call(s): {detail}")
        raw, finish = call_model(transcript, repair_prompt(cls, detail))
        spent += 1
Enter fullscreen mode Exit fullscreen mode

Notice what spent measures: repair calls, not total calls. If you also want a session-wide ceiling for your free allotment, count the first call too, and log the count next to every result so a surprising bill has a paper trail.

Checking it without spending anything

You can validate the classifier offline, before any network call exists. Four fixtures, four expected labels, and an assertion that the first two never increment a call counter:

FIXTURES = [
    ('{"cards": [{"front": "What is a', "length", TRUNCATED),
    ('Sure! Here you go:\n```

json\n{"cards": []}\n

```', "stop", NOISY),
    ('{"cards": [{"front": "Q", "back": "A"', "stop", BROKEN),
    ('{"cards": [{"term": "hash table"}]}', "stop", MISMATCH),
]

for raw, finish, expected in FIXTURES:
    got, detail = classify(raw, finish)
    print(f"{got:12} expected {expected:12} {detail}")
    assert got == expected, (raw, got, expected)
Enter fullscreen mode Exit fullscreen mode

Expected output is four aligned lines ending in truncated, wrapper_noise, structural, semantic, and no exception. If an assertion fires, your normaliser is doing more or less than you think — which is exactly the kind of bug that otherwise surfaces at 2 a.m. as "the model is broken".

Where the free tier and the free server actually fit

A nightly batch is the natural home for this: a free server option keeps a small worker alive to process a queue of transcripts while the free model access absorbs the drafting calls. Keep the loop idempotent — write each result as it lands instead of at the end — because shared free infrastructure can restart without asking you, and a job that only saves on completion loses everything it learned.

Two boundaries matter more than the convenience. First, a free tier is not a service-level agreement, so nothing time-critical belongs in that worker. Second, lecture transcripts carry names and student IDs; read the provider's data terms before uploading anything you would not post publicly.

What still breaks

Long inputs. If a transcript does not fit alongside the output budget, truncation becomes your default outcome and the repair budget never fires. Split the input first, then measure.

Also, a model that keeps emitting the wrong schema after one honest correction is telling you something about your prompt, not its intelligence. Fix the instruction and the example, and the semantic class mostly disappears.

Try one prediction first

Before you run the fixtures, decide which of the four should cause the loop to stop entirely rather than attempt a repair, and say why in the comments. My answer is in the code, but a disagreement with a concrete counterexample is more useful to me than agreement.

If you want to exercise the loop against a real endpoint without paying for the experiment, MonkeyCode's free model access is one place to start — confirm the current free allotment on the official site first, because that number changes.

Top comments (0)