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
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Comment on lines +381 to +382

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason to set timeout as 30s and 60s? It seems to be too long for me 🤔


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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
5 changes: 1 addition & 4 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@ 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
}
}

allprojects {
android {
namespace "com.microsoft.codepush.react"
}
repositories {
mavenLocal()
mavenCentral()
Expand Down
3 changes: 2 additions & 1 deletion android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions code-push-plugin-testing-framework/script/serverUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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.
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
30 changes: 30 additions & 0 deletions test/template/scenarios/scenarioRetryTransientFailure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
var CodePushWrapper = require("../codePushWrapper.js");

module.exports = {
startTest: function (testApp) {
CodePushWrapper.checkForUpdate(testApp,
function(remotePackage) {
if (remotePackage) {
// 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() {
},
function(error) {
},
remotePackage
);
} else {
testApp.sendTestMessage("No update available for retry test");
}
},
function(error) {
testApp.sendTestMessage("Check for update failed: " + error.message);
}
);
},

getScenarioName: function () {
return "Retry Behavior Test";
}
};
23 changes: 23 additions & 0 deletions test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1632,4 +1633,26 @@ 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) };
ServerUtil.mockDownloadFailureCount = 2;

setupUpdateScenario(projectManager, targetPlatform, UpdateDeviceReady, "Update 1")
.then<void>((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);
});