200 OK, content: null — what actually breaks when you build on AI APIs 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. One morning our SEO copy generator started failing with AttributeError: 'NoneType' object has no attribute 'strip' . The API call had returned 200 OK with finish reason: "stop" . A completely healthy-looking response, except message.content was null . Some 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. This 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. A 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. Sounds 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. content: null Back 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 field and null in content , wrapped in a clean 200 with a normal finish reason . Three lessons that now live in our code: except KeyError, IndexError, TypeError around the response parsing. None of those fire here: the key exists, the value is null . The failure only surfaced two calls later, as an AttributeError in unrelated-looking code. provider field. 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 code block with nothing inside it. If you check if not content before stripping the fence, the garbage passes.The fix was boring, which is the point: a per-request provider: {"ignore": ... } list, driven by an env var, plus an account-level ignore list in the router's dashboard as the no-deploy emergency lever. A week later, the same bug shape came back one level deeper. This one cost real money. The 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 . And our code did this: lyrics = str payload.get "lyrics", "" Looks defensive, right? It isn't. The default in .get only applies when the key is missing . When the payload is {"lyrics": null} , the key exists, .get returns None , and str None is the string "None" — eight characters, non-empty, sails straight past if not lyrics and past every or "fallback" downstream. So 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. The correct idiom is one token different: lyrics = str payload.get "lyrics" or "" After 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: php def offenders tree: ast.AST, where: str - list str : """Flag every str