Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
@@ -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
Copy link
Copy Markdown

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);
}
}
}
35 changes: 35 additions & 0 deletions test/template/scenarios/scenarioRetryTransientFailure.js
Original file line number Diff line number Diff line change
@@ -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";
}
};
19 changes: 19 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,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);
});