Skip to content

Commit 0de372c

Browse files
authored
fix(http): map non-JSON 200 responses to a typed error envelope (#166)
A successful (200) response whose body is not JSON — a misconfigured endpoint, a proxy / captive-portal / login page returning HTML with a success status, or an empty body — caused the OK path to call raw `response.json()`, whose SyntaxError escaped to the top-level handler. That produced an opaque exit 1 and, under --output json, a bare `{"error":"<parse message>"}` that breaks the typed-envelope contract every other error honors (the non-OK path already reads defensively via safeReadJson). Wrap the OK-path parse failure in a typed INTERNAL ApiError (exit 1 unchanged) that carries the requestId, names the likely cause, and points the operator at their endpoint config; details include the HTTP status, content-type, and underlying parse message. Abort/timeout errors mid-read keep their existing classification. Fixes #94
1 parent c0b52f8 commit 0de372c

2 files changed

Lines changed: 101 additions & 1 deletion

File tree

src/lib/http.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,53 @@ describe('HttpClient happy path', () => {
140140
});
141141
});
142142

143+
describe('HttpClient — 200 response with a non-JSON body', () => {
144+
function htmlResponse(status = 200): Response {
145+
return new Response('<!DOCTYPE html><html><body>Login</body></html>', {
146+
status,
147+
headers: { 'content-type': 'text/html' },
148+
});
149+
}
150+
151+
it('throws a typed INTERNAL ApiError (not a raw SyntaxError) with the requestId and details', async () => {
152+
const fetchImpl = vi.fn(async () => htmlResponse());
153+
const client = makeClient(fetchImpl as unknown as typeof fetch);
154+
let caught: unknown;
155+
try {
156+
await client.get('/me');
157+
} catch (err) {
158+
caught = err;
159+
}
160+
expect(caught).toBeInstanceOf(ApiError);
161+
const apiErr = caught as ApiError;
162+
expect(apiErr.code).toBe('INTERNAL');
163+
expect(apiErr.exitCode).toBe(1);
164+
expect(apiErr.requestId).toMatch(/^cli_/);
165+
expect(apiErr.message).toContain('non-JSON response');
166+
expect(apiErr.nextAction).toContain('TESTSPRITE_API_URL');
167+
expect(apiErr.details).toMatchObject({ httpStatus: 200, contentType: 'text/html' });
168+
expect(String(apiErr.details.parseError)).toContain('JSON');
169+
});
170+
171+
it('does not retry a malformed 200 body (single fetch call)', async () => {
172+
const fetchImpl = vi.fn(async () => htmlResponse());
173+
const client = makeClient(fetchImpl as unknown as typeof fetch);
174+
await expect(client.get('/me')).rejects.toBeInstanceOf(ApiError);
175+
// A non-JSON success body is a hard config error, not a transient transport
176+
// failure — it must not burn the retry budget.
177+
expect(fetchImpl).toHaveBeenCalledTimes(1);
178+
});
179+
180+
it('handles an empty 200 body the same way', async () => {
181+
const fetchImpl = vi.fn(
182+
async () =>
183+
new Response('', { status: 200, headers: { 'content-type': 'application/json' } }),
184+
);
185+
const client = makeClient(fetchImpl as unknown as typeof fetch);
186+
await expect(client.get('/me')).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 });
187+
});
188+
});
189+
143190
describe('HttpClient error mapping', () => {
144191
it('does not retry AUTH_INVALID and exits 3', async () => {
145192
const fetchImpl = vi.fn(async () => errorEnvelopeResponse(401, 'AUTH_INVALID'));

src/lib/http.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,13 @@ export class HttpClient {
498498
} catch (err) {
499499
// A timeout/abort can fire mid-body-read (headers received, stream stalls).
500500
this.rethrowIfAbort(err, timeoutSignal, options.signal, requestId);
501-
throw err;
501+
// Otherwise the successful response body was not valid JSON — a
502+
// misconfigured endpoint, a proxy / captive-portal / login page that
503+
// returns HTML with a 200 status, or an empty body. Surface a typed
504+
// error carrying the requestId instead of letting the raw SyntaxError
505+
// escape to index.ts, where it would print a bare `{"error":"..."}`
506+
// and break the --output json envelope contract.
507+
throw malformedResponseError(response, requestId, err);
502508
}
503509
}
504510

@@ -692,6 +698,53 @@ async function safeReadJson(response: Response): Promise<unknown> {
692698
}
693699
}
694700

701+
/**
702+
* Build a typed error for a successful response whose body could not be parsed
703+
* as JSON.
704+
*
705+
* The CLI expects every API response to be a JSON envelope. When a `200 OK`
706+
* carries a non-JSON body — a misconfigured endpoint, a proxy / captive-portal
707+
* / SSO login page returning HTML with a success status, or an empty body —
708+
* `response.json()` throws a raw `SyntaxError`. Left unhandled it escapes to
709+
* the top-level handler in `index.ts`, which prints a bare
710+
* `{"error":"<parse message>"}` under `--output json` (breaking the
711+
* typed-envelope contract every other error honors) and gives the operator no
712+
* actionable context.
713+
*
714+
* This wraps it in a typed `INTERNAL` `ApiError` (exit 1, unchanged) that
715+
* carries the `requestId`, names the likely cause, and points the operator at
716+
* their endpoint configuration. `details` includes the HTTP status, the
717+
* response `content-type` (when present), and the underlying parse message.
718+
*/
719+
export function malformedResponseError(
720+
response: Response,
721+
requestId: string,
722+
cause: unknown,
723+
): ApiError {
724+
const contentType = response.headers.get('content-type') ?? undefined;
725+
const parseError = cause instanceof Error ? cause.message : String(cause);
726+
const contentTypeNote = contentType ? ` (content-type: ${contentType})` : '';
727+
return new ApiError(
728+
{
729+
code: 'INTERNAL',
730+
message:
731+
`The server returned a non-JSON response${contentTypeNote} for an HTTP ${response.status}. ` +
732+
`This usually means the endpoint is not the TestSprite API — a proxy, captive portal, or ` +
733+
`login page can return HTML with a success status.`,
734+
nextAction:
735+
'Check that --endpoint-url / TESTSPRITE_API_URL points at the TestSprite API ' +
736+
'(default https://api.testsprite.com), then retry.',
737+
requestId,
738+
details: {
739+
httpStatus: response.status,
740+
...(contentType ? { contentType } : {}),
741+
parseError,
742+
},
743+
},
744+
response.status,
745+
);
746+
}
747+
695748
export function parseRetryAfter(headerValue: string | null): number | undefined {
696749
if (!headerValue) return undefined;
697750
const numeric = Number(headerValue);

0 commit comments

Comments
 (0)