From 6b4ff191dc7eca8da22c23aeda6189ee2d30a02e Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 14 Jan 2026 18:28:06 +0100 Subject: [PATCH 1/5] 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 a1e2732ec234e05e940fc610208842ee217f2e99 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 28 Jan 2026 18:29:59 +0100 Subject: [PATCH 2/5] 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)), }) } } From 575c5806253b89a8227aba817351736ac77b29eb Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Fri, 30 Jan 2026 16:35:21 +0100 Subject: [PATCH 3/5] fix(api): Retry DNS failures only for sentry.io --- src/api/mod.rs | 57 +++++++++++++++++++++++++++++++++++++----------- src/constants.rs | 3 +++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 09165f28e3..d850efeaf4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -20,7 +20,7 @@ use std::error::Error as _; use std::fs::File; use std::io::{self, Read as _, Write}; use std::rc::Rc; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::{fmt, thread}; use anyhow::{Context as _, Result}; @@ -40,11 +40,12 @@ use serde::{Deserialize, Serialize}; use sha1_smol::Digest; use symbolic::common::DebugId; use symbolic::debuginfo::ObjectKind; +use url::Url; use uuid::Uuid; use crate::api::errors::{ProjectRenamedError, RetryError}; use crate::config::{Auth, Config}; -use crate::constants::{ARCH, EXT, PLATFORM, RELEASE_REGISTRY_LATEST_URL, VERSION}; +use crate::constants::{ARCH, DEFAULT_HOST, EXT, PLATFORM, RELEASE_REGISTRY_LATEST_URL, VERSION}; use crate::utils::http::{self, is_absolute_url}; use crate::utils::non_empty::NonEmptySlice; use crate::utils::progress::{ProgressBar, ProgressBarMode}; @@ -112,6 +113,7 @@ pub struct ApiRequest { is_authenticated: bool, body: Option>, progress_bar_mode: ProgressBarMode, + url: String, } /// Represents an API response. @@ -181,7 +183,7 @@ impl Api { region_url: Option<&str>, ) -> ApiResult { let (url, auth) = self.resolve_base_url_and_auth(url, region_url)?; - self.construct_api_request(method, &url, auth) + self.construct_api_request(method, url, auth) } fn resolve_base_url_and_auth( @@ -211,7 +213,7 @@ impl Api { fn construct_api_request( &self, method: Method, - url: &str, + url: String, auth: Option<&Auth>, ) -> ApiResult { let mut handle = self @@ -1162,7 +1164,7 @@ impl ApiRequest { fn create( mut handle: r2d2::PooledConnection, method: &Method, - url: &str, + url: String, auth: Option<&Auth>, pipeline_env: Option, global_headers: Option>, @@ -1197,7 +1199,7 @@ impl ApiRequest { Method::Delete => handle.custom_request("DELETE")?, } - handle.url(url)?; + handle.url(&url)?; let request = ApiRequest { handle, @@ -1205,6 +1207,7 @@ impl ApiRequest { is_authenticated: false, body: None, progress_bar_mode: ProgressBarMode::Disabled, + url, }; let request = match auth { @@ -1317,13 +1320,11 @@ impl ApiRequest { 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 { + // Retry DNS failures for sentry.io, as these likely indicate + // a network issue. DNS failures for other domains should not + // be retried, to avoid masking configuration problems (e.g. + // if the user has mistyped their self-hosted URL). + if is_dns_error(&err) && self.is_sentry_io_host() { anyhow::bail!(RetryError::from(err)); } @@ -1358,6 +1359,36 @@ impl ApiRequest { Err(err) => Err(ApiError::with_source(ApiErrorKind::RequestFailed, err)), }) } + + /// Determines whether a URL has a sentry.io host (including subdomains). + fn is_sentry_io_host(&self) -> bool { + /// A regex which matches exactly "sentry.io" and hostnames ending in + /// ".sentry.io". + static SENTRY_IO_HOST_RE: LazyLock = LazyLock::new(|| { + Regex::new(&format!(r"^(\S*\.)?{}$", regex::escape(DEFAULT_HOST))) + .expect("regex is valid") + }); + + Url::parse(&self.url) + .ok() + .map(|url| { + url.host_str() + .is_some_and(|host| SENTRY_IO_HOST_RE.is_match(host)) + }) + .unwrap_or(false) + } +} + +/// Returns true if the error source chain contains a curl DNS resolution error. +fn is_dns_error(err: &ApiError) -> bool { + let mut current = err.source(); + while let Some(error) = current { + if let Some(curl_err) = error.downcast_ref::() { + return curl_err.is_couldnt_resolve_host(); + } + current = error.source(); + } + false } impl ApiResponse { diff --git a/src/constants.rs b/src/constants.rs index 9d8f52ada9..7acee0849b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -8,6 +8,9 @@ pub const APP_NAME: &str = "sentrycli"; /// The default API URL pub const DEFAULT_URL: &str = "https://sentry.io/"; +/// The default API host +pub const DEFAULT_HOST: &str = "sentry.io"; + /// The version of the library pub const VERSION: &str = env!("CARGO_PKG_VERSION"); From 9a41b72bb454d45e630d40de6aaa73a259cb9079 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Fri, 30 Jan 2026 16:38:21 +0100 Subject: [PATCH 4/5] docs(changelog): Add DNS retry entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb88997e6b..95e753ec2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixes - Fixed a bug where the `dart-symbol-map` command did not accept the `--url` argument ([#3108](https://github.com/getsentry/sentry-cli/pull/3108)). +- Retry DNS resolution failures for `sentry.io` requests to reduce intermittent failures for some users ([#3085](https://github.com/getsentry/sentry-cli/pull/3085)) ## 3.1.0 From 99c690cb122008a340e5daeb4b133bcbc0b00dc8 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Fri, 30 Jan 2026 16:42:39 +0100 Subject: [PATCH 5/5] map_err --- src/api/mod.rs | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index d850efeaf4..fa60a59607 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1317,20 +1317,17 @@ impl ApiRequest { debug!("retry number {retry_number}, max retries: {max_retries}"); *retry_number += 1; - let mut rv = match self.send_into(&mut out) { - Ok(rv) => rv, - Err(err) => { - // Retry DNS failures for sentry.io, as these likely indicate - // a network issue. DNS failures for other domains should not - // be retried, to avoid masking configuration problems (e.g. - // if the user has mistyped their self-hosted URL). - if is_dns_error(&err) && self.is_sentry_io_host() { - anyhow::bail!(RetryError::from(err)); - } - - anyhow::bail!(err); + let mut rv = self.send_into(&mut out).map_err(|err| { + // Retry DNS failures for sentry.io, as these likely indicate + // a network issue. DNS failures for other domains should not + // be retried, to avoid masking configuration problems (e.g. + // if the user has mistyped their self-hosted URL). + if is_dns_error(&err) && self.is_sentry_io_host() { + anyhow::anyhow!(RetryError::from(err)) + } else { + anyhow::anyhow!(err) } - }; + })?; rv.body = Some(out);