Skip to content

fix: surface the API's error message instead of the client's status name - #53

Merged
claudiacodacy merged 6 commits into
feat/od-710-keep-latestfrom
fix/surface-api-error-messages
Sep 22, 2026
Merged

claudiacodacy merged 6 commits into
feat/od-710-keep-latestfrom
fix/surface-api-error-messages

Conversation

@claudiacodacy

Copy link
Copy Markdown
Contributor

Stacked on #52 (→ #50#49). Found while testing the image commands end to end against gh/claudiacodacy.

The bug

catchErrorCodes in the generated client builds ApiError.message from a static table:

const errors = { 400: 'Bad Request', 401: 'Unauthorized', 404: 'Not Found', ... }

handleError printed that and ignored err.body — where the server's actual explanation was sitting the whole time. It affects every command, not just the image ones.

What changes, measured against the live API

Each row is the same command run against main's build and this branch's build:

Command Before After
repository gh claudiacodacy no-such-repo Error: Not Found Error: Could not find repository gh/claudiacodacy/no-such-repo (HTTP 404)
repositories gh no-such-org Error: Not Found Error: Could not find organization for provider: gh, organizationName: no-such-org (HTTP 404)
pull-request … 99999 Error: Not Found Error: Cannot find pull request with number '99999' (HTTP 404)
finding gh claudiacodacy 0000… Error: Not Found Error: Item with id 0000… not found (HTTP 404)
info with a bad token Error: Unauthorized Error: Bad credentials (HTTP 401)
image … --tag 9.9.9 --upload … Error: Bad Request Error: SBOM tag mismatch: expected 9.9.9, found 3.20 (HTTP 400)

issues, ls, directories and images all improve the same way. tool, patterns and image <missing> are unchanged — they handle those cases themselves and never reach handleError.

Decisions

  • The status stays, as a trailing (HTTP 404). It is the part that gets quoted in a bug report, and Unauthorized carries meaning that a terse body message might not.
  • The extraction moves rather than duplicates. import-config.ts already had parseApiErrorBody doing exactly this for tools --import's failure table — good evidence the fix belongs to us, not the API. It is now apiErrorDetails in utils/error.ts, used by both, so a new body shape has one place to be handled. It reads the spec's message, an out-of-spec errors array, a plain string body, else the serialized body.
  • The message is sanitized before printing. Error bodies quote back image names, tags and branch names — values a crafted repository controls. This was the one render path with no sanitizer, precisely because it never rendered server text before. Covered by a test.
  • Silence stays silent. A body carrying nothing, or one that only echoes the status name, leaves the old output byte-identical — so commands whose failures the API doesn't explain don't change at all.

Tests

