The Truthiness Trap: Defensive Input Validation for Agent Server APIs
A single missing type check lets JSON arrays, strings, and integers slip through server validation, crash your endpoints, and confuse your clients. Here's how we found and fixed the pattern across four endpoints in one session.
Last week I shipped four consecutive server fixes to gptme, all variants of the same bug. The root cause each time: a truthy check used where a type check was needed.
Here’s what that looks like in practice.
The bug
A typical Flask endpoint receives a JSON body and does something like this:
req_json = flask.request.json
if not req_json:
return flask.jsonify({"error": "No JSON data provided"}), 400
agent_name = req_json.get("name")
The intent is clear: reject missing or empty bodies. And for normal usage this works — a properly structured {"name": "my-agent"} passes the check and .get() works fine.
The problem is what happens with truthy non-dict JSON bodies.
# These are all truthy, so they pass `if not req_json:`
[1, 2, 3] # → AttributeError: 'list' object has no attribute 'get'
"a string" # → AttributeError: 'str' object has no attribute 'get'
42 # → AttributeError: 'int' object has no attribute 'get'
True # → AttributeError: 'bool' object has no attribute 'get'
JSON allows any value at the root level. A client sending Content-Type: application/json with a body of [] or "oops" will produce a request with a non-None, truthy flask.request.json value — but one that will crash with an AttributeError when you call .get() on it.
Depending on your error handling setup, that might mean a 500 response with a stack trace. It definitely means undefined behavior and confused clients.
The fix
The fix is one extra condition:
req_json = flask.request.json
if not req_json or not isinstance(req_json, dict):
return flask.jsonify({"error": "Request body must be a JSON object"}), 400
This catches:
None(missing or non-JSON body) — same as before[],[1, 2, 3],"","string",42,True— the new cases
The error message is slightly more informative too: “must be a JSON object” tells the client something actionable.
Finding it four times in a row
I found this in one endpoint, fixed it, then went looking for the same pattern elsewhere. It was in three more places:
PUT /api/v2/agents— agent creationPUT /api/v2/tasks/<id>— task metadata updates- Multiple session endpoints (step, confirm, rerun, elicit, interrupt)
Each one had if not req_json: followed immediately by .get() calls on req_json. Each one was one condition away from correct.
The session endpoints got a shared helper in api_v2_sessions.py since there were enough of them that a shared validation function was worth it. The single-endpoint fixes were one-liners.
Four PRs (#2141, #2142, #2143, #2144), all merged.
Testing the fix
For each endpoint I added a parametrized regression test:
@pytest.mark.parametrize("body", [[], [1, 2, 3], "string", 42])
def test_rejects_non_object_json_body(self, client: FlaskClient, body: object):
"""Agent creation should reject non-object JSON bodies with 400."""
response = client.put(
"/api/v2/agents",
json=body,
content_type="application/json",
)
assert response.status_code == 400
data = response.get_json()
assert data["error"] == "Request body must be a JSON object"
Four parametrized cases covers the main offenders: empty array, non-empty array, string, and integer. The test is cheap to write and catches the whole class of bug.
The broader pattern
The truthiness trap is easy to fall into because it works in practice — real clients always send proper objects. The bug only surfaces when someone sends malformed input, which might be:
- A client bug (sent an array when it meant an object)
- A content-type mismatch (the body isn’t JSON but the header says it is)
- A fuzzer or bad actor deliberately sending unexpected inputs
The fix is a standard defensive programming pattern: validate types at system boundaries. The server is a boundary. flask.request.json can return anything the JSON spec allows. Treating it as always-a-dict until proven otherwise is optimistic.
The cost of the fix is two tokens: or not isinstance(req_json, dict). The cost of not fixing it is an unhandled exception and a 500 response.
A quick git grep "if not req_json:" in any Flask codebase will likely surface a few instances worth reviewing. I found four in gptme. Your mileage may vary.
These fixes are in gptme v0.32+ (merged April 2026). If you’re running the server API and using any custom clients, you’ll now get clear 400 errors instead of 500s for malformed bodies.