fix: harden official resource sync retries

This commit is contained in:
2026-07-07 01:14:54 +08:00
parent e0d43fb994
commit b2d6871926
14 changed files with 333 additions and 72 deletions
+151 -44
View File
@@ -8,7 +8,7 @@
use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG};
use md5::{Digest, Md5};
use serde::Deserialize;
use std::process::Command;
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
/// Official JP launcher API root.
@@ -23,6 +23,8 @@ pub const YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST: &str = "launcher-pkg-ba-jp.yo-sta
/// Official backup JP PC launcher package CDN host.
pub const YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST: &str = "launcher-pkg-ba-jp-bk.yo-star.com";
const DEFAULT_LAUNCHER_RETRY_ATTEMPTS: usize = 3;
/// Latest official PC client metadata returned by `/api/launcher/game/config`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherGameConfig {
@@ -77,6 +79,7 @@ pub struct YostarJpLauncherRemoteManifest {
pub struct OfficialLauncherBootstrapService {
curl_command: String,
launcher_version: String,
retry_attempts: usize,
}
impl OfficialLauncherBootstrapService {
@@ -93,9 +96,16 @@ impl OfficialLauncherBootstrapService {
Self {
curl_command: curl_command.into(),
launcher_version: launcher_version.into(),
retry_attempts: DEFAULT_LAUNCHER_RETRY_ATTEMPTS,
}
}
/// Sets the number of attempts for each launcher `curl` transfer.
pub fn with_retry_attempts(mut self, retry_attempts: usize) -> Self {
self.retry_attempts = retry_attempts.max(1);
self
}
/// Fetches latest official PC game config.
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?;
@@ -161,28 +171,26 @@ impl OfficialLauncherBootstrapService {
));
}
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(manifest_url)
.output()
.map_err(|error| {
let output = self.run_curl_with_retry(
|| {
let mut command = Command::new(&self.curl_command);
command
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(manifest_url);
command
},
|output| {
let stderr = String::from_utf8_lossy(&output.stderr);
format!(
"Failed to launch curl command {}: {error}",
self.curl_command
"curl failed for launcher manifest {manifest_url}: {}",
stderr.trim()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"curl failed for launcher manifest {manifest_url}: {}",
stderr.trim()
));
}
},
)?;
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?;
@@ -220,29 +228,27 @@ impl OfficialLauncherBootstrapService {
let url = launcher_api_url(path)?;
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--header")
.arg("Content-Type: application/json;charset=UTF-8")
.arg("--header")
.arg(format!("Authorization: {authorization}"))
.arg("--url")
.arg(&url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
}
let output = self.run_curl_with_retry(
|| {
let mut command = Command::new(&self.curl_command);
command
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--header")
.arg("Content-Type: application/json;charset=UTF-8")
.arg("--header")
.arg(format!("Authorization: {authorization}"))
.arg("--url")
.arg(&url);
command
},
|output| {
let stderr = String::from_utf8_lossy(&output.stderr);
format!("curl failed for {url}: {}", stderr.trim())
},
)?;
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher API response: {error}"))?;
@@ -261,6 +267,34 @@ impl OfficialLauncherBootstrapService {
Ok(envelope)
}
fn run_curl_with_retry(
&self,
mut build_command: impl FnMut() -> Command,
failure_message: impl Fn(&Output) -> String,
) -> Result<Output, String> {
let attempts = self.retry_attempts.max(1);
let mut last_error = None;
for attempt in 1..=attempts {
match build_command().output() {
Ok(output) if output.status.success() => return Ok(output),
Ok(output) => {
last_error = Some(format!(
"attempt {attempt}/{attempts}: {}",
failure_message(&output)
));
}
Err(error) => {
last_error = Some(format!(
"attempt {attempt}/{attempts}: Failed to launch curl command {}: {error}",
self.curl_command
));
}
}
}
Err(last_error.unwrap_or_else(|| format!("curl command {} did not run", self.curl_command)))
}
}
/// Generic official launcher API response envelope.
@@ -394,6 +428,58 @@ pub fn launcher_authorization_header(
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn write_shell_script(path: &Path, script: &str) {
fs::write(path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
}
fn fake_flaky_launcher_curl_script() -> &'static str {
r#"#!/bin/sh
set -eu
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
--url)
url="$2"
shift 2
;;
*)
shift
;;
esac
done
state="$0.state"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
printf '%s' "$count" > "$state"
if [ "$count" -eq 1 ]; then
printf '%s\n' "temporary launcher failure" >&2
exit 35
fi
case "$url" in
*/api/launcher/game/config)
printf '%s' '{"code":200,"data":{"game_latest_version":"1.70.436321","game_latest_file_path":"prod/manifest/BlueArchive_JP_TEMP","game_lowest_version":"1.69.0","game_start_params":[]}}'
;;
*)
printf '%s\n' "unexpected url: $url" >&2
exit 22
;;
esac
"#
}
#[test]
fn builds_official_launcher_api_url() {
@@ -510,4 +596,25 @@ mod tests {
assert_eq!(manifest.files.len(), 1);
assert_eq!(manifest.files[0].path, "/BlueArchive.exe");
}
#[test]
fn retries_transient_launcher_api_failures() {
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_shell_script(&curl_path, fake_flaky_launcher_curl_script());
let service = OfficialLauncherBootstrapService::with_curl_command(
"1.7.2",
curl_path.to_string_lossy().to_string(),
)
.with_retry_attempts(2);
let config = service.fetch_game_config().unwrap();
assert_eq!(config.game_latest_version, "1.70.436321");
assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
"2"
);
}
}