13 new in src/utils/error.test.ts (the file didn't exist), 734 passing, no existing test touched. Plus the live before/after sweep above across 11 commands.

🤖 Generated with Claude Code

@claudiacodacy
claudiacodacy added this pull request to stack #51 September 21, 2026 13:17
@claudiacodacy
claudiacodacy marked this pull request as ready for review September 21, 2026 13:17
@codacy-production

codacy-production Bot commented Sep 21, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 55 complexity · 5 duplication

Metric Results
Complexity 55
Duplication 5

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

@codacy-production codacy-production Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull Request Overview

The API error handling can throw while processing malformed errors entries, preventing the original API failure from being reported. This should be fixed before merging. Codacy reports the PR as not up to standards due to a new issue; the same function also has high decision complexity, increasing regression risk.

About this PR

  • Add an import-config regression test covering tools import failure extraction through the shared helper. Per-line coverage is unavailable, so coverage claims cannot currently be independently verified.

Test suggestions

  • Extract the API message field, errors array entries, plain string body, and serialized fallback
  • Return no details for empty, null, absent, or empty-string bodies
  • Format an ApiError with the server message and trailing HTTP status
  • Preserve legacy output when the body is empty or only repeats the status name
  • Sanitize terminal escape sequences in server-provided error text
  • Format ordinary Error and non-Error throws
  • Handle an API error by printing the formatted message and exiting with status 1
  • Retain tools import failure extraction behavior through the shared apiErrorDetails helper
Prompt proposal for missing tests
Consider implementing these tests if applicable:
1. Retain tools import failure extraction behavior through the shared apiErrorDetails helper
Low confidence findings
  • Reconcile the stated count of 13 new tests with the 10 test cases present in the provided test file.

TIP How was this review? Give us feedback

Comment thread src/utils/error.ts
Comment on lines +23 to +47
export function apiErrorDetails(body: unknown): string[] {
if (body && typeof body === "object") {
const details: string[] = [];
const obj = body as Record<string, unknown>;
if (typeof obj.message === "string") {
details.push(obj.message);
}
if (Array.isArray(obj.errors)) {
for (const e of obj.errors) {
details.push(typeof e === "string" ? e : ((e as any)?.message ?? JSON.stringify(e)));
}
}
if (details.length === 0) {
const serialized = JSON.stringify(body);
if (serialized !== "{}" && serialized !== "null") {
details.push(serialized);
}
}
return details;
}
if (typeof body === "string" && body.length > 0) {
return [body];
}
return [];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MEDIUM RISK

Malformed errors entries can leave non-string values such as undefined in details; formatError then calls .trim() and throws while handling the original API error. Normalize every entry to a string or filter invalid entries before formatting. Given this function's 15 decision paths, extract the body/array normalization into helpers and add tests for malformed entries.

See Issue in Codacy

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Every errors entry now leaves errorEntryDetail as a string, so a body like {"errors": [{"message": 42}]} can no longer throw on .trim() while reporting the API error the user was waiting to read; entries with nothing to say are dropped rather than rendered as "undefined". The function was also split — objectDetails, errorEntryDetail and serializedBody — and there is a regression test for malformed entries.

🤖 Generated by /pr-fixup command

alerizzo
alerizzo previously approved these changes Sep 21, 2026
@claudiacodacy
claudiacodacy dismissed alerizzo’s stale review September 21, 2026 22:07

The merge-base changed after approval.

@claudiacodacy
claudiacodacy force-pushed the fix/surface-api-error-messages branch 2 times, most recently from 1a70ba6 to 1b470c5 Compare September 21, 2026 22:09
@claudiacodacy
claudiacodacy force-pushed the fix/surface-api-error-messages branch 2 times, most recently from bc88271 to 988a191 Compare September 21, 2026 22:16
claudiacodacy and others added 2 commits September 21, 2026 23:17
`catchErrorCodes` builds ApiError.message from a static
{400: 'Bad Request', 404: 'Not Found', ...} table, so handleError - which
printed err.message and ignored err.body - was throwing away the explanation
the server had already sent. On every command.

  Error: Not Found
  Error: Could not find repository gh/claudiacodacy/no-such-repo (HTTP 404)

  Error: Unauthorized
  Error: Bad credentials (HTTP 401)

  Error: Bad Request
  Error: SBOM tag mismatch: expected 9.9.9, found 3.20 (HTTP 400)

The status stays as a trailing (HTTP nnn): it is what gets quoted in a bug
report, and "Unauthorized" carries meaning a terse body may not.

The extraction moves rather than duplicates. import-config.ts already had
parseApiErrorBody for the `tools --import` failure table; it is now
apiErrorDetails in utils/error.ts, used by both, so a new body shape has one
place to be handled.

The message is sanitized before printing. Error bodies quote back image
names, tags and branch names, which a crafted repository controls, and this
was the one render path with no sanitizer - because it never rendered server
text before.

A body that carries nothing, or only echoes the status name, leaves the old
output untouched, so commands whose failures the API does not explain are
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`apiErrorDetails` pushed an `errors` entry's `message` through unconverted.
A body like `{"errors": [{"message": 42}]}` therefore left a number in the
list, and `formatError`'s `.trim()` threw on it — an exception raised while
reporting an API error, which loses the error the user was waiting to read.
Every entry now leaves `errorEntryDetail` as a string, and the ones with
nothing to say are dropped rather than rendered as "undefined".

Codacy also had the function at CCN 15 (limit 8). The body/array normalization
and the serialized fallback come out as `objectDetails`, `errorEntryDetail`
and `serializedBody`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alerizzo

Copy link
Copy Markdown
Collaborator

Code review

Reviewed as part of the #49#50#52#53 stack, against the merged state. The core change is sound — apiErrorDetails is robust against every body shape ApiError.body can actually hold, handleError still exits 1 on every branch, the import-config.ts extraction is behaviour-preserving for the tools --import failure table, and the sanitization is correctly placed. One issue:

A non-JSON error body is passed through verbatim, with no length cap

}
if (typeof body === "string" && body.length > 0) {
return [body];
}
return [];

ApiError.body is not always JSON. The generated client's getResponseBody returns response.text() for any response whose Content-Type is not application/json / application/problem+json:

// src/api/client/core/request.ts (generated, gitignored) — getResponseBody
const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type));
if (isJSON) { return await response.json(); } else { return await response.text(); }

