From 63df8e7866ece62bb157c33187f2c388e8b9db0b Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 14 Jan 2026 18:28:06 +0100 Subject: [PATCH 1/2] fix(api): Retry API requests on DNS resolution failure When DNS resolution fails (CURLE_COULDNT_RESOLVE_HOST), the CLI now retries the request using the existing exponential backoff retry mechanism. This addresses intermittent DNS failures that were causing ~5-10% of builds to fail. Fixes #2763 Co-Authored-By: Claude Opus 4.5 --- src/api/errors/mod.rs | 17 +++++++++++++++++ src/api/mod.rs | 33 +++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/api/errors/mod.rs b/src/api/errors/mod.rs index 230a920ff7..d91deb5c3a 100644 --- a/src/api/errors/mod.rs +++ b/src/api/errors/mod.rs @@ -28,3 +28,20 @@ impl RetryError { self.body } } + +#[derive(Debug, thiserror::Error)] +#[error("request failed with retryable curl error: {source}")] +pub(super) struct RetryableCurlError { + #[source] + source: curl::Error, +} + +impl RetryableCurlError { + pub fn new(source: curl::Error) -> Self { + Self { source } + } + + pub fn into_source(self) -> curl::Error { + self.source + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 7dce776cae..7ef8cd2a3a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -15,6 +15,7 @@ mod serialization; use std::borrow::Cow; use std::cell::RefCell; use std::collections::HashMap; +use std::error::Error as _; #[cfg(any(target_os = "macos", not(feature = "managed")))] use std::fs::File; use std::io::{self, Read as _, Write}; @@ -41,7 +42,7 @@ use symbolic::common::DebugId; use symbolic::debuginfo::ObjectKind; use uuid::Uuid; -use crate::api::errors::{ProjectRenamedError, RetryError}; +use crate::api::errors::{ProjectRenamedError, RetryError, RetryableCurlError}; use crate::config::{Auth, Config}; use crate::constants::{ARCH, EXT, PLATFORM, RELEASE_REGISTRY_LATEST_URL, VERSION}; use crate::utils::http::{self, is_absolute_url}; @@ -1313,7 +1314,20 @@ impl ApiRequest { debug!("retry number {retry_number}, max retries: {max_retries}"); *retry_number += 1; - let mut rv = self.send_into(&mut out)?; + let result = self.send_into(&mut out); + + // Check for retriable curl errors (DNS resolution failure) + if let Some(curl_err) = result + .as_ref() + .err() + .and_then(|e| e.source()) + .and_then(|s| s.downcast_ref::()) + .filter(|e| e.is_couldnt_resolve_host()) + { + anyhow::bail!(RetryableCurlError::new(curl_err.clone())); + } + + let mut rv = result?; rv.body = Some(out); if RETRY_STATUS_CODES.contains(&rv.status) { @@ -1326,7 +1340,7 @@ impl ApiRequest { send_req .retry(backoff) .sleep(thread::sleep) - .when(|e| e.is::()) + .when(|e| e.is::() || e.is::()) .notify(|e, dur| { debug!( "retry number {} failed due to {e:#}, retrying again in {} ms", @@ -1335,9 +1349,16 @@ impl ApiRequest { ); }) .call() - .or_else(|err| match err.downcast::() { - Ok(err) => Ok(err.into_body()), - Err(err) => Err(ApiError::with_source(ApiErrorKind::RequestFailed, err)), + .or_else(|err| { + err.downcast::() + .map(RetryError::into_body) + .map_err(|err| { + err.downcast::() + .map(|e| ApiError::from(e.into_source())) + .unwrap_or_else(|e| { + ApiError::with_source(ApiErrorKind::RequestFailed, e) + }) + }) }) } } From 64b87e658f45a9213a9653490355923a9c70790b Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 28 Jan 2026 18:29:59 +0100 Subject: [PATCH 2/2] fix(api): Merge retryable curl errors into RetryError --- src/api/errors/mod.rs | 38 +++++++-------------------------- src/api/mod.rs | 49 ++++++++++++++++++++----------------------- 2 files changed, 31 insertions(+), 56 deletions(-) diff --git a/src/api/errors/mod.rs b/src/api/errors/mod.rs index d91deb5c3a..1b7be0adae 100644 --- a/src/api/errors/mod.rs +++ b/src/api/errors/mod.rs @@ -14,34 +14,12 @@ pub(super) struct ProjectRenamedError(pub(super) String); pub(super) type ApiResult = Result; #[derive(Debug, thiserror::Error)] -#[error("request failed with retryable status code {}", .body.status)] -pub(super) struct RetryError { - body: ApiResponse, -} - -impl RetryError { - pub fn new(body: ApiResponse) -> Self { - Self { body } - } - - pub fn into_body(self) -> ApiResponse { - self.body - } -} - -#[derive(Debug, thiserror::Error)] -#[error("request failed with retryable curl error: {source}")] -pub(super) struct RetryableCurlError { - #[source] - source: curl::Error, -} - -impl RetryableCurlError { - pub fn new(source: curl::Error) -> Self { - Self { source } - } - - pub fn into_source(self) -> curl::Error { - self.source - } +pub(super) enum RetryError { + #[error("request failed with retryable status code {}", body.status)] + Status { body: ApiResponse }, + #[error("request failed with retryable error: {source}")] + ApiError { + #[from] + source: ApiError, + }, } diff --git a/src/api/mod.rs b/src/api/mod.rs index 7ef8cd2a3a..09165f28e3 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -42,7 +42,7 @@ use symbolic::common::DebugId; use symbolic::debuginfo::ObjectKind; use uuid::Uuid; -use crate::api::errors::{ProjectRenamedError, RetryError, RetryableCurlError}; +use crate::api::errors::{ProjectRenamedError, RetryError}; use crate::config::{Auth, Config}; use crate::constants::{ARCH, EXT, PLATFORM, RELEASE_REGISTRY_LATEST_URL, VERSION}; use crate::utils::http::{self, is_absolute_url}; @@ -1314,24 +1314,27 @@ impl ApiRequest { debug!("retry number {retry_number}, max retries: {max_retries}"); *retry_number += 1; - let result = self.send_into(&mut out); - - // Check for retriable curl errors (DNS resolution failure) - if let Some(curl_err) = result - .as_ref() - .err() - .and_then(|e| e.source()) - .and_then(|s| s.downcast_ref::()) - .filter(|e| e.is_couldnt_resolve_host()) - { - anyhow::bail!(RetryableCurlError::new(curl_err.clone())); - } + let mut rv = match self.send_into(&mut out) { + Ok(rv) => rv, + Err(err) => { + let is_retryable_dns = err + .source() + .and_then(|s| s.downcast_ref::()) + .is_some_and(|e| e.is_couldnt_resolve_host()); + + // Wrap DNS errors in a RetryError so they get retried + if is_retryable_dns { + anyhow::bail!(RetryError::from(err)); + } + + anyhow::bail!(err); + } + }; - let mut rv = result?; rv.body = Some(out); if RETRY_STATUS_CODES.contains(&rv.status) { - anyhow::bail!(RetryError::new(rv)); + anyhow::bail!(RetryError::Status { body: rv }); } Ok(rv) @@ -1340,7 +1343,7 @@ impl ApiRequest { send_req .retry(backoff) .sleep(thread::sleep) - .when(|e| e.is::() || e.is::()) + .when(|e| e.is::()) .notify(|e, dur| { debug!( "retry number {} failed due to {e:#}, retrying again in {} ms", @@ -1349,16 +1352,10 @@ impl ApiRequest { ); }) .call() - .or_else(|err| { - err.downcast::() - .map(RetryError::into_body) - .map_err(|err| { - err.downcast::() - .map(|e| ApiError::from(e.into_source())) - .unwrap_or_else(|e| { - ApiError::with_source(ApiErrorKind::RequestFailed, e) - }) - }) + .or_else(|err| match err.downcast::() { + Ok(RetryError::Status { body }) => Ok(body), + Ok(RetryError::ApiError { source }) => Err(source), + Err(err) => Err(ApiError::with_source(ApiErrorKind::RequestFailed, err)), }) } }