The API Call That Silently Failed: When a Python Dict Isn't a String
A deep-dive into a subtle bug in the ACO system's LLM integration where passing a dict directly to an HTTP client caused silent failures — and why type errors don't always raise exceptions when you expect them to.
There’s a particular class of bug that feels almost unfair: the one where your code looks correct, passes linting, and runs without raising a single exception — yet does nothing. That’s the bug we encountered last week in the ACO system’s LLM client integration with OpenRouter.
The Problem
The Developer agent was supposed to generate code and post it back to a GitHub PR. Instead, it was silently doing nothing. No error. No exception. Just silence.
After adding retry logic and better error visibility, the actual error surfaced: a 400 Bad Request from OpenRouter’s API. The error message was cryptic — something about an invalid request format. But here’s the thing: the request code looked completely fine. It had always worked before.
The Investigation
The bug lived in how we were building the API request payload. The code was doing something like this:
payload = {
"model": "anthropic/claude-sonnet-4-20250514",
"messages": [...],
"max_tokens": 4096,
}
response = self.session.post(url, json=payload)
That json=payload parameter is supposed to handle JSON serialization automatically. And it does — when payload is a dict. The problem was that somewhere upstream, payload was already a string containing JSON, not a dict. So we were passing json="{\"model\": ...}" — a string, not a dict.
HTTPX’s json= parameter only serializes dict/list objects. When you pass a string, it does something different — it sends the string as the raw body with Content-Type: application/json, but without proper JSON wrapping. OpenRouter was receiving what looked like a malformed request.
The Fix
The fix was straightforward once we understood it:
# Before (broken — payload was already a JSON string)
payload = json.dumps(story_dict) # returns a string
response = self.session.post(url, json=payload)
# After (correct — httpx serializes dict to JSON automatically)
payload = story_dict # keep as dict
response = self.session.post(url, json=payload)
# OR if you need the string form for other reasons:
response = self.session.post(url, content=payload, headers={"Content-Type": "application/json"})
The real lesson here isn’t about OpenRouter or HTTPX specifically — it’s about layering serialization. When you have a pipeline where data flows through multiple systems, you need to be clear about where serialization happens and where it doesn’t. Mixing json.dumps() with json= parameter is a classic trap.
Why Tests Didn’t Catch It
Here’s the annoying part: our unit tests used a MockLLMClient that doesn’t call the real OpenRouter API. The tests passed because they never exercised the HTTP path at all. The bug only manifested in the full integration pipeline — when the real HTTP client actually sent the request to OpenRouter.
This is a good reminder that test doubles (mocks, stubs, fakes) are double-edged swords. They let you test logic in isolation, but they also insulate you from real integration issues. The fix was to add an integration test that actually hits a mock HTTP server — not the LLM client mock — to verify the HTTP request is constructed correctly.
The ACO system’s test suite now has a test_phase2_3_full_workflow.py that exercises this path end-to-end. It would have caught this bug in minutes instead of hours.
If you’re building systems that call external APIs from Python, watch for this exact pattern: dict-vs-string ambiguity in your serialization layer. It usually works fine until it doesn’t — and then the failure mode is silent.
Enjoyed this? Give it some claps
Stay in the loop
New posts drop when there's something worth writing about. No spam — just the occasional deep dive from the workbench.
Or follow on Substack directly
Comments
Written by Aniket Karne
April 9, 2026 at 12:00 AM UTC