From 208d54efa19dc2543d4b5ddad19af8986ad8d6f0 Mon Sep 17 00:00:00 2001 From: fnox Date: Sat, 15 Aug 2026 13:16:46 +0200 Subject: [PATCH] Retry transient upload failures instead of aborting the whole sync Roblox's edge proxy intermittently rejects valid upload requests with an HTML 400 page ("Your browser sent an invalid request") before they reach the Assets API. Previously any non-200/non-429 response set the global fatally_failed flag, which aborted every other in-flight upload and operation poll - discarding uploads that had already succeeded server-side and leaving their asset ids out of the lockfile (orphaning the assets). Now: - Connection errors, 5xx responses, and proxy-level HTML 400s retry with exponential backoff (up to the existing 5-attempt cap). - Only 401/403 set the global fatal flag, since a bad or underprivileged API key genuinely dooms every request. - Other failures (e.g. real 400s from the API) fail only their own asset, letting the rest of the sync finish and persist. Co-Authored-By: Claude Fable 5 --- src/web_api.rs | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/web_api.rs b/src/web_api.rs index 71c92e1..674da87 100644 --- a/src/web_api.rs +++ b/src/web_api.rs @@ -160,7 +160,20 @@ impl WebApiClient { } } - let res = make_req(&self.inner).send().await?; + let res = match make_req(&self.inner).send().await { + Ok(res) => res, + Err(err) if attempt < MAX => { + let wait = Duration::from_secs(1 << attempt); + warn!( + "Request failed to send ({err}), retrying in {:.2} seconds", + wait.as_secs_f64() + ); + tokio::time::sleep(wait).await; + attempt += 1; + continue; + } + Err(err) => return Err(err.into()), + }; let status = res.status(); match status { @@ -190,11 +203,33 @@ impl WebApiClient { continue; } StatusCode::OK => return Ok(res), - _ => { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { let body = res.text().await?; self.fatally_failed.store(true, Ordering::SeqCst); bail!("Request failed with status {status}:\n{body}"); } + _ => { + let body = res.text().await?; + + // Roblox's edge proxy intermittently rejects valid requests with an HTML + // 400 page before they reach the API, so those are retryable alongside 5xx. + let transient = status.is_server_error() + || (status == StatusCode::BAD_REQUEST + && body.trim_start().starts_with('<')); + + if transient && attempt < MAX { + let wait = Duration::from_secs(1 << attempt); + warn!( + "Request failed with status {status}, retrying in {:.2} seconds", + wait.as_secs_f64() + ); + tokio::time::sleep(wait).await; + attempt += 1; + continue; + } + + bail!("Request failed with status {status}:\n{body}"); + } } } }