From 1082574d001e7994bec20dae1e7ee493d0413376 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 19:36:26 +0330 Subject: [PATCH 01/11] feat(config): Separate NXDOMAIN DNS cache --- relay-config/src/config.rs | 11 ++++++++ relay-server/src/services/upstream.rs | 39 +++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/relay-config/src/config.rs b/relay-config/src/config.rs index 27e1ee8ed33..92f825f26f6 100644 --- a/relay-config/src/config.rs +++ b/relay-config/src/config.rs @@ -887,6 +887,10 @@ pub struct Http { /// the resolved entries. This helps to limit the amount of requests made to the upstream DNS /// server (important for K8s infrastructure). pub dns_cache: bool, + /// When `dns_cache` is enabled, controls whether NXDOMAIN responses are cached. + /// Set to `false` to always re-query for failed lookups. + /// Only has an effect when `dns_cache` is `true`. + pub dns_cache_nxdomain: bool, } impl Default for Http { @@ -904,6 +908,7 @@ impl Default for Http { global_metrics: false, forward: true, dns_cache: true, + dns_cache_nxdomain: true, } } } @@ -2225,6 +2230,12 @@ impl Config { self.values.http.dns_cache } + /// Returns `true` if relay should cache negative DNS responses (NXDOMAIN). + /// Only meaningful when `http_dns_cache()` returns `true`. + pub fn http_dns_cache_nxdomain(&self) -> bool { + self.values.http.dns_cache_nxdomain + } + /// Returns the expiry timeout for cached projects. pub fn project_cache_expiry(&self) -> Duration { Duration::from_secs(self.values.cache.project_expiry.into()) diff --git a/relay-server/src/services/upstream.rs b/relay-server/src/services/upstream.rs index 9e1e42eb4da..2ced76f0cb6 100644 --- a/relay-server/src/services/upstream.rs +++ b/relay-server/src/services/upstream.rs @@ -11,10 +11,13 @@ use std::fmt; use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use std::net::SocketAddr; use std::time::Duration; use axum::response::IntoResponse; use bytes::Bytes; +use hickory_resolver::config::{ResolverConfig, ResolverOpts}; +use hickory_resolver::TokioAsyncResolver; use itertools::Itertools; use relay_auth::{ RegisterChallenge, RegisterRequest, RegisterResponse, Registration, SecretKey, Signature, @@ -29,6 +32,7 @@ use relay_system::{ AsyncResponse, FromMessage, Interface, MessageResponse, NoResponse, Sender, Service, }; pub use reqwest::Method; +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use reqwest::header; use serde::Serialize; use serde::de::DeserializeOwned; @@ -841,6 +845,24 @@ impl UpstreamQuery for RegisterResponse { } } +/// A custom DNS resolver backed by hickory, allowing fine-grained control over +/// resolver options (e.g. NXDOMAIN TTL) that reqwest's `.hickory_dns()` does not expose. +struct HickoryResolver(TokioAsyncResolver); + +impl Resolve for HickoryResolver { + fn resolve(&self, name: Name) -> Resolving { + let resolver = self.0.clone(); // cheap, TokioAsyncResolver is Arc-backed + Box::pin(async move { + let addrs = resolver + .lookup_ip(name.as_str()) + .await? + .into_iter() + .map(|ip| SocketAddr::new(ip, 0)); + Ok(Box::new(addrs) as Addrs) + }) + } +} + /// A shared, asynchronous client to build and execute requests. /// /// The main way to send a request through this client is [`send`](Self::send). @@ -856,16 +878,23 @@ struct SharedClient { impl SharedClient { /// Creates a new `SharedClient` instance. pub fn build(config: Arc) -> Self { - let reqwest = reqwest::ClientBuilder::new() + let mut builder = reqwest::ClientBuilder::new() .connect_timeout(config.http_connection_timeout()) .timeout(config.http_timeout()) // In the forward endpoint, this means that content negotiation is done twice, and the // response body is first decompressed by the client, then re-compressed by the server. - .gzip(true) - .hickory_dns(config.http_dns_cache()) - .build() - .unwrap(); + .gzip(true); + + if config.http_dns_cache() { + let mut opts = ResolverOpts::default(); + if !config.http_dns_cache_nxdomain() { + opts.negative_max_ttl = Some(Duration::ZERO); + } + let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts); + builder = builder.dns_resolver(Arc::new(HickoryResolver(resolver))); + } + let reqwest = builder.build().unwrap(); Self { config, reqwest } } From 4b993999b8fc2903c5ba2eda591205749b5bee58 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 19:46:26 +0330 Subject: [PATCH 02/11] Disable hickory DNS resolver when asked to --- relay-server/src/services/upstream.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/relay-server/src/services/upstream.rs b/relay-server/src/services/upstream.rs index 2ced76f0cb6..7f520ddb671 100644 --- a/relay-server/src/services/upstream.rs +++ b/relay-server/src/services/upstream.rs @@ -892,6 +892,11 @@ impl SharedClient { } let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts); builder = builder.dns_resolver(Arc::new(HickoryResolver(resolver))); + } else { + // Explicitly disable hickory so reqwest falls back to the system resolver. + // Without this, reqwest defaults to hickory when the feature is compiled in, + // ignoring the user's opt-out. + builder = builder.hickory_dns(false); } let reqwest = builder.build().unwrap(); From 990fe5ca8c5520d7a868eb1ca65ca8eaf5e30fce Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 19:53:36 +0330 Subject: [PATCH 03/11] Use system resolvers instead of ResolverConfig::default() --- relay-server/src/services/upstream.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/relay-server/src/services/upstream.rs b/relay-server/src/services/upstream.rs index 7f520ddb671..10e6731b76c 100644 --- a/relay-server/src/services/upstream.rs +++ b/relay-server/src/services/upstream.rs @@ -890,7 +890,11 @@ impl SharedClient { if !config.http_dns_cache_nxdomain() { opts.negative_max_ttl = Some(Duration::ZERO); } - let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts); + let (system_config, mut system_opts) = read_system_conf().unwrap_or_default(); + if !config.http_dns_cache_nxdomain() { + system_opts.negative_max_ttl = Some(Duration::ZERO); + } + let resolver = TokioAsyncResolver::tokio(system_config, system_opts); builder = builder.dns_resolver(Arc::new(HickoryResolver(resolver))); } else { // Explicitly disable hickory so reqwest falls back to the system resolver. From 6a3cd6ebad9daab4b3d9ea3718f655c4299a58d7 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 19:58:53 +0330 Subject: [PATCH 04/11] Use Ipv4AndIpv6 strategy to resolve --- relay-server/src/services/upstream.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/relay-server/src/services/upstream.rs b/relay-server/src/services/upstream.rs index 10e6731b76c..df543c9b14d 100644 --- a/relay-server/src/services/upstream.rs +++ b/relay-server/src/services/upstream.rs @@ -891,6 +891,9 @@ impl SharedClient { opts.negative_max_ttl = Some(Duration::ZERO); } let (system_config, mut system_opts) = read_system_conf().unwrap_or_default(); + // Match reqwest's built-in hickory behaviour which uses Ipv4AndIpv6 (parallel, + // "happy eyeballs") instead of the hickory default Ipv4thenIpv6 (sequential). + system_opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; if !config.http_dns_cache_nxdomain() { system_opts.negative_max_ttl = Some(Duration::ZERO); } From 3714ed76ba7eb3a2a988aeefe4bccf2e0777da83 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 20:06:10 +0330 Subject: [PATCH 05/11] Remove unused code --- relay-server/src/services/upstream.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/relay-server/src/services/upstream.rs b/relay-server/src/services/upstream.rs index df543c9b14d..dfd0ced59de 100644 --- a/relay-server/src/services/upstream.rs +++ b/relay-server/src/services/upstream.rs @@ -16,7 +16,8 @@ use std::time::Duration; use axum::response::IntoResponse; use bytes::Bytes; -use hickory_resolver::config::{ResolverConfig, ResolverOpts}; +use hickory_resolver::config::{LookupIpStrategy, ResolverOpts}; +use hickory_resolver::system_conf::read_system_conf; use hickory_resolver::TokioAsyncResolver; use itertools::Itertools; use relay_auth::{ @@ -886,10 +887,6 @@ impl SharedClient { .gzip(true); if config.http_dns_cache() { - let mut opts = ResolverOpts::default(); - if !config.http_dns_cache_nxdomain() { - opts.negative_max_ttl = Some(Duration::ZERO); - } let (system_config, mut system_opts) = read_system_conf().unwrap_or_default(); // Match reqwest's built-in hickory behaviour which uses Ipv4AndIpv6 (parallel, // "happy eyeballs") instead of the hickory default Ipv4thenIpv6 (sequential). From dc9ebb894542b74ddc810061fb1c14361e304149 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 20:11:04 +0330 Subject: [PATCH 06/11] Remove unused import --- relay-server/src/services/upstream.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay-server/src/services/upstream.rs b/relay-server/src/services/upstream.rs index dfd0ced59de..c6ee7d5f71a 100644 --- a/relay-server/src/services/upstream.rs +++ b/relay-server/src/services/upstream.rs @@ -16,7 +16,7 @@ use std::time::Duration; use axum::response::IntoResponse; use bytes::Bytes; -use hickory_resolver::config::{LookupIpStrategy, ResolverOpts}; +use hickory_resolver::config::LookupIpStrategy; use hickory_resolver::system_conf::read_system_conf; use hickory_resolver::TokioAsyncResolver; use itertools::Itertools; From 75f5677e9f54a6dec6a9c6c26d7850a27db03692 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 20:19:32 +0330 Subject: [PATCH 07/11] Add hickory-resolver to deps --- Cargo.toml | 1 + relay-server/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index d3914c3dc85..20bfa9e64e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,6 +120,7 @@ globset = "0.4.16" hash32 = "1.0.0" hashbrown = "0.15.4" hex = "0.4.3" +hickory-resolver = "0.25.2" hmac = "0.12.1" hostname = "0.4.1" http = "1.3.1" diff --git a/relay-server/Cargo.toml b/relay-server/Cargo.toml index f1ae0b47534..16e546ded30 100644 --- a/relay-server/Cargo.toml +++ b/relay-server/Cargo.toml @@ -48,6 +48,7 @@ either = { workspace = true } flate2 = { workspace = true } futures = { workspace = true, features = ["async-await"] } hashbrown = { workspace = true } +hickory-resolver = { workspace = true } http = { workspace = true } http-body-util = { workspace = true } hyper = { workspace = true } From fbadde11bcc87441e6e771d0748be62d68f63879 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 20:25:45 +0330 Subject: [PATCH 08/11] Add PR to CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7911643a28..1e68a740075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - To prevent false positives, non-public email addresses (e.g. `user@localhost`) are no longer scrubbed by default. ([#5737](https://github.com/getsentry/relay/pull/5737)) +**Features**: + +- Separate NXDOMAIN DNS cache. ([#5750](https://github.com/getsentry/relay/pull/5750)) + **Internal**: - Calculate and track accepted bytes per individual trace metric item via `TraceMetricByte` data category. ([#5744](https://github.com/getsentry/relay/pull/5744)) From d48866446f5263e16522fd52f61058d6603bea89 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 22:59:27 +0330 Subject: [PATCH 09/11] Add hickory-resolver to Cargo.lock --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index b40f40e8565..eed7c969d65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4776,6 +4776,7 @@ dependencies = [ "flate2", "futures", "hashbrown 0.15.4", + "hickory-resolver", "http", "http-body-util", "hyper", From 0b2d26fc0ba940c87a59dff0860417e218fb17cc Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 23:14:13 +0330 Subject: [PATCH 10/11] Replace deprecated TokioAsyncResolver with TokioResolver --- relay-server/src/services/upstream.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/relay-server/src/services/upstream.rs b/relay-server/src/services/upstream.rs index c6ee7d5f71a..74dde42719f 100644 --- a/relay-server/src/services/upstream.rs +++ b/relay-server/src/services/upstream.rs @@ -18,7 +18,8 @@ use axum::response::IntoResponse; use bytes::Bytes; use hickory_resolver::config::LookupIpStrategy; use hickory_resolver::system_conf::read_system_conf; -use hickory_resolver::TokioAsyncResolver; +use hickory_resolver::TokioResolver; +use hickory_resolver::name_server::TokioConnectionProvider; use itertools::Itertools; use relay_auth::{ RegisterChallenge, RegisterRequest, RegisterResponse, Registration, SecretKey, Signature, @@ -848,7 +849,7 @@ impl UpstreamQuery for RegisterResponse { /// A custom DNS resolver backed by hickory, allowing fine-grained control over /// resolver options (e.g. NXDOMAIN TTL) that reqwest's `.hickory_dns()` does not expose. -struct HickoryResolver(TokioAsyncResolver); +struct HickoryResolver(TokioResolver); impl Resolve for HickoryResolver { fn resolve(&self, name: Name) -> Resolving { @@ -894,7 +895,9 @@ impl SharedClient { if !config.http_dns_cache_nxdomain() { system_opts.negative_max_ttl = Some(Duration::ZERO); } - let resolver = TokioAsyncResolver::tokio(system_config, system_opts); + let resolver = TokioResolver::builder_with_config(system_config, TokioConnectionProvider::default()) + .with_options(system_opts) + .build(); builder = builder.dns_resolver(Arc::new(HickoryResolver(resolver))); } else { // Explicitly disable hickory so reqwest falls back to the system resolver. From d507969e97c8f70845ace96f10e298687491feb6 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Thu, 19 Mar 2026 23:14:58 +0330 Subject: [PATCH 11/11] Fix comment --- relay-server/src/services/upstream.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay-server/src/services/upstream.rs b/relay-server/src/services/upstream.rs index 74dde42719f..8c73ed24f61 100644 --- a/relay-server/src/services/upstream.rs +++ b/relay-server/src/services/upstream.rs @@ -853,7 +853,7 @@ struct HickoryResolver(TokioResolver); impl Resolve for HickoryResolver { fn resolve(&self, name: Name) -> Resolving { - let resolver = self.0.clone(); // cheap, TokioAsyncResolver is Arc-backed + let resolver = self.0.clone(); // cheap, TokioResolver is Arc-backed Box::pin(async move { let addrs = resolver .lookup_ip(name.as_str())