{"slug": "200-ok-content-null-what-actually-breaks-when-you-build-on-ai-apis", "title": "200 OK, content: null — what actually breaks when you build on AI APIs", "summary": "A developer building Hitou, a personalized song generation service, documented real-world failure modes of AI APIs that reached production. The team found that LLM API responses with status 200 and finish_reason 'stop' can still have null content, and that defensive code like `str(payload.get('lyrics', ''))` fails silently when the key exists but is null, causing the word 'None' to be sung by a music API. The developer shared field notes on guarding against such failures, including per-request provider ignore lists and AST-based tests.", "body_md": "One morning our SEO copy generator started failing with `AttributeError: 'NoneType' object has no attribute 'strip'`\n\n. The API call had returned **200 OK** with `finish_reason: \"stop\"`\n\n. A completely healthy-looking response, except `message.content`\n\nwas `null`\n\n.\n\nSome context before we chase that null. We build [Hitou](https://hitou.site), a small web product that writes and produces a personalized song about someone you love: an LLM writes lyrics from the story you tell, a music model sings them, and a few minutes later there's a track with a gift page. The happy path took a few weeks. Everything since has been learning, in production, how AI APIs fail.\n\nThis post is the field notes: the failure modes that actually reached (or almost reached) paying users, and the guards that stopped them from happening twice.\n\nA wizard collects the story (who the song is about, the occasion, the inside jokes). An LLM turns that into structured lyrics — verses, chorus, style notes — as JSON. The lyrics go to Suno v5 through a provider API, which returns two rendered takes plus word-level timestamps. We cut a preview, build a karaoke-style highlight track from the timestamps, and serve the whole thing from a FastAPI app. Two music providers sit behind a switch: a primary and a fallback, so one vendor having a bad day doesn't stop orders.\n\nSounds simple. The interesting part is that every arrow in that pipeline is a third-party API that can fail in ways the status code will never tell you.\n\n`content: null`\n\nBack to that null. Here's the thing about LLM routers (we use OpenRouter): one model slug is served by many independent hosts, and the router picks one per request. We eventually ran the same prompt across every host serving that model. **Exactly one of them was broken.** It returned the entire completion in the `reasoning`\n\nfield and `null`\n\nin `content`\n\n, wrapped in a clean 200 with a normal `finish_reason`\n\n.\n\nThree lessons that now live in our code:\n\n`except (KeyError, IndexError, TypeError)`\n\naround the response parsing. None of those fire here: the key exists, the value is `null`\n\n. The failure only surfaced two calls later, as an `AttributeError`\n\nin unrelated-looking code.`provider`\n\nfield. Without logging it, \"the model is flaky\" and \"one host out of 33 is broken\" are indistinguishable — and the second one has a config-level fix.`json`\n\ncode block with nothing inside it. If you check `if not content`\n\nbefore stripping the fence, the garbage passes.The fix was boring, which is the point: a per-request `provider: {\"ignore\": [...]}`\n\nlist, driven by an env var, plus an account-level ignore list in the router's dashboard as the no-deploy emergency lever.\n\nA week later, the same bug shape came back one level deeper. This one cost real money.\n\nThe LLM response was now validated: non-null, non-empty after stripping code fences, parsed JSON, all the required sections in place. What our checks didn't cover was **leaf types**: a key can be present, the structure can look right, and the value can still be `null`\n\n. And our code did this:\n\n```\nlyrics = str(payload.get(\"lyrics\", \"\"))\n```\n\nLooks defensive, right? It isn't. The default in `.get()`\n\nonly applies when the key is **missing**. When the payload is `{\"lyrics\": null}`\n\n, the key exists, `.get()`\n\nreturns `None`\n\n, and `str(None)`\n\nis the string `\"None\"`\n\n— eight characters, non-empty, sails straight past `if not lyrics`\n\nand past every `or \"fallback\"`\n\ndownstream.\n\nSo we paid a music API to professionally sing the word \"None\". A related null in a word-timestamps payload put the literal word \"None\" into a paying customer's karaoke highlight. Nothing crashed. No exception handler in the codebase had anything to catch.\n\nThe correct idiom is one token different:\n\n```\nlyrics = str(payload.get(\"lyrics\") or \"\")\n```\n\nAfter it bit us twice in one week, we stopped trusting review to catch it and wrote a test that walks the AST of the whole codebase:\n\n``` php\ndef _offenders(tree: ast.AST, where: str) -> list[str]:\n    \"\"\"Flag every str(<x>.get(<key>, \"<literal>\")) call.\"\"\"\n    found = []\n    for node in ast.walk(tree):\n        if (isinstance(node, ast.Call)\n                and isinstance(node.func, ast.Name)\n                and node.func.id == \"str\"\n                and len(node.args) == 1):\n            inner = node.args[0]\n            if (isinstance(inner, ast.Call)\n                    and isinstance(inner.func, ast.Attribute)\n                    and inner.func.attr == \"get\"\n                    and len(inner.args) == 2\n                    and isinstance(inner.args[1], ast.Constant)\n                    and isinstance(inner.args[1].value, str)):\n                found.append(f\"{where}:{node.lineno}\")\n    return found\n```\n\nWhy AST and not grep? Because grep missed a real offender: a multi-line call with indexing in the middle (`str(messages[-1].get(\"content\", \"\"))`\n\n). The AST doesn't care how the call is formatted.\n\nWe deliberately gate only `str(...)`\n\n. `float(None)`\n\nraises a loud `TypeError`\n\nthat any error path catches; `str(None)`\n\ndegrades *silently* and ships to a user. Gate the quiet failures — the loud ones catch themselves.\n\n\"Just put pydantic on the boundary\" is a fair response, and strict response models would catch this too. In practice we have several third-party boundaries (an LLM router, two music providers, a transcription API), they evolve at different speeds, and not all of them have earned a full typed model yet. The AST gate is one 30-line test that covers every `str(.get())`\n\nin the codebase — including the boundaries nobody has gotten around to modelling.\n\nSuno v5 returns word-level timestamps with the audio. That's what our karaoke highlight is built on. The contract looks clean: `{word, startS, endS}`\n\nper token, and the data mostly matches it. \"Mostly\" is the whole job:\n\n`endS`\n\ncan swallow an instrumental gap; we've measured 11 seconds. If your highlight follows word ends, it drifts badly. Follow the starts.The umbrella policy above all three: **no karaoke beats broken karaoke.** The normalizer returns `None`\n\non anything it can't trust — too few words, character-error-rate above threshold (healthy sung tracks measure ~0.20-0.25 on the vendor's CER metric; we reject above 0.4), non-monotonic timestamps, timestamps past the track duration. The page quietly renders without the feature. Graceful degradation is a product decision, not just an ops one.\n\nMusic generation is slow and *occasionally* very slow: same API, same payload, 25-30 minutes instead of the usual 3-9. Early on we treated the long tail as failure — time out, refund, re-create. That's exactly wrong, because re-creating a paid render doubles the cost while the original job is often still cooking.\n\nWhat we run now:\n\n`str(x.get(k, \"\"))`\n\nis a bug.`str(x.get(k) or \"\")`\n\n. If the codebase is bigger than one file, write the AST gate — it's 30 lines and it never gets tired.None of this is exotic engineering. It's the unglamorous layer between \"the demo works\" and \"strangers pay for it\" — which, if you're gluing LLMs and generative audio together in 2026, is where most of the actual work turns out to live.\n\n*If you're curious what all this plumbing produces: hitou.site — it turns a story about someone into a finished song in a few minutes.*", "url": "https://wpnews.pro/news/200-ok-content-null-what-actually-breaks-when-you-build-on-ai-apis", "canonical_source": "https://dev.to/timur_hitou/200-ok-content-null-what-actually-breaks-when-you-build-on-ai-apis-3dml", "published_at": "2026-07-29 07:07:08+00:00", "updated_at": "2026-07-29 07:35:27.926183+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-products", "developer-tools"], "entities": ["Hitou", "OpenRouter", "Suno"], "alternates": {"html": "https://wpnews.pro/news/200-ok-content-null-what-actually-breaks-when-you-build-on-ai-apis", "markdown": "https://wpnews.pro/news/200-ok-content-null-what-actually-breaks-when-you-build-on-ai-apis.md", "text": "https://wpnews.pro/news/200-ok-content-null-what-actually-breaks-when-you-build-on-ai-apis.txt", "jsonld": "https://wpnews.pro/news/200-ok-content-null-what-actually-breaks-when-you-build-on-ai-apis.jsonld"}}