So the whole response body becomes the single "detail" and formatError prints all of it. A gateway, load balancer or corporate proxy answering 502 with a text/html error page — normal for a CLI that documents HTTPS_PROXY / HTTP_PROXY support in AGENTS.md — now dumps the entire HTML page into the terminal behind Error: , where before this PR it printed Error: Bad Gateway. The same applies to any text/plain stack trace or WAF block page.

Why it matters: this is a regression in exactly the environments the proxy support was added for, and it is the one input class the new tests do not cover — "takes a plain string body as the detail" uses a short string, so nothing pins the behaviour for a large or markup-shaped one.

Expected fix: only treat a string body as a detail when it is plausibly a message — e.g. cap it (first line, or ~200 characters) and fall back to err.message when the body is longer than that or looks like markup. The same cap is worth applying to the serializedBody fallback, which can also produce a very large single line.

🤖 Generated by /code-review command

claudiacodacy and others added 3 commits September 22, 2026 11:04
… failures

ApiError.body is response.text() for any content type the generated client
does not treat as JSON, so a gateway, load balancer or TLS-intercepting
proxy answering 502 with an HTML error page put the whole page in the
terminal behind `Error: ` — a regression in exactly the environments the
HTTPS_PROXY support was added for, where the previous behaviour was
`Error: Bad Gateway`.

A string body is now used only when it plausibly is a message: first line,
at most 200 characters, not starting with `<`. Anything else falls back to
the status name. Dropping rather than truncating, because half an HTML page
is noise, not a shorter explanation. The same cap guards the serializedBody
fallback, which can produce an equally large single line.

Separately, --keep-latest's per-tag failure list still read "Bad Request"
for every failure. That loop continues past a failure so it can list every
tag that did not go, so it formats at its own call site and never reaches
formatError. New exported errorReason(err) shares the extraction with
formatError — both now go through a private apiErrorReason — and returns
the bare reason, without the `Error: ` prefix, the `(HTTP n)` suffix or
sanitization, since the table path sanitizes at render and the JSON path
does not sanitize at all. The existing test masked this by rejecting with a
plain Error rather than an ApiError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 705 and 738 totals were measured per branch; in merged order the three
follow-up entries run 747 -> 752 -> 762, and 762 is what the tip runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claudiacodacy

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in abecad4, plus #52's third finding, which lands here rather than there.

Non-JSON error body passed through verbatim — fixed.

Confirmed against getResponseBody: any content type the client doesn't treat as JSON arrives as response.text(), so a proxy's HTML 502 page went into the terminal in full behind Error: . As you note, that's a regression in exactly the environments the HTTPS_PROXY support was added for, where the previous output was Error: Bad Gateway.

A string body is now used only when it plausibly is a message: first line, at most 200 characters (MAX_DETAIL_LENGTH), not starting with <. Anything else falls back to the status name.

I went with dropping rather than truncating, which is the one place I'd want a second opinion: half an HTML page or half a stack trace is noise rather than a shorter explanation, and falling back gives exactly the pre-PR behaviour, which was at least correct. The cost is that a >200-character plain-text message is discarded rather than clipped — I think that's the right trade, since the API's own messages are one short line, but say if you'd rather see a clipped prefix.

Same cap now guards the serializedBody fallback, as you suggested. Five new tests: an HTML page, an over-cap single line, whitespace-only, a short plain-text body and the first line of a multi-line one (plus one exactly at the cap), and an oversized serialized object.

#52's finding 3 — per-tag failures reported the status name — fixed here.

apiErrorDetails doesn't exist yet at #52's point in the stack, so the fix belongs at this level. New exported errorReason(err): formatError and it now share a private apiErrorReason, and errorReason returns the bare reason without the Error: prefix, the (HTTP n) suffix or sanitization — the --keep-latest table path sanitizes at render and its JSON path doesn't sanitize at all. deleteTagsInSequence uses it instead of err.message. The existing test masked this by rejecting with a plain Error; there's now one that rejects with a real ApiError.

Every fix across the stack was checked by reverting it and confirming the new test fails — all six caught their own bug. 762 tests green on this branch, check-types and npm run build clean.

🤖 Generated by /pr-fixup command

joanasteodoro
joanasteodoro previously approved these changes Sep 22, 2026
stack merge was automatically disabled September 22, 2026 10:31

Pull Request is not mergeable

…sages

Resolves the changelog conflict in stack order and trims this branch's
review-follow-up entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stack merge was automatically disabled September 22, 2026 10:36

Pull Request is not mergeable

@claudiacodacy
claudiacodacy merged commit dc33994 into main Sep 22, 2026
4 checks passed
@claudiacodacy
claudiacodacy deleted the fix/surface-api-error-messages branch September 22, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants