Compare commits

...
5 changed files with 153 additions and 13 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Show the final Google Play rejection when a retried release commit has an uncertain outcome.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry recognized Google Play processing and review-setting rejections after transient request failures, while preserving uncertain commit outcomes.
@@ -198,7 +198,10 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
return [204];
});
const task = submission();
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
const error = await task.run().catch(error => error);
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toContain('HTTP 404');
expect((error as Error).message).toContain('may still have saved');
expect(deleted).toBe(false);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
});
@@ -144,6 +144,16 @@ export namespace GooglePlayUtils {
signal?: AbortSignal;
}
): Promise<void> {
let uncertainCommitError: GooglePlayCommitOutcomeUnknownError | undefined;
const lastCommitError = (error: unknown): unknown => {
while (
error instanceof GooglePlayCommitOutcomeUnknownError ||
error instanceof AggregateError
) {
error = error.cause;
}
return error;
};
const sendCommitAsync = async ({
changesNotSentForReview,
}: {
@@ -165,13 +175,32 @@ export namespace GooglePlayUtils {
throw error;
}
if (
uncertainCommitError ||
!(error instanceof GooglePlayApiError) ||
GooglePlayApiError.isRetryableStatus(error.status)
) {
throw new GooglePlayCommitOutcomeUnknownError(
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
const cause = uncertainCommitError
? new AggregateError(
[uncertainCommitError, error],
'Google Play commit attempts failed.',
{ cause: error }
)
: error;
const lastError = lastCommitError(error);
const lastResponse =
lastError instanceof GooglePlayApiError &&
lastError.status >= 400 &&
lastError.status < 500 &&
!GooglePlayApiError.isRetryableStatus(lastError.status)
? `Google Play returned HTTP ${lastError.status} for the last commit attempt${lastError.apiMessage ? `: ${lastError.apiMessage}` : '.'}\n`
: '';
uncertainCommitError = new GooglePlayCommitOutcomeUnknownError(
lastResponse
? `${lastResponse}An earlier commit attempt failed with a temporary error, so Google Play may still have saved the release for ${packageName}. Check its release status at https://play.google.com/console/. If the release is not there, fix the problem above and submit again.`
: `Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
{ cause, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
throw uncertainCommitError;
}
throw error;
}
@@ -183,10 +212,14 @@ export namespace GooglePlayUtils {
};
// Google can accept the bundle upload before the edit is ready to commit.
// Retry only this explicit rejection, not other HTTP 400 preconditions.
const isUploadStillProcessing = (error: unknown): boolean =>
error instanceof GooglePlayApiError &&
error.status === 400 &&
error.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.';
const isUploadStillProcessing = (error: unknown): boolean => {
const lastError = lastCommitError(error);
return (
lastError instanceof GooglePlayApiError &&
lastError.status === 400 &&
lastError.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.'
);
};
const commitAsync = async (options: { changesNotSentForReview?: boolean }): Promise<void> => {
try {
@@ -204,7 +237,21 @@ export namespace GooglePlayUtils {
}
)(options);
} catch (error) {
if (isUploadStillProcessing(error)) {
if (uncertainCommitError && !(error instanceof GooglePlayCommitOutcomeUnknownError)) {
// Cancellation or a later authentication failure cannot establish the earlier outcome.
throw new GooglePlayCommitOutcomeUnknownError(uncertainCommitError.message, {
cause: new AggregateError(
[uncertainCommitError, error],
'Google Play commit recovery stopped.',
{ cause: error }
),
metadata: uncertainCommitError.metadata,
});
}
if (
isUploadStillProcessing(error) &&
!(error instanceof GooglePlayCommitOutcomeUnknownError)
) {
throw new SystemError(
`Google Play did not finish processing the uploaded bundle for ${packageName} within five minutes. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
@@ -218,13 +265,14 @@ export namespace GooglePlayUtils {
// reject its presence, even when false. True saves changes for review in Console.
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
} catch (error) {
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
const lastError = lastCommitError(error);
if (!(lastError instanceof GooglePlayApiError) || lastError.status !== 400) {
throw error;
}
// Like Fastlane, retry only an explicit rejection of the review setting.
if (
changesNotSentForReview &&
error.apiMessage.includes('The query parameter changesNotSentForReview must not be set')
lastError.apiMessage.includes('The query parameter changesNotSentForReview must not be set')
) {
logger.warn(
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
@@ -232,7 +280,9 @@ export namespace GooglePlayUtils {
await commitAsync({});
} else if (
!changesNotSentForReview &&
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
lastError.apiMessage.includes(
'Please set the query parameter changesNotSentForReview to true'
)
) {
logger.warn(
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
@@ -84,6 +84,23 @@ it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit',
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
await expect(commit()).resolves.toBeUndefined();
});
it('shows the final rejection after a transient commit failure without claiming the outcome is known', async () => {
mockToken();
api().post(`${editPath}:commit`).query({}).reply(503);
const message = 'You must let us know whether your app includes any health features.';
api().post(`${editPath}:commit`).query({}).reply(403, { error: { message } });
const error = await commit().catch(error => error);
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
expect(mapped).toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
expect((mapped as Error).message).toContain(`HTTP 403 for the last commit attempt: ${message}`);
expect((mapped as Error).message).toContain('may still have saved');
expect((mapped as Error).message).toContain('https://play.google.com/console/');
expect(mapped).toMatchObject({
metadata: { googlePlayErrorResponse: JSON.stringify({ error: { message } }) },
cause: expect.any(AggregateError),
});
expect(((mapped as Error).cause as AggregateError).errors).toHaveLength(2);
});
it('preserves cancellation before commit is sent', async () => {
const controller = new AbortController();
const reason = new Error('cancelled before commit');
@@ -92,6 +109,37 @@ it('preserves cancellation before commit is sent', async () => {
});
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
it.each([
[false, pendingUploadMessage, {}],
[true, 'The query parameter changesNotSentForReview must not be set', {}],
[
false,
'Please set the query parameter changesNotSentForReview to true',
{ changesNotSentForReview: 'true' },
],
] as const)(
'recovers from a wrapped rejection: %s %s',
async (changesNotSentForReview, message, retryQuery) => {
mockToken();
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
api().post(`${editPath}:commit`).query(query).reply(503);
api().post(`${editPath}:commit`).query(query).reply(400, { error: { message } });
api().post(`${editPath}:commit`).query(retryQuery).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
}
);
it('keeps an uncertain outcome when processing recovery ends in another rejection', async () => {
mockToken();
api().post(`${editPath}:commit`).query({}).reply(503);
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`).query({}).reply(404);
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
});
it.each([false, true])(
'waits for bundle processing without changing review settings (%s)',
async changesNotSentForReview => {
@@ -110,6 +158,17 @@ it.each([false, true])(
}
);
it('preserves uncertainty when wrapped processing retries are exhausted', async () => {
mockToken();
api().post(`${editPath}:commit`).query({}).reply(503);
api()
.post(`${editPath}:commit`)
.query({})
.times(14)
.reply(400, { error: { message: pendingUploadMessage } });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
});
it('stops waiting for bundle processing after 14 attempts', async () => {
mockToken();
api()
@@ -250,7 +309,9 @@ it('keeps the last Google Play error response when the commit outcome is unknown
.query({})
.times(6)
.reply(503, { error: { message: 'Backend error' } });
await expect(commit()).rejects.toMatchObject({
const error = await commit().catch(error => error);
expect(error.message).not.toContain('last commit attempt');
expect(error).toMatchObject({
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
@@ -269,3 +330,19 @@ it('does not keep the upload session query or a non-JSON response', async () =>
googlePlayErrorResponse: undefined,
});
});
it('preserves an uncertain commit if processing recovery is cancelled', async () => {
const controller = new AbortController();
const reason = new Error('cancelled after uncertain commit');
const logger = createMockLogger();
jest.spyOn(logger, 'warn').mockImplementation(() => controller.abort(reason));
mockToken();
api().post(`${editPath}:commit`).query({}).reply(503);
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
await expect(commit({ signal: controller.signal, logger })).rejects.toBeInstanceOf(
GooglePlayCommitOutcomeUnknownError
);
});