From 3fbc7ce00e0aca58d267996d325a21f1b8b5587f Mon Sep 17 00:00:00 2001 From: Muhammad Talha Date: Mon, 14 Sep 2026 16:12:33 -0700 Subject: [PATCH] fix(data-connect): include debugDetails in GraphQL error messages --- .../data-connect-api-client-internal.ts | 39 +++++++- .../data-connect-api-client-internal.spec.ts | 95 ++++++++++++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 5cae97f0a8..efae2ae360 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -407,7 +407,7 @@ export class DataConnectApiClient { }; const resp = await this.httpClient.send(request); if (resp.data.errors && validator.isNonEmptyArray(resp.data.errors)) { - const allMessages = resp.data.errors.map((error: { message: any; }) => error.message).join(' '); + const allMessages = formatGraphqlErrors(resp.data.errors); throw new FirebaseDataConnectError({ code: DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, message: allMessages, @@ -435,6 +435,15 @@ export class DataConnectApiClient { } const data = response.data as any; + if (validator.isNonNullObject(data) && validator.isNonEmptyArray(data.errors)) { + return new FirebaseDataConnectError({ + code: DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, + message: formatGraphqlErrors(data.errors), + httpResponse: toHttpResponse(response), + cause: err, + }); + } + const error: ServerError = (validator.isNonNullObject(data) && validator.isNonNullObject(data.error)) ? data.error : (validator.isNonNullObject(data) ? data : {}); @@ -646,6 +655,34 @@ interface ServerError { status?: string; } +interface GraphqlErrorResponse { + message?: string; + extensions?: { + debugDetails?: string; + [key: string]: any; + }; + [key: string]: any; +} + +/** + * Formats GraphQL errors into a human-readable string, including debugDetails if present. + * + * @internal + */ +export function formatGraphqlErrors(errors: GraphqlErrorResponse[]): string { + return errors + .map((error) => { + // Defensive fallback for non-conforming or malformed error payloads. + if (!validator.isNonNullObject(error)) { + return String(error); + } + const message = error.message || 'Unknown error'; + const details = error.extensions?.debugDetails; + return details ? `${message}: ${details}` : message; + }) + .join('; '); +} + /** * Extracts property keys from an object or array of objects as a space-separated string, * including recursively nested object/array fields for the `@allow(fields: ...)` directive. diff --git a/test/unit/data-connect/data-connect-api-client-internal.spec.ts b/test/unit/data-connect/data-connect-api-client-internal.spec.ts index 79d0d0f665..a6f5e8db46 100644 --- a/test/unit/data-connect/data-connect-api-client-internal.spec.ts +++ b/test/unit/data-connect/data-connect-api-client-internal.spec.ts @@ -16,8 +16,16 @@ */ import * as _ from 'lodash'; -import { expect } from 'chai'; +import * as chai from 'chai'; import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; import { AuthorizedHttpClient, HttpClient, @@ -252,6 +260,91 @@ describe('DataConnectApiClient', () => { return apiClient.executeGraphql('query', {}) .should.eventually.be.rejected.and.deep.include(expected); }); + + it('should reject when GraphQL errors with debugDetails are returned', async () => { + const gqlErrorResponse = { + errors: [ + { + message: 'SQL execution failed', + path: ['content_update'], + extensions: { + code: 'INTERNAL', + debugDetails: 'Quota exceeded for quota metric Connect Queries' + } + } + ] + }; + sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(gqlErrorResponse, 200)); + + await expect(apiClient.executeGraphql('query', {})) + .to.be.rejectedWith( + FirebaseDataConnectError, + 'SQL execution failed: Quota exceeded for quota metric Connect Queries' + ); + }); + + it('should reject and format multiple GraphQL errors', async () => { + const gqlErrorResponse = { + errors: [ + { message: 'First error' }, + { + message: 'Second error', + extensions: { debugDetails: 'detailed failure' } + } + ] + }; + sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(gqlErrorResponse, 200)); + + await expect(apiClient.executeGraphql('query', {})) + .to.be.rejectedWith( + FirebaseDataConnectError, + 'First error; Second error: detailed failure' + ); + }); + + it('should reject with formatted GraphQL errors on non-200 HTTP response', async () => { + const gqlErrorResponse = { + errors: [ + { + message: 'Bad request', + extensions: { debugDetails: 'Field not found' } + } + ] + }; + const mockErr = utils.errorFrom(gqlErrorResponse, 400); + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(mockErr); + + await expect(apiClient.executeGraphql('query', {})) + .to.be.rejectedWith( + FirebaseDataConnectError, + 'Bad request: Field not found' + ); + }); + + it('should handle malformed error arrays containing null or primitive elements', async () => { + const gqlErrorResponse = { + errors: [ + null, + 'string error', + { message: 'valid error', extensions: { debugDetails: 'extra' } } + ] + }; + sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(gqlErrorResponse, 200)); + + await expect(apiClient.executeGraphql('query', {})) + .to.be.rejectedWith( + FirebaseDataConnectError, + 'null; string error; valid error: extra' + ); + }); }); it('should resolve with the GraphQL response on success', async () => {