Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/legacy/helpers/runRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,16 @@ export const runRequest = async (input: Input): Promise<ClientError | GraphQLCli
const fetcher = createFetcher(config.method)
const fetchResponse = await fetcher(config)

// Read response body text first (can only be read once)
const body = await fetchResponse.text()

// Parse response body FIRST, regardless of HTTP status
// This allows GraphQL errors to be extracted even when HTTP status is 4xx/5xx (fixes #1281)
let result
try {
result = await parseResultFromResponse(
fetchResponse,
result = parseResultFromText(
body,
fetchResponse.headers.get(CONTENT_TYPE_HEADER),
input.fetchOptions.jsonSerializer ?? defaultJsonSerializer,
)
} catch (error) {
Expand All @@ -87,6 +91,7 @@ export const runRequest = async (input: Input): Promise<ClientError | GraphQLCli
const clientResponseBase = {
status: fetchResponse.status,
headers: fetchResponse.headers,
body,
}

// Handle non-2xx HTTP status codes
Expand Down Expand Up @@ -159,9 +164,7 @@ const executionResultClientResponseFields = ($params: Input) => (executionResult
}
}

const parseResultFromResponse = async (response: Response, jsonSerializer: JsonSerializer) => {
const contentType = response.headers.get(CONTENT_TYPE_HEADER)
const text = await response.text()
const parseResultFromText = (text: string, contentType: string | null, jsonSerializer: JsonSerializer) => {
if (contentType && isGraphQLContentType(contentType)) {
return parseGraphQLExecutionResult(jsonSerializer.parse(text))
} else {
Expand Down
11 changes: 11 additions & 0 deletions src/legacy/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export interface GraphQLResponse<T = unknown> {
errors?: GraphQLError[]
extensions?: unknown
status: number
headers: Headers
/**
* The response body text. Useful for debugging non-GraphQL responses
* (e.g., 401/403 errors that return plain JSON instead of GraphQL).
*/
body: string
[key: string]: unknown
}

Expand All @@ -62,6 +68,11 @@ export type TypedDocumentString<$Result, $Variables> = String & DocumentTypeDeco
export interface GraphQLClientResponse<Data> {
status: number
headers: Headers
/**
* The response body text. Useful for debugging non-GraphQL responses
* (e.g., 401/403 errors that return plain JSON instead of GraphQL).
*/
body: string
data: Data
extensions?: unknown
errors?: GraphQLError[]
Expand Down
4 changes: 2 additions & 2 deletions tests/legacy/batching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ test(`minimal double query`, async () => {
test(`basic error`, async () => {
mockServer.res({ body: [{ errors }] })
await expect(batchRequests(mockServer.url, [{ document: `x` }])).rejects.toMatchInlineSnapshot(
`[Error: GraphQL Error (Code: 200): {"response":{"0":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{}},"request":{"query":["x"],"variables":[null]}}]`,
`[Error: GraphQL Error (Code: 200): {"response":{"0":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{},"body":"[{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]}}]"},"request":{"query":["x"],"variables":[null]}}]`,
)
})

Expand All @@ -34,7 +34,7 @@ test(`successful query with another which make an error`, async () => {
await expect(
batchRequests(mockServer.url, [{ document: `{ me { id } }` }, { document: `x` }]),
).rejects.toMatchInlineSnapshot(
`[Error: GraphQL Error (Code: 200): {"response":{"0":{"data":{"me":{"id":"some-id"}}},"1":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{}},"request":{"query":["{ me { id } }","x"],"variables":[null,null]}}]`,
`[Error: GraphQL Error (Code: 200): {"response":{"0":{"data":{"me":{"id":"some-id"}}},"1":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{},"body":"[{\\"data\\":{\\"me\\":{\\"id\\":\\"some-id\\"}}},{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]}}]"},"request":{"query":["{ me { id } }","x"],"variables":[null,null]}}]`,
)
})

Expand Down
12 changes: 6 additions & 6 deletions tests/legacy/general.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ test(`minimal query`, async () => {

test(`minimal raw query`, async () => {
const mockRes = ctx.res({ body: { data, extensions: { version: `1` } } }).spec.body!
const { headers: _, ...result } = await rawRequest(ctx.url, `{ me { id } }`)
const { headers: _, body: __, ...result } = await rawRequest(ctx.url, `{ me { id } }`)
expect(result).toEqual({ data: mockRes.data, extensions: mockRes.extensions, status: 200 })
})

test(`minimal raw query with response headers`, async () => {
const { headers: reqHeaders, body } = ctx.res({
const { headers: reqHeaders, body: mockBody } = ctx.res({
headers: {
'Content-Type': `application/json`,
'X-Custom-Header': `test-custom-header`,
Expand All @@ -31,8 +31,8 @@ test(`minimal raw query with response headers`, async () => {
},
}).spec

const { headers, ...result } = await rawRequest(ctx.url, `{ me { id } }`)
expect(result).toEqual({ ...body, status: 200 })
const { headers, body: _, ...result } = await rawRequest(ctx.url, `{ me { id } }`)
expect(result).toEqual({ ...mockBody, status: 200 })
expect(headers.get(`X-Custom-Header`)).toEqual(reqHeaders![`X-Custom-Header`])
})

Expand All @@ -41,15 +41,15 @@ test(`basic error`, async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const res = await request(ctx.url, `x`).catch((x: unknown) => x)
expect(res).toMatchInlineSnapshot(
`[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{}},"request":{"query":"x"}}]`,
`[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{},"body":"{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]}}"},"request":{"query":"x"}}]`,
)
})

test(`basic error with raw request`, async () => {
ctx.res({ body: { errors } })
const res: unknown = await rawRequest(ctx.url, `x`).catch((x: unknown) => x)
expect(res).toMatchInlineSnapshot(
`[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{}},"request":{"query":"x"}}]`,
`[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{},"body":"{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]}}"},"request":{"query":"x"}}]`,
)
})

Expand Down
30 changes: 30 additions & 0 deletions tests/legacy/http-status-with-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,34 @@ describe(`HTTP 4xx/5xx status codes with GraphQL response body`, () => {
expect(clientError.response.errors).toEqual(graphqlErrors)
}
})

test(`non-GraphQL response - headers and rawBody should be accessible`, async () => {
const nonGraphQLBody = { error: `Kill switch activated`, reason: `maintenance` }

// Setup mock to return 503 with non-GraphQL response and custom header
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
ctx.server.use(`*`, function mock(req, res) {
res.status(503)
.set(`Kill-Switch`, `true`)
.set(`Content-Type`, `application/json`)
.send(JSON.stringify(nonGraphQLBody))
})

const client = new GraphQLClient(ctx.url)

try {
await client.request(`{ user { id } }`)
expect.fail(`Expected ClientError to be thrown`)
} catch (error) {
expect(error).toBeInstanceOf(ClientError)
const clientError = error as ClientError
expect(clientError.response.status).toBe(503)
// Verify headers are accessible
expect(clientError.response.headers.get(`Kill-Switch`)).toBe(`true`)
// Verify body is accessible for non-GraphQL responses
expect(clientError.response.body).toBe(JSON.stringify(nonGraphQLBody))
// Can parse the body to get detailed error info
expect(JSON.parse(clientError.response.body)).toEqual(nonGraphQLBody)
}
})
})