Skip to content

fix: add codersdk JSON response decoder for typed API endpoints - #27804

Merged
dylanhuff-at-coder merged 3 commits into
mainfrom
dylan/plat-378-sdk-json-response-decoder
Aug 5, 2026
Merged

fix: add codersdk JSON response decoder for typed API endpoints#27804
dylanhuff-at-coder merged 3 commits into
mainfrom
dylan/plat-378-sdk-json-response-decoder

Conversation

@dylanhuff-at-coder

@dylanhuff-at-coder dylanhuff-at-coder commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

coder whoami and coder list can surface low-level JSON decode errors when a reverse proxy, SSO portal, or incorrect Coder URL returns HTML with a successful HTTP status.

Add a shared SDK JSON response decoder and use it for the user and workspace list endpoints so these commands return a structured, actionable API response error instead. Refs #27044

Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.

Typed SDK methods previously decoded response bodies directly with
json.NewDecoder(res.Body).Decode(...). When a reverse proxy, SSO
portal, or misconfigured Coder URL returns 200 OK with an HTML body,
this surfaced as "invalid character '<' looking for beginning of
value" with no context.

Add ReadBodyAsJSON next to ReadBodyAsError. It sniffs a bounded body
prefix to identify HTML via the media type or a leading '<', streams
valid JSON without buffering the whole body, and returns a structured
*Error carrying the status code, method, URL, content type, decode
cause, and a sanitized bounded body excerpt. Migrate Client.User
(coder whoami) and Client.Workspaces (coder list) as the first
adopters; remaining typed endpoints migrate in follow-up PRs.

Refs #27044
@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown

PLAT-378

@dylanhuff-at-coder dylanhuff-at-coder changed the title fix(codersdk): add JSON response decoder for typed API endpoints fix: add codersdk JSON response decoder for typed API endpoints Aug 3, 2026
@dylanhuff-at-coder
dylanhuff-at-coder marked this pull request as ready for review August 4, 2026 15:57
@BobbyHo

BobbyHo commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Code review

No bugs or CLAUDE.md violations found. One non-blocking suggestion:

invalidBodyError is called four times in ReadBodyAsJSON, and two of those are byte-identical — the HTML block appears verbatim in both the Content-Type short-circuit and the body-sniff branch.

coder/codersdk/client.go

Lines 528 to 536 in 033cce1

mimeType := parseMimeType(res.Header.Get("Content-Type"))
if isHTMLMimeType(mimeType) {
return invalidBodyError(res, Response{
Message: "Received an HTML response instead of JSON from the Coder API.",
Detail: invalidBodyDetail(res),
}, htmlResponseHelper, nil)
}

coder/codersdk/client.go

Lines 551 to 558 in 033cce1

}
if isHTMLBody(mimeType, prefix.bytes) {
return invalidBodyError(res, Response{
Message: "Received an HTML response instead of JSON from the Coder API.",
Detail: invalidBodyDetail(res),
}, htmlResponseHelper, nil)
}
return invalidBodyError(res, Response{

Extracting just the duplicated case and turning the tail into a switch removes the repetition without changing behavior:

func htmlBodyError(res *http.Response) *Error {
	return invalidBodyError(res, Response{
		Message: "Received an HTML response instead of JSON from the Coder API.",
		Detail:  invalidBodyDetail(res),
	}, htmlResponseHelper, nil)
}
switch {
case err == nil:
	return nil
case body.err != nil && errors.Is(err, body.err):
	return xerrors.Errorf("read response body: %w", err)
case len(prefix.bytes) == 0 && errors.Is(err, io.EOF):
	return invalidBodyError(res, Response{
		Message: "Received an empty response from the Coder API.",
		Detail:  invalidBodyDetail(res),
	}, "", nil)
case isHTMLBody(mimeType, prefix.bytes):
	return htmlBodyError(res)
default:
	return invalidBodyError(res, Response{
		Message: "Received an invalid JSON response from the Coder API.",
		Detail:  fmt.Sprintf("decode body: %s, %s", err.Error(), invalidBodyDetail(res)),
	}, "", err)
}

Comment thread codersdk/client.go

@BobbyHo BobbyHo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm. just left couple non-blocking (NIT) comments.

@dylanhuff-at-coder

Copy link
Copy Markdown
Contributor Author

Code review

No bugs or CLAUDE.md violations found. One non-blocking suggestion:

invalidBodyError is called four times in ReadBodyAsJSON, and two of those are byte-identical — the HTML block appears verbatim in both the Content-Type short-circuit and the body-sniff branch.

coder/codersdk/client.go

Lines 528 to 536 in 033cce1

mimeType := parseMimeType(res.Header.Get("Content-Type"))
if isHTMLMimeType(mimeType) {
return invalidBodyError(res, Response{
Message: "Received an HTML response instead of JSON from the Coder API.",
Detail: invalidBodyDetail(res),
}, htmlResponseHelper, nil)
}

coder/codersdk/client.go

Lines 551 to 558 in 033cce1

}
if isHTMLBody(mimeType, prefix.bytes) {
return invalidBodyError(res, Response{
Message: "Received an HTML response instead of JSON from the Coder API.",
Detail: invalidBodyDetail(res),
}, htmlResponseHelper, nil)
}
return invalidBodyError(res, Response{

Extracting just the duplicated case and turning the tail into a switch removes the repetition without changing behavior:

func htmlBodyError(res *http.Response) *Error {
	return invalidBodyError(res, Response{
		Message: "Received an HTML response instead of JSON from the Coder API.",
		Detail:  invalidBodyDetail(res),
	}, htmlResponseHelper, nil)
}
switch {
case err == nil:
	return nil
case body.err != nil && errors.Is(err, body.err):
	return xerrors.Errorf("read response body: %w", err)
case len(prefix.bytes) == 0 && errors.Is(err, io.EOF):
	return invalidBodyError(res, Response{
		Message: "Received an empty response from the Coder API.",
		Detail:  invalidBodyDetail(res),
	}, "", nil)
case isHTMLBody(mimeType, prefix.bytes):
	return htmlBodyError(res)
default:
	return invalidBodyError(res, Response{
		Message: "Received an invalid JSON response from the Coder API.",
		Detail:  fmt.Sprintf("decode body: %s, %s", err.Error(), invalidBodyDetail(res)),
	}, "", err)
}

Done in 6d2f137 — extracted an htmlBodyError helper for the two identical HTML cases and converted the tail into a switch, exactly as suggested. CI is green :)

@BobbyHo BobbyHo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm with the latest changes. Thank you for addressing my comments.

@dylanhuff-at-coder
dylanhuff-at-coder merged commit db68c6c into main Aug 5, 2026
29 checks passed
@dylanhuff-at-coder
dylanhuff-at-coder deleted the dylan/plat-378-sdk-json-response-decoder branch August 5, 2026 17:42
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants