launcher: fix CDN Connection Reset — geo-proxy fallback + atomic downloads, v1.1.0.0

- downloadFileWithSmartProxy/getWithSmartProxy now rotate the proxy request
  across api.zern.cc -> api.zernmc.ru -> api.zernmc.online -> api.pl.zern.cc
  -> api.swe.zern.cc instead of retrying the same single proxy base, so a
  region-specific CDN connection reset no longer burns every retry.
- DomainSelector registers every candidate/mirror domain as a fallback proxy
  base at startup; mirrors list stays in sync with LAUNCHER_MIRRORS.
- Downloads write to <target>.part and are moved into place atomically on
  success, so an interrupted transfer can no longer leave a corrupt partial
  file that later 'file exists' checks treat as installed.
- Bump version to 1.1.0.0 (revision 1.1.0, hotfix 0).
This commit is contained in:
SashegDev
2026-08-20 10:07:37 +00:00
parent 60d5094b5a
commit 0ae1db3582
3 changed files with 107 additions and 8 deletions
@@ -60,6 +60,13 @@ public final class DomainSelector {
}
}
if (best != null) {
// Make every candidate (incl. geo mirrors) available as a fallback proxy base.
for (String c : candidates) {
ZHttpClient.registerProxyBase(c);
}
}
if (best == null) {
// nothing reachable: keep whatever is configured
System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain"));
@@ -51,6 +51,24 @@ public class ZHttpClient {
private static String BASE_URL = "https://api.zern.cc";
/**
* Alternate proxy endpoints (geo mirrors) used when BASE_URL's proxy fails with
* a connection error (e.g. CDN "Connection reset" from the current region).
* Stay in sync with LAUNCHER_MIRRORS in server/main.py.
*/
private static final List<String> GEO_PROXY_BASES = List.of(
"https://api.zernmc.ru",
"https://api.zernmc.online",
"https://api.pl.zern.cc",
"https://api.swe.zern.cc"
);
/**
* Extra proxy bases discovered at runtime (e.g. mirrors fetched from
* /launcher/mirrors by DomainSelector). Kept in insertion order, deduplicated.
*/
private static final List<String> extraProxyBases = new ArrayList<>();
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
private static final AtomicBoolean healthThreadStarted = new AtomicBoolean(false);
@@ -59,6 +77,38 @@ public class ZHttpClient {
BASE_URL = url;
}
/**
* Registers an additional proxy base URL (mirror/geo domain) that the smart
* proxy fallback may rotate through. No-op if already present.
*/
public static void registerProxyBase(String url) {
if (url == null || url.isBlank()) return;
String normalized = url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
synchronized (extraProxyBases) {
if (!extraProxyBases.contains(normalized)) {
extraProxyBases.add(normalized);
}
}
}
/**
* Ordered, deduplicated list of proxy endpoints to attempt: the primary
* BASE_URL first, then geo mirrors and any runtime-discovered mirrors.
*/
private static List<String> getProxyBases() {
List<String> bases = new ArrayList<>();
bases.add(BASE_URL);
for (String geo : GEO_PROXY_BASES) {
if (!bases.contains(geo)) bases.add(geo);
}
synchronized (extraProxyBases) {
for (String m : extraProxyBases) {
if (!bases.contains(m)) bases.add(m);
}
}
return bases;
}
public static String getBaseUrl() {
return BASE_URL;
}
@@ -418,11 +468,15 @@ public class ZHttpClient {
}
}
List<String> proxyBases = getProxyBases();
int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
// Rotate through every proxy base so one region's Connection Reset does not
// burn all retries on the same endpoint.
String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size());
try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
@@ -442,8 +496,10 @@ public class ZHttpClient {
} catch (Exception e) {
if (attempt == maxRetries || !isRetryableError(e)) {
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
throw new IOException("Failed to fetch data directly or via proxy (" + proxyBase + "): " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Proxy fetch attempt " + attempt + "/" + maxRetries
+ " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base");
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
}
}
@@ -452,6 +508,11 @@ public class ZHttpClient {
}
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
Path tmp = target.resolveSibling(target.getFileName() + ".part");
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
if (!shouldUseProxyForUrl(url)) {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
@@ -469,12 +530,14 @@ public class ZHttpClient {
HttpRequest request = requestBuilder.build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 45);
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 45);
if (response.statusCode() == 200) {
moveIntoPlace(tmp, target);
directSuccessCount++;
return;
}
// non-200 response: fall through to proxy below
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
@@ -486,11 +549,19 @@ public class ZHttpClient {
}
}
List<String> proxyBases = getProxyBases();
int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
// Rotate through every proxy base so one region's Connection Reset does not
// burn all retries on the same endpoint.
String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size());
try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl;
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
@@ -499,24 +570,45 @@ public class ZHttpClient {
.GET()
.build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 300);
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 300);
if (response.statusCode() != 200) {
throw new IOException("Proxy download failed: HTTP " + response.statusCode());
}
moveIntoPlace(tmp, target);
proxySuccessCount++;
return;
} catch (Exception e) {
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
if (attempt == maxRetries || !isRetryableError(e)) {
throw new IOException("Proxy download failed: " + e.getMessage(), e);
throw new IOException("Proxy download failed (" + proxyBase + "): " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Proxy download attempt " + attempt + "/" + maxRetries
+ " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base");
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
}
}
}
/**
* Atomically replaces the destination with the fully-downloaded temp file.
* Prevents a Connection Reset mid-transfer from leaving a corrupt partial
* file at the final path that later "file exists" checks would treat as installed.
*/
private static void moveIntoPlace(Path tmp, Path target) throws IOException {
Files.createDirectories(target.getParent());
try {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING,
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
} catch (java.nio.file.AtomicMoveNotSupportedException e) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
/**
* Retries a request against the Zern server. The server proxies are slow on cold cache
* and may return transient 502/5xx when upstreams are flaky, so a single failure should
+2 -2
View File
@@ -19,8 +19,8 @@
</modules>
<properties>
<revision>1.0.16</revision>
<hotfix>21</hotfix>
<revision>1.1.0</revision>
<hotfix>0</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>