Skip to content

Commit 0b59f67

Browse files
fix(api): Retry API requests on DNS resolution failure (#3085)
### Description Sentry CLI now retries requests that fail to resolve `sentry.io`, addressing intermittent failures for some users uploading symbol files. Retries are limited to `sentry.io` hosts to avoid masking configuration issues for other domains. ### Issues * Fixes #2763 * Fixes [CLI-168](https://linear.app/getsentry/issue/CLI-168/retry-api-request-when-dns-resolution-of-sentryio-fails) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e95d48c commit 0b59f67

File tree

4 files changed

+67
-22
lines changed

4 files changed

+67
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- The `dart-symbol-map upload` command now correctly resolves the organization from the auth token payload ([#3065](https://github.com/getsentry/sentry-cli/pull/3065)).
8+
- Retry DNS resolution failures for `sentry.io` requests to reduce intermittent failures for some users ([#3085](https://github.com/getsentry/sentry-cli/pull/3085))
89

910
## 3.2.0
1011

src/api/errors/mod.rs

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,12 @@ pub(super) struct ProjectRenamedError(pub(super) String);
1414
pub(super) type ApiResult<T> = Result<T, ApiError>;
1515

1616
#[derive(Debug, thiserror::Error)]
17-
#[error("request failed with retryable status code {}", .body.status)]
18-
pub(super) struct RetryError {
19-
body: ApiResponse,
20-
}
21-
22-
impl RetryError {
23-
pub fn new(body: ApiResponse) -> Self {
24-
Self { body }
25-
}
26-
27-
pub fn into_body(self) -> ApiResponse {
28-
self.body
29-
}
17+
pub(super) enum RetryError {
18+
#[error("request failed with retryable status code {}", body.status)]
19+
Status { body: ApiResponse },
20+
#[error("request failed with retryable error: {source}")]
21+
ApiError {
22+
#[from]
23+
source: ApiError,
24+
},
3025
}

src/api/mod.rs

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ mod serialization;
1515
use std::borrow::Cow;
1616
use std::cell::RefCell;
1717
use std::collections::HashMap;
18+
use std::error::Error as _;
1819
#[cfg(any(target_os = "macos", not(feature = "managed")))]
1920
use std::fs::File;
2021
use std::io::{self, Read as _, Write};
2122
use std::rc::Rc;
22-
use std::sync::Arc;
23+
use std::sync::{Arc, LazyLock};
2324
use std::{fmt, thread};
2425

2526
use anyhow::{Context as _, Result};
@@ -39,11 +40,12 @@ use serde::{Deserialize, Serialize};
3940
use sha1_smol::Digest;
4041
use symbolic::common::DebugId;
4142
use symbolic::debuginfo::ObjectKind;
43+
use url::Url;
4244
use uuid::Uuid;
4345

4446
use crate::api::errors::{ProjectRenamedError, RetryError};
4547
use crate::config::{Auth, Config};
46-
use crate::constants::{ARCH, EXT, PLATFORM, RELEASE_REGISTRY_LATEST_URL, VERSION};
48+
use crate::constants::{ARCH, DEFAULT_HOST, EXT, PLATFORM, RELEASE_REGISTRY_LATEST_URL, VERSION};
4749
use crate::utils::http::{self, is_absolute_url};
4850
use crate::utils::non_empty::NonEmptySlice;
4951
use crate::utils::progress::{ProgressBar, ProgressBarMode};
@@ -111,6 +113,7 @@ pub struct ApiRequest {
111113
is_authenticated: bool,
112114
body: Option<Vec<u8>>,
113115
progress_bar_mode: ProgressBarMode,
116+
url: String,
114117
}
115118

116119
/// Represents an API response.
@@ -180,7 +183,7 @@ impl Api {
180183
region_url: Option<&str>,
181184
) -> ApiResult<ApiRequest> {
182185
let (url, auth) = self.resolve_base_url_and_auth(url, region_url)?;
183-
self.construct_api_request(method, &url, auth)
186+
self.construct_api_request(method, url, auth)
184187
}
185188

186189
fn resolve_base_url_and_auth(
@@ -210,7 +213,7 @@ impl Api {
210213
fn construct_api_request(
211214
&self,
212215
method: Method,
213-
url: &str,
216+
url: String,
214217
auth: Option<&Auth>,
215218
) -> ApiResult<ApiRequest> {
216219
let mut handle = self
@@ -1161,7 +1164,7 @@ impl ApiRequest {
11611164
fn create(
11621165
mut handle: r2d2::PooledConnection<CurlConnectionManager>,
11631166
method: &Method,
1164-
url: &str,
1167+
url: String,
11651168
auth: Option<&Auth>,
11661169
pipeline_env: Option<String>,
11671170
global_headers: Option<Vec<String>>,
@@ -1196,14 +1199,15 @@ impl ApiRequest {
11961199
Method::Delete => handle.custom_request("DELETE")?,
11971200
}
11981201

1199-
handle.url(url)?;
1202+
handle.url(&url)?;
12001203

12011204
let request = ApiRequest {
12021205
handle,
12031206
headers,
12041207
is_authenticated: false,
12051208
body: None,
12061209
progress_bar_mode: ProgressBarMode::Disabled,
1210+
url,
12071211
};
12081212

12091213
let request = match auth {
@@ -1313,11 +1317,22 @@ impl ApiRequest {
13131317
debug!("retry number {retry_number}, max retries: {max_retries}");
13141318
*retry_number += 1;
13151319

1316-
let mut rv = self.send_into(&mut out)?;
1320+
let mut rv = self.send_into(&mut out).map_err(|err| {
1321+
// Retry DNS failures for sentry.io, as these likely indicate
1322+
// a network issue. DNS failures for other domains should not
1323+
// be retried, to avoid masking configuration problems (e.g.
1324+
// if the user has mistyped their self-hosted URL).
1325+
if is_dns_error(&err) && self.is_sentry_io_host() {
1326+
anyhow::anyhow!(RetryError::from(err))
1327+
} else {
1328+
anyhow::anyhow!(err)
1329+
}
1330+
})?;
1331+
13171332
rv.body = Some(out);
13181333

13191334
if RETRY_STATUS_CODES.contains(&rv.status) {
1320-
anyhow::bail!(RetryError::new(rv));
1335+
anyhow::bail!(RetryError::Status { body: rv });
13211336
}
13221337

13231338
Ok(rv)
@@ -1336,10 +1351,41 @@ impl ApiRequest {
13361351
})
13371352
.call()
13381353
.or_else(|err| match err.downcast::<RetryError>() {
1339-
Ok(err) => Ok(err.into_body()),
1354+
Ok(RetryError::Status { body }) => Ok(body),
1355+
Ok(RetryError::ApiError { source }) => Err(source),
13401356
Err(err) => Err(ApiError::with_source(ApiErrorKind::RequestFailed, err)),
13411357
})
13421358
}
1359+
1360+
/// Determines whether a URL has a sentry.io host (including subdomains).
1361+
fn is_sentry_io_host(&self) -> bool {
1362+
/// A regex which matches exactly "sentry.io" and hostnames ending in
1363+
/// ".sentry.io".
1364+
static SENTRY_IO_HOST_RE: LazyLock<Regex> = LazyLock::new(|| {
1365+
Regex::new(&format!(r"^(\S*\.)?{}$", regex::escape(DEFAULT_HOST)))
1366+
.expect("regex is valid")
1367+
});
1368+
1369+
Url::parse(&self.url)
1370+
.ok()
1371+
.map(|url| {
1372+
url.host_str()
1373+
.is_some_and(|host| SENTRY_IO_HOST_RE.is_match(host))
1374+
})
1375+
.unwrap_or(false)
1376+
}
1377+
}
1378+
1379+
/// Returns true if the error source chain contains a curl DNS resolution error.
1380+
fn is_dns_error(err: &ApiError) -> bool {
1381+
let mut current = err.source();
1382+
while let Some(error) = current {
1383+
if let Some(curl_err) = error.downcast_ref::<curl::Error>() {
1384+
return curl_err.is_couldnt_resolve_host();
1385+
}
1386+
current = error.source();
1387+
}
1388+
false
13431389
}
13441390

13451391
impl ApiResponse {

src/constants.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ pub const APP_NAME: &str = "sentrycli";
88
/// The default API URL
99
pub const DEFAULT_URL: &str = "https://sentry.io/";
1010

11+
/// The default API host
12+
pub const DEFAULT_HOST: &str = "sentry.io";
13+
1114
/// The version of the library
1215
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
1316

0 commit comments

Comments
 (0)