Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
102 changes: 83 additions & 19 deletions packages/eas-cli/src/build/__tests__/handleBuildRequestError-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down Expand Up @@ -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 () => {
Expand All @@ -122,7 +125,7 @@ describe(Build.name, () => {
handleBuildRequestError(error, platform);
} catch {}

expect(logDebugSpy).toBeCalledWith(undefined);
expect(logDebugSpy).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -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<Error>(() => {
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 () => {
Expand Down
157 changes: 140 additions & 17 deletions packages/eas-cli/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,17 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
};

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(
Expand All @@ -247,25 +252,143 @@ 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}`
);
}
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<string, unknown> | null {
const hasGraphQLErrors = Array.isArray(error?.graphQLErrors);
const hasResponse = !!error?.response;
const hasNetworkError = !!error?.networkError;

if (!hasGraphQLErrors && !hasResponse && !hasNetworkError) {
return null;
}

const debugInfo: Record<string, unknown> = {};
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<string, unknown> {
const formattedError: Record<string, unknown> = {};
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<string, unknown> | null {
if (!response) {
return null;
}

const formattedResponse: Record<string, unknown> = {};
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<string, string> {
const headers: Record<string, string> = {};
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<TPlatform extends Platform>(
ctx: BuildContext<TPlatform>
): Promise<{
Expand Down
Loading