diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fbff8ec5c..5e6baf93f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Skip embedded bundle upload for development client builds instead of warning that the bundle is missing. ([#3940](https://github.com/expo/eas-cli/pull/3940) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Show network and HTTP response details for EAS Build request failures when GraphQL errors are empty. ([#3982](https://github.com/expo/eas-cli/pull/3982) by [@szdziedzic](https://github.com/szdziedzic)) ### 🧹 Chores diff --git a/packages/eas-cli/src/build/__tests__/handleBuildRequestError-test.ts b/packages/eas-cli/src/build/__tests__/handleBuildRequestError-test.ts index daf887f436..afa05605ef 100644 --- a/packages/eas-cli/src/build/__tests__/handleBuildRequestError-test.ts +++ b/packages/eas-cli/src/build/__tests__/handleBuildRequestError-test.ts @@ -25,23 +25,26 @@ beforeEach(() => { jest.resetAllMocks(); }); -const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[ - { - "name": "GraphQLError", - "extensions": {}, - "message": "Error 1" - }, - { - "name": "GraphQLError", - "extensions": {}, - "message": "Error 2" - }, - { - "name": "GraphQLError", - "extensions": {}, - "message": "Error 3" - } -]`; +const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `Build request error details: +{ + "graphQLErrors": [ + { + "name": "GraphQLError", + "extensions": {}, + "message": "Error 1" + }, + { + "name": "GraphQLError", + "extensions": {}, + "message": "Error 2" + }, + { + "name": "GraphQLError", + "extensions": {}, + "message": "Error 3" + } + ] +}`; const EXPECTED_EAS_BUILD_DOWN_MESSAGE = `EAS Build is down for maintenance. Try again later. Check ${link( 'https://status.expo.dev/' @@ -110,7 +113,7 @@ describe(Build.name, () => { handleBuildRequestError(error, platform); } catch {} - expect(logDebugSpy).toBeCalledWith(undefined); + expect(logDebugSpy).not.toHaveBeenCalled(); }); it('does it with iOS', async () => { @@ -122,7 +125,7 @@ describe(Build.name, () => { handleBuildRequestError(error, platform); } catch {} - expect(logDebugSpy).toBeCalledWith(undefined); + expect(logDebugSpy).not.toHaveBeenCalled(); }); }); @@ -510,6 +513,67 @@ describe(Build.name, () => { assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage); }); }); + describe('with empty GraphQL errors and network response details', () => { + it('throws base Error class with network and response details', async () => { + const platform = Platform.ANDROID; + const error = new CombinedError({ + graphQLErrors: [], + networkError: new Error('Request failed: 400 (Bad Request)'), + response: { + status: 400, + statusText: 'Bad Request', + headers: { + get: (headerName: string) => + headerName === 'expo-request-id' ? mockRequestId : null, + }, + }, + }); + const expectedMessage = + `${EXPECTED_GENERIC_MESSAGE}\n` + + 'Network error: Request failed: 400 (Bad Request)\n' + + 'Response status: 400 Bad Request\n' + + `Request ID: ${mockRequestId}`; + + const handleBuildRequestErrorThrownError = getError(() => { + handleBuildRequestError(error, platform); + }); + + assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage); + }); + + it('logs network and response details to debug', async () => { + const platform = Platform.ANDROID; + const logDebugSpy = jest.spyOn(Log, 'debug'); + const error = new CombinedError({ + graphQLErrors: [], + networkError: new Error('Request failed: 400 (Bad Request)'), + response: { + status: 400, + statusText: 'Bad Request', + headers: { + get: (headerName: string) => + headerName === 'expo-request-id' ? mockRequestId : null, + }, + }, + }); + + try { + handleBuildRequestError(error, platform); + } catch {} + + expect(logDebugSpy).toBeCalledWith(expect.stringContaining('"graphQLErrors": []')); + expect(logDebugSpy).toBeCalledWith( + expect.stringContaining('"message": "[Network] Request failed: 400 (Bad Request)"') + ); + expect(logDebugSpy).toBeCalledWith( + expect.stringContaining('"message": "Request failed: 400 (Bad Request)"') + ); + expect(logDebugSpy).toBeCalledWith(expect.stringContaining('"status": 400')); + expect(logDebugSpy).toBeCalledWith( + expect.stringContaining(`"expo-request-id": "${mockRequestId}"`) + ); + }); + }); }); describe('with iOS', () => { it('throws base Error class with custom message', async () => { diff --git a/packages/eas-cli/src/build/build.ts b/packages/eas-cli/src/build/build.ts index 5d21cea62e..1b3d35efa2 100644 --- a/packages/eas-cli/src/build/build.ts +++ b/packages/eas-cli/src/build/build.ts @@ -231,12 +231,17 @@ const SERVER_SIDE_DEFINED_ERRORS: Record = { }; export function handleBuildRequestError(error: any, platform: Platform): never { - Log.debug(JSON.stringify(error.graphQLErrors, null, 2)); - - const graphQLErrorCode: string = error?.graphQLErrors?.[0]?.extensions?.errorCode; - if (graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) { + logBuildRequestErrorDebugInfo(error); + + const graphQLErrors: GraphQLError[] = Array.isArray(error?.graphQLErrors) + ? error.graphQLErrors + : []; + const graphQLErrorCode: string | undefined = graphQLErrors[0]?.extensions?.errorCode as + | string + | undefined; + if (graphQLErrorCode && graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) { const ErrorClass: typeof EasCommandError = SERVER_SIDE_DEFINED_ERRORS[graphQLErrorCode]; - throw new ErrorClass(error?.graphQLErrors?.[0]?.message); + throw new ErrorClass(graphQLErrors[0]?.message); } else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') { throw new EasBuildDownForMaintenanceError( `EAS Build is down for maintenance. Try again later. Check ${link( @@ -247,18 +252,8 @@ export function handleBuildRequestError(error: any, platform: Platform): never { throw new EasBuildTooManyPendingBuildsError( `You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.` ); - } else if (error?.graphQLErrors) { - const errorMessage = error.graphQLErrors - .map((graphQLError: GraphQLError) => { - const requestIdLine = graphQLError?.extensions?.requestId - ? `\nRequest ID: ${graphQLError.extensions.requestId}` - : ''; - const errorMessageLine = graphQLError?.message - ? `\nError message: ${graphQLError.message}` - : ''; - return `${requestIdLine}${errorMessageLine}`; - }) - .join(''); + } else if (Array.isArray(error?.graphQLErrors)) { + const errorMessage = formatBuildRequestErrorDetails(error); throw new Error( `Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${errorMessage}` ); @@ -266,6 +261,134 @@ export function handleBuildRequestError(error: any, platform: Platform): never { throw error; } +function formatBuildRequestErrorDetails(error: any): string { + const graphQLErrors: GraphQLError[] = Array.isArray(error?.graphQLErrors) + ? error.graphQLErrors + : []; + const details: string[] = graphQLErrors + .map((graphQLError: GraphQLError) => { + const requestIdLine = graphQLError?.extensions?.requestId + ? `\nRequest ID: ${graphQLError.extensions.requestId}` + : ''; + const errorMessageLine = graphQLError?.message + ? `\nError message: ${graphQLError.message}` + : ''; + return `${requestIdLine}${errorMessageLine}`; + }) + .filter(Boolean); + + if (error?.networkError?.message) { + details.push(`\nNetwork error: ${error.networkError.message}`); + } + + const responseStatusLine = formatResponseStatusLine(error?.response); + if (responseStatusLine) { + details.push(responseStatusLine); + } + + const responseRequestIdLine = formatResponseRequestIdLine(error?.response); + if (responseRequestIdLine && !details.some(detail => detail.includes(responseRequestIdLine))) { + details.push(responseRequestIdLine); + } + + if (details.length === 0 && error?.message) { + details.push(`\nError message: ${error.message}`); + } + + return details.join(''); +} + +function logBuildRequestErrorDebugInfo(error: any): void { + const debugInfo = formatBuildRequestErrorDebugInfo(error); + if (debugInfo) { + Log.debug(`Build request error details:\n${JSON.stringify(debugInfo, null, 2)}`); + } +} + +function formatBuildRequestErrorDebugInfo(error: any): Record | null { + const hasGraphQLErrors = Array.isArray(error?.graphQLErrors); + const hasResponse = !!error?.response; + const hasNetworkError = !!error?.networkError; + + if (!hasGraphQLErrors && !hasResponse && !hasNetworkError) { + return null; + } + + const debugInfo: Record = {}; + if (hasGraphQLErrors) { + debugInfo.graphQLErrors = error.graphQLErrors; + } + if (error?.message && (error.graphQLErrors?.length === 0 || hasNetworkError || hasResponse)) { + debugInfo.message = error.message; + } + if (hasNetworkError) { + debugInfo.networkError = formatErrorForDebug(error.networkError); + } + + const response = formatResponseForDebug(error?.response); + if (response) { + debugInfo.response = response; + } + + return debugInfo; +} + +function formatErrorForDebug(error: any): Record { + const formattedError: Record = {}; + for (const property of ['name', 'message', 'code', 'type', 'errno', 'syscall', 'stack']) { + if (error?.[property]) { + formattedError[property] = error[property]; + } + } + return formattedError; +} + +function formatResponseForDebug(response: any): Record | null { + if (!response) { + return null; + } + + const formattedResponse: Record = {}; + for (const property of ['status', 'statusText', 'url']) { + if (response[property] !== undefined && response[property] !== '') { + formattedResponse[property] = response[property]; + } + } + + const headers = getResponseHeadersForDebug(response); + if (Object.keys(headers).length > 0) { + formattedResponse.headers = headers; + } + + return Object.keys(formattedResponse).length > 0 ? formattedResponse : null; +} + +function getResponseHeadersForDebug(response: any): Record { + const headers: Record = {}; + for (const headerName of ['expo-request-id', 'x-request-id', 'content-type']) { + const headerValue = response?.headers?.get?.(headerName); + if (headerValue) { + headers[headerName] = headerValue; + } + } + return headers; +} + +function formatResponseStatusLine(response: any): string | null { + if (response?.status === undefined && !response?.statusText) { + return null; + } + return `\nResponse status: ${[response.status, response.statusText] + .filter(value => value !== undefined && value !== '') + .join(' ')}`; +} + +function formatResponseRequestIdLine(response: any): string | null { + const responseRequestId = + response?.headers?.get?.('expo-request-id') ?? response?.headers?.get?.('x-request-id'); + return responseRequestId ? `\nRequest ID: ${responseRequestId}` : null; +} + async function uploadProjectAsync( ctx: BuildContext ): Promise<{