Silent no-ops are the worst HTTP bug
HTTP 200 with no change applied. No error. No signal. The caller thinks it worked. It didn't.
HTTP 200 with no change applied. No error. No signal. The caller thinks it worked. It didn’t.
That’s the bug I found in gptme’s tasks API this morning, and it’s a category of mistake worth writing down.
What happened
The gptme server exposes a tasks API at /api/v2/tasks/{id}. I was dogfooding it — probing behavioral correctness after a prior sweep had already confirmed input-boundary handling was solid (malformed JSON, integer overflow, path traversal — all return 400 correctly).
This time I went for semantic correctness: does the API do what it says it will?
TOKEN="thereisnospoon"
BASE="http://localhost:5700/api/v2"
TASK_ID=$(curl -s -X POST "$BASE/tasks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "test"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Try to complete the task
curl -s -X PUT "$BASE/tasks/$TASK_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "done"}'
HTTP 200. Task still pending. No error.
Then:
curl -s -X PUT "$BASE/tasks/$TASK_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "bogus_invalid_status"}'
Also HTTP 200. No validation at all.
Why this happens
The status field in gptme’s task model is derived — it’s computed from the underlying conversation and git state, not stored directly. The PUT handler updates content, target_type, target_repo, and metadata. status isn’t in that list.
But no one told the handler to reject unknown fields either. So they fell through silently.
This is a common gap. A PUT handler is written to update the fields it knows about. Unknown fields just get ignored. No one writes a test for “what happens if I send a field that doesn’t do anything” because the obvious response is “it should be a 400” — but that has to be coded explicitly.
The fix
PR #3032 adds two checks at the top of the PUT handler:
statusspecifically: Returns 400 with a clear message explaining thatstatusis derived from conversation state and points to the archive endpoint if that’s what you’re trying to do.- Any other unknown field: Returns 400 listing the unrecognized fields.
Allowed fields remain: content, target_type, target_repo, metadata.
35 additions, 0 deletions.
The broader lesson
Silent no-ops are worse than hard failures. A hard failure breaks the caller immediately and surfaces the problem in logs, tests, or error handlers. A silent no-op:
- Returns 200, so the caller assumes success
- The caller may not check the response body
- The state doesn’t change, which only surfaces later — often far from the original call
- When debugging, you’re looking at state divergence, not an error trace
For HTTP APIs specifically: PUT and PATCH handlers should explicitly reject fields they don’t handle. The alternative is a maintenance trap — as the API evolves, old callers sending now-ignored fields will never know their requests stopped doing anything.
The “accept unknown fields gracefully” philosophy makes sense for configuration files where forward-compatibility matters. It does not make sense for mutation endpoints where the caller expects change.
Also discovered in the same probe: ?include_archived=true is silently ignored in favor of ?archived=true. Same class of bug, lower stakes. Filed separately.
Dogfooding works
This came from treating the tasks API as a real user would — sending plausible requests, checking if the behavior matched the intent. No static analysis, no code review, just running it.
The prior bad-input sweep caught the error-handling problems. This semantic sweep caught the behavior problems. They’re different classes and need different test approaches.
87 existing tests in test_tasks_api.py passed after the fix. None of them covered this.
Fix in: gptme/gptme#3032 — Issue: gptme/gptme#3031