From 0c107f86503982983c6bcc810eb29cd619b02ce1 Mon Sep 17 00:00:00 2001 From: Tiago Barros Date: Fri, 14 Aug 2026 12:47:57 -0300 Subject: [PATCH] Report non-JSON API responses as they came The GraphQL client parsed every response body as JSON regardless of what came back. A gateway error such as an Envoy 503, whose body is plain text, therefore surfaced as "Unexpected token 'u', "upstream c"... is not valid JSON", which says nothing about the status or the failure, and discarded the body before anything was printed. The body is now read as text and parsed explicitly, and a body that is not a JSON object is reported as it came. The check is on the payload rather than on the response status because the API answers an invalid query with status 400 and a payload, and access denied with status 200 and a payload, so the status does not tell whether the body carries structured errors. A test pins that behaviour. Co-Authored-By: Claude Opus 5 --- .../graphql/fetchGraphqlClient.ts | 52 ++++--- .../graphql/fetchGraphqlClient.test.ts | 129 ++++++++++++++++++ 2 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 test/infrastructure/graphql/fetchGraphqlClient.test.ts diff --git a/src/infrastructure/graphql/fetchGraphqlClient.ts b/src/infrastructure/graphql/fetchGraphqlClient.ts index 519e90b7..073996ce 100644 --- a/src/infrastructure/graphql/fetchGraphqlClient.ts +++ b/src/infrastructure/graphql/fetchGraphqlClient.ts @@ -48,25 +48,41 @@ export class FetchGraphqlClient implements GraphqlClient { }), }); - return response.json().then(result => { - const {data, errors} = result as GraphqlResponseBody; + const {data, errors} = await FetchGraphqlClient.parseBody(response); - if (errors !== undefined) { - throw new ApiError( - errors[0].message.replace(/"/g, '`'), - errors.map( - ({extensions}) => ({ - ...extensions, - detail: extensions.detail?.replace(/"/g, '`'), - }), - ), - ); - } + if (errors !== undefined) { + throw new ApiError( + errors[0].message.replace(/"/g, '`'), + errors.map( + ({extensions}) => ({ + ...extensions, + detail: extensions.detail?.replace(/"/g, '`'), + }), + ), + ); + } - return { - data: data, - headers: response.headers, - }; - }); + return { + data: data, + headers: response.headers, + }; + } + + private static async parseBody(response: Response): Promise> { + const body = await response.text(); + + let payload: unknown; + + try { + payload = JSON.parse(body); + } catch { + payload = null; + } + + if (typeof payload !== 'object' || payload === null) { + throw new ApiError(body); + } + + return payload as GraphqlResponseBody; } } diff --git a/test/infrastructure/graphql/fetchGraphqlClient.test.ts b/test/infrastructure/graphql/fetchGraphqlClient.test.ts new file mode 100644 index 00000000..1a6b3236 --- /dev/null +++ b/test/infrastructure/graphql/fetchGraphqlClient.test.ts @@ -0,0 +1,129 @@ +import {TypedDocumentString} from '@/infrastructure/graphql/schema/graphql'; +import {FetchGraphqlClient} from '@/infrastructure/graphql/fetchGraphqlClient'; +import {ApiError, ProblemType} from '@/application/api/error'; + +describe('A fetch GraphQL client', () => { + const endpoint = new URL('https://example.com/graphql'); + + type TestResult = { + greeting: string, + }; + + const query = new TypedDocumentString>('query Test { greeting }'); + + it('should return the data from a valid payload', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ + data: { + greeting: 'Hello', + }, + }), + ); + + const client = new FetchGraphqlClient({endpoint: endpoint}); + + await expect(client.execute(query)).resolves.toEqual({ + headers: expect.any(Headers), + data: { + greeting: 'Hello', + }, + }); + }); + + it('should use the token provider and send the token', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ + data: { + greeting: 'Hello', + }, + }), + ); + + const getToken = jest.fn().mockResolvedValue('token-value'); + + const client = new FetchGraphqlClient({ + endpoint: endpoint, + tokenProvider: {getToken: getToken}, + }); + + await client.execute(query); + + expect(getToken).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer token-value', + }), + }), + ); + }); + + type MalformedBodyScenario = { + body: string, + status: number, + }; + + it.each<[string, MalformedBodyScenario]>( + Object.entries({ + 'report a gateway error as it came': { + body: 'upstream connect error or disconnect/reset before headers. ' + + 'reset reason: connection termination', + status: 503, + }, + 'report an HTML error page as it came': { + body: '

502 Bad Gateway

', + status: 502, + }, + 'report an empty body as it came': { + body: '', + status: 500, + }, + 'report a payload that is not an object as it came': { + body: 'null', + status: 200, + }, + }), + )('should %s', async (_, scenario) => { + const {body, status} = scenario; + + jest.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body, {status: status})); + + const client = new FetchGraphqlClient({endpoint: endpoint}); + + const error = await client.execute(query).catch(reason => reason); + + expect(error).toBeInstanceOf(ApiError); + expect(error.message).toBe(body); + }); + + it('should report the errors of a non-successful response', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json( + { + errors: [ + { + message: 'Invalid input', + extensions: { + type: ProblemType.INVALID_INPUT, + title: 'Invalid input', + detail: 'Cannot query field "greeting" on type "Query".', + status: 400, + }, + }, + ], + }, + {status: 400}, + ), + ); + + const client = new FetchGraphqlClient({endpoint: endpoint}); + + const error = await client.execute(query).catch(reason => reason); + + expect(error).toBeInstanceOf(ApiError); + expect(error.message).toBe('Invalid input'); + expect(error.isErrorType(ProblemType.INVALID_INPUT)).toBe(true); + expect(error.problems[0].detail).toBe('Cannot query field `greeting` on type `Query`.'); + }); +});