From c26615bbaa5b1d5620bc62f47356c3a5ea5cb690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Wed, 27 Aug 2025 09:28:12 +0200 Subject: [PATCH 1/2] Add retry logic to bundle download [Android] --- .../react/CodePushNetworkException.java | 22 +++++ .../codepush/react/CodePushUpdateManager.java | 93 +++++++++++++------ .../microsoft/codepush/react/RetryHelper.java | 87 +++++++++++++++++ .../scenarioRetryTransientFailure.js | 35 +++++++ test/test.ts | 19 ++++ 5 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 android/app/src/main/java/com/microsoft/codepush/react/CodePushNetworkException.java create mode 100644 android/app/src/main/java/com/microsoft/codepush/react/RetryHelper.java create mode 100644 test/template/scenarios/scenarioRetryTransientFailure.js diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushNetworkException.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushNetworkException.java new file mode 100644 index 000000000..0d165ff06 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushNetworkException.java @@ -0,0 +1,22 @@ +package com.microsoft.codepush.react; + +import java.io.EOFException; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.zip.ZipException; + +public class CodePushNetworkException extends IOException { + private final int httpStatusCode; + + public CodePushNetworkException(String message, int httpStatusCode) { + super(message + " (HTTP " + httpStatusCode + ")"); + this.httpStatusCode = httpStatusCode; + } + + public int getHttpStatusCode() { + return httpStatusCode; + } +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index 0bbe38cba..18a2465e6 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -146,16 +146,30 @@ public JSONObject getPackage(String packageHash) { public void downloadPackage(JSONObject updatePackage, String expectedBundleFileName, DownloadProgressCallback progressCallback, String stringPublicKey) throws IOException { - String newUpdateHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null); - String newUpdateFolderPath = getPackageFolderPath(newUpdateHash); - String newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME); + final String newUpdateHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null); + final String newUpdateFolderPath = getPackageFolderPath(newUpdateHash); + final String newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME); if (FileUtils.fileAtPathExists(newUpdateFolderPath)) { // This removes any stale data in newPackageFolderPath that could have been left // uncleared due to a crash or error during the download or install process. FileUtils.deleteDirectoryAtPath(newUpdateFolderPath); } - String downloadUrlString = updatePackage.optString(CodePushConstants.DOWNLOAD_URL_KEY, null); + final String downloadUrlString = updatePackage.optString(CodePushConstants.DOWNLOAD_URL_KEY, null); + + RetryHelper.executeWithRetry(new RetryHelper.RetryableOperation() { + @Override + public void execute() throws IOException { + downloadPackageAttempt(updatePackage, expectedBundleFileName, progressCallback, stringPublicKey, + newUpdateHash, newUpdateFolderPath, newUpdateMetadataPath, downloadUrlString); + } + }); + } + + private void downloadPackageAttempt(JSONObject updatePackage, String expectedBundleFileName, + DownloadProgressCallback progressCallback, String stringPublicKey, + String newUpdateHash, String newUpdateFolderPath, + String newUpdateMetadataPath, String downloadUrlString) throws IOException { HttpURLConnection connection = null; BufferedInputStream bin = null; FileOutputStream fos = null; @@ -177,7 +191,12 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN } } + connection.setConnectTimeout(30000); + connection.setReadTimeout(60000); connection.setRequestProperty("Accept-Encoding", "identity"); + + RetryHelper.checkHttpResponse(connection); + bin = new BufferedInputStream(connection.getInputStream()); long totalBytes = connection.getContentLength(); @@ -345,35 +364,49 @@ public void rollbackPackage() { } public void downloadAndReplaceCurrentBundle(String remoteBundleUrl, String bundleFileName) throws IOException { - URL downloadUrl; - HttpURLConnection connection = null; - BufferedInputStream bin = null; - FileOutputStream fos = null; - BufferedOutputStream bout = null; try { - downloadUrl = new URL(remoteBundleUrl); - connection = (HttpURLConnection) (downloadUrl.openConnection()); - bin = new BufferedInputStream(connection.getInputStream()); - File downloadFile = new File(getCurrentPackageBundlePath(bundleFileName)); - downloadFile.delete(); - fos = new FileOutputStream(downloadFile); - bout = new BufferedOutputStream(fos, CodePushConstants.DOWNLOAD_BUFFER_SIZE); - byte[] data = new byte[CodePushConstants.DOWNLOAD_BUFFER_SIZE]; - int numBytesRead = 0; - while ((numBytesRead = bin.read(data, 0, CodePushConstants.DOWNLOAD_BUFFER_SIZE)) >= 0) { - bout.write(data, 0, numBytesRead); - } + final URL downloadUrl = new URL(remoteBundleUrl); + final String finalBundleFileName = bundleFileName; + + RetryHelper.executeWithRetry(new RetryHelper.RetryableOperation() { + @Override + public void execute() throws IOException { + HttpURLConnection connection = null; + BufferedInputStream bin = null; + FileOutputStream fos = null; + BufferedOutputStream bout = null; + + try { + connection = (HttpURLConnection) (downloadUrl.openConnection()); + connection.setConnectTimeout(30000); + connection.setReadTimeout(60000); + + RetryHelper.checkHttpResponse(connection); + + bin = new BufferedInputStream(connection.getInputStream()); + File downloadFile = new File(getCurrentPackageBundlePath(finalBundleFileName)); + downloadFile.delete(); + fos = new FileOutputStream(downloadFile); + bout = new BufferedOutputStream(fos, CodePushConstants.DOWNLOAD_BUFFER_SIZE); + byte[] data = new byte[CodePushConstants.DOWNLOAD_BUFFER_SIZE]; + int numBytesRead = 0; + while ((numBytesRead = bin.read(data, 0, CodePushConstants.DOWNLOAD_BUFFER_SIZE)) >= 0) { + bout.write(data, 0, numBytesRead); + } + } finally { + try { + if (bout != null) bout.close(); + if (fos != null) fos.close(); + if (bin != null) bin.close(); + if (connection != null) connection.disconnect(); + } catch (IOException e) { + throw new CodePushUnknownException("Error closing IO resources.", e); + } + } + } + }); } catch (MalformedURLException e) { throw new CodePushMalformedDataException(remoteBundleUrl, e); - } finally { - try { - if (bout != null) bout.close(); - if (fos != null) fos.close(); - if (bin != null) bin.close(); - if (connection != null) connection.disconnect(); - } catch (IOException e) { - throw new CodePushUnknownException("Error closing IO resources.", e); - } } } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/RetryHelper.java b/android/app/src/main/java/com/microsoft/codepush/react/RetryHelper.java new file mode 100644 index 000000000..eca0f94e0 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/RetryHelper.java @@ -0,0 +1,87 @@ +package com.microsoft.codepush.react; + +import java.io.EOFException; +import java.io.IOException; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.zip.ZipException; + +public class RetryHelper { + private static final int MAX_RETRY_ATTEMPTS = 3; + private static final long BASE_RETRY_DELAY_MS = 1000; + + public interface RetryableOperation { + void execute() throws IOException; + } + + public static void executeWithRetry(RetryableOperation operation) throws IOException { + IOException lastException = null; + + for (int attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + try { + operation.execute(); + return; + } catch (IOException e) { + lastException = e; + + boolean shouldRetry = isRetryableException(e) && attempt < MAX_RETRY_ATTEMPTS; + + if (shouldRetry) { + CodePushUtils.log("Download attempt " + attempt + " failed, retrying: " + e.getMessage()); + + try { + // Exponential backoff + long delay = BASE_RETRY_DELAY_MS * (1L << (attempt - 1)); + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("Download interrupted", ie); + } + } else { + throw lastException; + } + } + } + + throw lastException; + } + + private static boolean isRetryableException(IOException e) { + if (e instanceof CodePushNetworkException) { + CodePushNetworkException ne = (CodePushNetworkException) e; + int statusCode = ne.getHttpStatusCode(); + if (statusCode > 0) { + return statusCode >= 500 || statusCode == 408 || statusCode == 429; + } + } + + return isRetryableError(e, 0); + } + + private static boolean isRetryableError(Throwable e, int depth) { + // Prevent stack overflow in recursive calls + if (e == null || depth > 10) { + return false; + } + + if (e instanceof SocketTimeoutException || + e instanceof ConnectException || + e instanceof UnknownHostException || + e instanceof SocketException || + e instanceof EOFException) { + return true; + } + + return isRetryableError(e.getCause(), depth + 1); + } + + public static void checkHttpResponse(HttpURLConnection connection) throws IOException { + int responseCode = connection.getResponseCode(); + if (responseCode >= 400) { + throw new CodePushNetworkException("HTTP error during download", responseCode); + } + } +} diff --git a/test/template/scenarios/scenarioRetryTransientFailure.js b/test/template/scenarios/scenarioRetryTransientFailure.js new file mode 100644 index 000000000..e8795e8d1 --- /dev/null +++ b/test/template/scenarios/scenarioRetryTransientFailure.js @@ -0,0 +1,35 @@ +var CodePushWrapper = require("../codePushWrapper.js"); + +module.exports = { + startTest: function (testApp) { + CodePushWrapper.checkForUpdate(testApp, + function(remotePackage) { + if (remotePackage) { + testApp.testMessage("Starting retry behavior test"); + + // The download URL will be set to trigger retries (unreachable host, HTTP 500, etc.) + // RetryHelper will log retry attempts before eventually failing + CodePushWrapper.download(testApp, + function() { + testApp.testMessage("Unexpected download success"); + }, + function(error) { + // Expected outcome after RetryHelper exhausts all retry attempts + testApp.testMessage("Download failed after retry attempts: " + error.message); + }, + remotePackage + ); + } else { + testApp.testMessage("No update available for retry test"); + } + }, + function(error) { + testApp.testMessage("Check for update failed: " + error.message); + } + ); + }, + + getScenarioName: function () { + return "Retry Behavior Test"; + } +}; diff --git a/test/test.ts b/test/test.ts index ca4f70d4c..0fb0d4d37 100644 --- a/test/test.ts +++ b/test/test.ts @@ -535,6 +535,7 @@ const ScenarioSyncMandatoryDefault = "scenarioSyncMandatoryDefault.js"; const ScenarioSyncMandatoryResume = "scenarioSyncMandatoryResume.js"; const ScenarioSyncMandatoryRestart = "scenarioSyncMandatoryRestart.js"; const ScenarioSyncMandatorySuspend = "scenarioSyncMandatorySuspend.js"; +const ScenarioRetryTransientFailure = "scenarioRetryTransientFailure.js"; const UpdateDeviceReady = "updateDeviceReady.js"; const UpdateNotifyApplicationReady = "updateNotifyApplicationReady.js"; @@ -1632,4 +1633,22 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl .done(() => { done(); }, (e) => { done(e); }); }); }); + + TestBuilder.describe("#RetryHelper", + () => { + TestBuilder.it("retries network failures and logs retry attempts", true, + (done: Mocha.Done) => { + ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; + + /* Use an unreachable host to trigger network timeouts that will be retried */ + ServerUtil.updateResponse.update_info.download_url = "http://unreachable-host-for-retry-test.invalid/update.zip"; + + projectManager.runApplication(TestConfig.testRunDirectory, targetPlatform); + + ServerUtil.expectTestMessages([ + ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, + ServerUtil.TestMessage.DOWNLOAD_ERROR]) + .then(() => { done(); }, (e) => { done(e); }); + }); + }, ScenarioRetryTransientFailure); }); From a71077e20448af66b01cfbdc1a1e4a4a79c2c600 Mon Sep 17 00:00:00 2001 From: elio Date: Sun, 28 Sep 2025 21:52:26 +0900 Subject: [PATCH 2/2] fix retry test case --- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/build.gradle | 5 +-- .../gradle/wrapper/gradle-wrapper.properties | 3 +- .../script/serverUtil.js | 8 ++++ .../code-push-plugin-testing-framework.d.ts | 2 + package-lock.json | 4 +- .../scenarioRetryTransientFailure.js | 9 +---- test/test.ts | 38 ++++++++++--------- 8 files changed, 39 insertions(+), 32 deletions(-) diff --git a/Examples/CodePushDemoAppNewArch/android/gradle/wrapper/gradle-wrapper.properties b/Examples/CodePushDemoAppNewArch/android/gradle/wrapper/gradle-wrapper.properties index 79eb9d003..c1d5e0185 100644 --- a/Examples/CodePushDemoAppNewArch/android/gradle/wrapper/gradle-wrapper.properties +++ b/Examples/CodePushDemoAppNewArch/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/build.gradle b/android/build.gradle index 31a524838..aa420c978 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:1.3.0' + classpath 'com.android.tools.build:gradle:8.11.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -14,9 +14,6 @@ buildscript { } allprojects { - android { - namespace "com.microsoft.codepush.react" - } repositories { mavenLocal() mavenCentral() diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index b9fbfaba0..3e3b65f58 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Sun Aug 24 14:51:00 KST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.4-all.zip diff --git a/code-push-plugin-testing-framework/script/serverUtil.js b/code-push-plugin-testing-framework/script/serverUtil.js index 80503628d..caefe496a 100644 --- a/code-push-plugin-testing-framework/script/serverUtil.js +++ b/code-push-plugin-testing-framework/script/serverUtil.js @@ -28,6 +28,11 @@ function setupServer(targetPlatform) { console.log("Response: " + JSON.stringify(exports.updateResponse)); }); app.get("/v0.1/public/codepush/report_status/download", function (req, res) { + if (exports.mockDownloadFailureCount > 0) { + exports.mockDownloadFailureCount--; + res.status(500).send("Mock download failure"); + return; + } console.log("Application downloading the package."); res.download(exports.updatePackagePath); }); @@ -54,10 +59,13 @@ exports.setupServer = setupServer; function cleanupServer() { if (exports.server) { exports.server.close(); + exports.mockDownloadFailureCount = 0; exports.server = undefined; } } exports.cleanupServer = cleanupServer; + +exports.mockDownloadFailureCount = 0; ////////////////////////////////////////////////////////////////////////////////////////// // Classes and methods used for sending mock responses to the app. /** diff --git a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts index 298c3b487..a2fc51c6c 100644 --- a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts +++ b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts @@ -274,6 +274,8 @@ declare module 'code-push-plugin-testing-framework/script/serverUtil' { export var server: any; /** Response the server gives the next update check request */ export var updateResponse: any; + /** Number of times to mock a download failure */ + export var mockDownloadFailureCount: number; /** Response the server gives the next test message request */ export var testMessageResponse: any; /** Called after the next test message request */ diff --git a/package-lock.json b/package-lock.json index 69e9b60f7..5a5e91f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@code-push-next/react-native-code-push", - "version": "10.0.1", + "version": "10.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@code-push-next/react-native-code-push", - "version": "10.0.1", + "version": "10.2.0", "license": "MIT", "dependencies": { "code-push": "4.2.3", diff --git a/test/template/scenarios/scenarioRetryTransientFailure.js b/test/template/scenarios/scenarioRetryTransientFailure.js index e8795e8d1..849ae07ae 100644 --- a/test/template/scenarios/scenarioRetryTransientFailure.js +++ b/test/template/scenarios/scenarioRetryTransientFailure.js @@ -5,26 +5,21 @@ module.exports = { CodePushWrapper.checkForUpdate(testApp, function(remotePackage) { if (remotePackage) { - testApp.testMessage("Starting retry behavior test"); - // The download URL will be set to trigger retries (unreachable host, HTTP 500, etc.) // RetryHelper will log retry attempts before eventually failing CodePushWrapper.download(testApp, function() { - testApp.testMessage("Unexpected download success"); }, function(error) { - // Expected outcome after RetryHelper exhausts all retry attempts - testApp.testMessage("Download failed after retry attempts: " + error.message); }, remotePackage ); } else { - testApp.testMessage("No update available for retry test"); + testApp.sendTestMessage("No update available for retry test"); } }, function(error) { - testApp.testMessage("Check for update failed: " + error.message); + testApp.sendTestMessage("Check for update failed: " + error.message); } ); }, diff --git a/test/test.ts b/test/test.ts index 0fb0d4d37..a6fd9a494 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1634,21 +1634,25 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl }); }); - TestBuilder.describe("#RetryHelper", - () => { - TestBuilder.it("retries network failures and logs retry attempts", true, - (done: Mocha.Done) => { - ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; - - /* Use an unreachable host to trigger network timeouts that will be retried */ - ServerUtil.updateResponse.update_info.download_url = "http://unreachable-host-for-retry-test.invalid/update.zip"; - - projectManager.runApplication(TestConfig.testRunDirectory, targetPlatform); - - ServerUtil.expectTestMessages([ - ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, - ServerUtil.TestMessage.DOWNLOAD_ERROR]) - .then(() => { done(); }, (e) => { done(e); }); - }); - }, ScenarioRetryTransientFailure); + TestBuilder.describe("#RetryHelper", + () => { + TestBuilder.it("retries network failures and logs retry attempts", true, + (done: Mocha.Done) => { + ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; + ServerUtil.mockDownloadFailureCount = 2; + + setupUpdateScenario(projectManager, targetPlatform, UpdateDeviceReady, "Update 1") + .then((updatePath: string) => { + ServerUtil.updatePackagePath = updatePath; + projectManager.runApplication(TestConfig.testRunDirectory, targetPlatform); + return ServerUtil.expectTestMessages([ + ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, + // As download failure exception within retrials is consumed in native side, + // we don't have any way to catch it on app side + ServerUtil.TestMessage.DOWNLOAD_SUCCEEDED, + ]); + }) + .done(() => { done(); }, (e) => { done(e); }); + }); + }, ScenarioRetryTransientFailure); });