Fix mod update checks failing on non-JSON responses (#931)

The mod update and Find Mods feeds feed the raw HTTP body straight to
Json.decode. When the endpoint hands back something that is not JSON
(an HTML error page, a proxy/captive prompt, or a plain-text outage
message like "Exceeded secondary rate limit" -- usually still HTTP 200),
the decoder's "unexpected character 'E'" assert escaped through the
pcall and became the error message, blaming the parser instead of the
response.

Add Json.describeUnexpected() as a pre-decode content-type guard: it
returns nil for body shapes the endpoints actually publish (JSON object
or array) and otherwise a short message naming what the server sent
(HTML page / plain text / empty, with a preview). Wire it into
ModUpdate.parseReleases and ModIndex.parse, so both the sync and async
update-check paths surface the real answer instead of the parse error.
HTTP status was already checked upstream by HostShell.httpGet (non-2xx
becomes "HTTP <code> from <url> (...)"); this closes the remaining
"2xx but not JSON" gap everywhere, including bridge platforms that
expose no status or headers.

Add regression tests for plain-text, HTML, and empty bodies; strengthen
the ModIndex HTML soft-fail test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Shane McGovern
2026-08-07 10:49:10 +01:00
parent 112120e8fe
commit d59a9522ee
5 changed files with 58 additions and 3 deletions
+22
View File
@@ -171,4 +171,26 @@ function Json.decode(s)
return nil, v
end
-- For an HTTP response that was meant to carry JSON but did not. Returns nil
-- when `s` starts like a JSON object or array (the only shapes the update and
-- index endpoints publish), otherwise a short message naming what the server
-- actually sent -- so callers surface "the response was an HTML page/plain
-- text, not JSON (it starts with ...)" instead of leaking the decoder's
-- low-level "unexpected character 'E'" assert at the first byte of an error
-- page or plain-text outage message.
function Json.describeUnexpected(s)
if type(s) ~= "string" then
return "the response had no body to decode"
end
local first = s:match("^%s*(.)")
if first == "{" or first == "[" then return nil end
local preview = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
if preview == "" then
return "the response was empty, not JSON"
end
if #preview > 60 then preview = preview:sub(1, 57) .. "..." end
local kind = (first == "<") and "an HTML page" or "plain text"
return ("the response was %s, not JSON (it starts with %q)"):format(kind, preview)
end
return Json