fix(sqlite): don't parse a non-QueryResponse body as JSON - #141
fix(sqlite): don't parse a non-QueryResponse body as JSON#141ashishtanwer wants to merge 1 commit into
Conversation
`_query_http` did a bare `response.json()`, so anything that wasn't
200-with-JSON surfaced as an opaque
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
for a non-JSON body, or a pydantic ValidationError for a `{"detail": ...}` error
body -- carrying no status code, no URL and no body text.
That cost a full grading session on 2026-07-30 (theseus#21168). A BLOB column made
an env runner return `500 Internal Server Error` as text/plain; the char-0
JSONDecodeError reached the generated multi-app verifier, whose "app route is
dead" heuristic failed the run ENVIRONMENT_NOT_READY and discarded ~90 minutes of
completed rollout -- while the environment was in fact healthy and served five
200s before each 500. The diagnosis needed the cluster's edge access logs, because
the exception the SDK produced named neither the status nor the body.
Raise FleetEnvironmentError with status_code, content-type and a 500-char body
snippet instead, mirroring the `_describe_http` precedent alongside it. Shared by
the sync and async paths so they can't drift, and it covers `exec()` too.
Only paths that already raised change: a 200-with-JSON body is untouched, and a
failed *query* (200 + success=False) is still returned as data rather than raised.
Two tests pin that.
The body snippet is included deliberately, not incidentally: callers classify
unreachability on the reason phrase in the body ("Bad Gateway", "Service
Unavailable"), so gateway failures keep classifying correctly through the new
exception.
Heads-up for theseus: an app-side 500 no longer matches that char-0 heuristic, so
`_app_unavailable` in orchestrator/tasks/activities/verifier.py would score such a
response 0 instead of raising ENVIRONMENT_NOT_READY. Silently scoring 0 for an
env-side fault is worse than failing loudly, so that heuristic likely wants an
explicit app-route-5xx rule. Called out rather than worked around here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Two sequencing notes for whoever merges this, both discovered while wiring up the theseus side. 1. Merge the theseus classifier fix first. fleet-ai/theseus#21306. Before this PR, an env-side 500 reached the generated multi-app verifier as
So merging this alone converts a loud environment fault into a quiet 0 on the agent's scorecard. #21306 adds the app-route-5xx rules that keep it loud; it's independently useful, since a 500 carried on a response object is already misclassified today. 2. This PR targets Once a release contains it, the theseus-side pins are two one-liners, with different blast radii:
|
What's wrong
_query_httpparses the response with a bareresponse.json():So anything that isn't 200-with-JSON surfaces as either
JSONDecodeError: Expecting value: line 1 column 1 (char 0)— a non-JSON body(text/plain 500, nginx HTML 502, empty body), or
ValidationError— a{"detail": ...}error body from a 401/404…with no status code, no URL and no body text in the exception.
Why it mattered
This cost a full grading session on 2026-07-30 (see
theseus#21168).
A BLOB column made an env runner raise during response serialization, so Starlette
answered
500 Internal Server Erroras text/plain (21 bytes). The resultingchar-0
JSONDecodeErrorreached the generated multi-app verifier, whose "app route isdead" heuristic failed the run
ENVIRONMENT_NOT_READYand threw away ~90 minutes ofcompleted rollout.
The environment was healthy the whole time — the runner served five 200s before the
500 in each of the four retries and was never restarted:
Diagnosing it required the cluster's edge access logs, purely because the exception
the SDK produced named neither the status nor the body. A caller cannot tell "app
returned 500" from "route is gone" — and here the difference decided whether a
session was scored or discarded.
The change
Raise
FleetEnvironmentErrorwithstatus_code,content-typeand a 500-char bodysnippet, mirroring the
_describe_httpprecedent a few lines above. Shared by thesync and async paths (
from ...resources.sqlite import _raise_for_non_query_response)so they can't drift, and it covers
exec()as well asquery().Only paths that already raised change. A 200-with-JSON body is untouched, and a
failed query (
200+success=False) is still returned as data rather than raised—
test_successful_query_is_untouchedandtest_sql_error_response_still_returned_as_data_not_raisedpin both, and they passwith and without the patch.
The body snippet is deliberate, not incidental: callers classify unreachability on
the reason phrase in the body ("Bad Gateway", "Service Unavailable"), so gateway
failures keep classifying correctly through the new exception
(
test_gateway_body_is_preserved_for_unavailability_classification).Tests
8 new tests in
tests/test_query_http_non_json_body.py, covering the text/plain 500,the nginx 502 HTML,
{"detail": ...}bodies at 401/404, an empty 200, the twomust-not-change paths, and the write path. 6 of the 8 fail without the patch.
Full suite: identical failure/error set before and after (5 pre-existing
tests/track/test_mcp_install.pyfailures and 3 pre-existingtest_sqlite_resource_dual_mode.pyerrors, all failing onmaintoo).One consequence worth a decision, not worked around here
An app-side 500 no longer matches the char-0 heuristic in theseus's
orchestrator/tasks/activities/verifier.py_app_unavailable(). With this merged,such a response would be scored 0 instead of raising
ENVIRONMENT_NOT_READY.Silently scoring 0 for an env-side fault is worse than failing loudly, so that
heuristic likely wants an explicit "app-route 5xx ⇒ unavailable" rule. Gateway codes
(502/503/504) are unaffected — their bodies still carry the phrases it matches. I've
flagged this on the theseus PR rather than quietly changing grading semantics from
inside the SDK.
🤖 Generated with Claude Code