Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 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 Down
31 changes: 31 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,35 @@
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).toBeInstanceOf(Headers)

Check failure on line 181 in tests/legacy/http-status-with-errors.test.ts

View workflow job for this annotation

GitHub Actions / Node 20 @env jsdom

tests/legacy/http-status-with-errors.test.ts > HTTP 4xx/5xx status codes with GraphQL response body > non-GraphQL response - headers and rawBody should be accessible

AssertionError: expected Headers { 'x-powered-by': 'Express', 'kill-switch': 'true', 'content-type': 'application/json; charset=utf-8', 'content-length': '56', etag: 'W/"38-TFc4JlC5gg30Z0j/mD1Onk1eUJo"', date: 'Fri, 12 Dec 2025 15:21:27 GMT', connection: 'keep-alive', 'keep-alive': 'timeout=5' } to be an instance of Headers ❯ tests/legacy/http-status-with-errors.test.ts:181:44
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)
}
})
})
Loading