From 82c1ba033d55c24152a18b409922484548e336bc Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 5 Dec 2025 18:41:14 +0300 Subject: [PATCH 01/16] enhance(sdk-rs): builder pattern --- .changeset/busy-cloths-search.md | 54 ++++ configs/cargo/Cargo.lock | 5 +- .../router/src/persisted_documents.rs | 41 ++- packages/libraries/router/src/registry.rs | 27 +- packages/libraries/router/src/usage.rs | 34 ++- packages/libraries/sdk-rs/Cargo.toml | 1 + .../libraries/sdk-rs/src/agent/builder.rs | 209 +++++++++++++++ packages/libraries/sdk-rs/src/agent/mod.rs | 3 + .../src/{agent.rs => agent/usage_agent.rs} | 143 ++++------ .../sdk-rs/src/{graphql.rs => agent/utils.rs} | 0 packages/libraries/sdk-rs/src/lib.rs | 1 - .../sdk-rs/src/persisted_documents.rs | 189 ++++++++++--- .../sdk-rs/src/supergraph_fetcher.rs | 251 +++++++++++++----- 13 files changed, 722 insertions(+), 236 deletions(-) create mode 100644 .changeset/busy-cloths-search.md create mode 100644 packages/libraries/sdk-rs/src/agent/builder.rs create mode 100644 packages/libraries/sdk-rs/src/agent/mod.rs rename packages/libraries/sdk-rs/src/{agent.rs => agent/usage_agent.rs} (82%) rename packages/libraries/sdk-rs/src/{graphql.rs => agent/utils.rs} (100%) diff --git a/.changeset/busy-cloths-search.md b/.changeset/busy-cloths-search.md new file mode 100644 index 00000000000..03f4d5529c5 --- /dev/null +++ b/.changeset/busy-cloths-search.md @@ -0,0 +1,54 @@ +--- +'hive-console-sdk-rs': minor +--- + +Breaking Changes to avoid future breaking changes; + +Switch to [Builder](https://rust-unofficial.github.io/patterns/patterns/creational/builder.html) pattern for `SupergraphFetcher`, `PersistedDocumentsManager` and `UsageAgent` structs. + +Benefits; + +- No need to provide all parameters at once when creating an instance even for default values. + +Example; +```rust +// Before +let fetcher = SupergraphFetcher::try_new_async( + "SOME_ENDPOINT", // endpoint + "SOME_KEY", + "MyUserAgent/1.0".to_string(), + Duration::from_secs(5), // connect_timeout + Duration::from_secs(10), // request_timeout + false, // accept_invalid_certs + 3, // retry_count + )?; + +// After +// No need to provide all parameters at once, can use default values +let fetcher = SupergraphFetcherBuilder::new() + .endpoint("SOME_ENDPOINT".to_string()) + .key("SOME_KEY".to_string()) + .build_async()?; +``` + +- Easier to add new configuration options in the future without breaking existing code. + +Example; + +```rust +let fetcher = SupergraphFetcher::try_new_async( + "SOME_ENDPOINT", // endpoint + "SOME_KEY", + "MyUserAgent/1.0".to_string(), + Duration::from_secs(5), // connect_timeout + Duration::from_secs(10), // request_timeout + false, // accept_invalid_certs + 3, // retry_count + circuit_breaker_config, // Breaking Change -> new parameter added + )?; + +let fetcher = SupergraphFetcherBuilder::new() + .endpoint("SOME_ENDPOINT".to_string()) + .key("SOME_KEY".to_string()) + .build_async()?; // No breaking change, circuit_breaker_config can be added later if needed +``` \ No newline at end of file diff --git a/configs/cargo/Cargo.lock b/configs/cargo/Cargo.lock index 24fe2fb4e9c..c80aa2e53ec 100644 --- a/configs/cargo/Cargo.lock +++ b/configs/cargo/Cargo.lock @@ -2613,7 +2613,7 @@ dependencies = [ [[package]] name = "hive-apollo-router-plugin" -version = "2.3.3" +version = "2.3.4" dependencies = [ "anyhow", "apollo-router", @@ -2647,7 +2647,7 @@ dependencies = [ [[package]] name = "hive-console-sdk" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "async-trait", @@ -2657,6 +2657,7 @@ dependencies = [ "md5", "mockito", "moka", + "regex-automata", "reqwest", "reqwest-middleware", "reqwest-retry 0.8.0", diff --git a/packages/libraries/router/src/persisted_documents.rs b/packages/libraries/router/src/persisted_documents.rs index 5391f1a6646..c47f4cc3eeb 100644 --- a/packages/libraries/router/src/persisted_documents.rs +++ b/packages/libraries/router/src/persisted_documents.rs @@ -100,17 +100,38 @@ impl PersistedDocumentsPlugin { } }; + let mut persisted_documents_manager = PersistedDocumentsManager::builder() + .key(key) + .endpoint(endpoint) + .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)); + + if let Some(connect_timeout) = config.connect_timeout { + persisted_documents_manager = + persisted_documents_manager.connect_timeout(Duration::from_secs(connect_timeout)); + } + + if let Some(request_timeout) = config.request_timeout { + persisted_documents_manager = + persisted_documents_manager.request_timeout(Duration::from_secs(request_timeout)); + } + + if let Some(retry_count) = config.retry_count { + persisted_documents_manager = persisted_documents_manager.max_retries(retry_count); + } + + if let Some(accept_invalid_certs) = config.accept_invalid_certs { + persisted_documents_manager = + persisted_documents_manager.accept_invalid_certs(accept_invalid_certs); + } + + if let Some(cache_size) = config.cache_size { + persisted_documents_manager = persisted_documents_manager.cache_size(cache_size); + } + + let persisted_documents_manager = persisted_documents_manager.build()?; + Ok(PersistedDocumentsPlugin { - persisted_documents_manager: Some(Arc::new(PersistedDocumentsManager::new( - key, - endpoint, - config.accept_invalid_certs.unwrap_or(false), - Duration::from_secs(config.connect_timeout.unwrap_or(5)), - Duration::from_secs(config.request_timeout.unwrap_or(15)), - config.retry_count.unwrap_or(3), - config.cache_size.unwrap_or(1000), - format!("hive-apollo-router/{}", PLUGIN_VERSION), - ))), + persisted_documents_manager: Some(Arc::new(persisted_documents_manager)), allow_arbitrary_documents, }) } diff --git a/packages/libraries/router/src/registry.rs b/packages/libraries/router/src/registry.rs index 243c160cbc7..ec8a2879f7e 100644 --- a/packages/libraries/router/src/registry.rs +++ b/packages/libraries/router/src/registry.rs @@ -2,13 +2,13 @@ use crate::consts::PLUGIN_VERSION; use crate::registry_logger::Logger; use anyhow::{anyhow, Result}; use hive_console_sdk::supergraph_fetcher::SupergraphFetcher; +use hive_console_sdk::supergraph_fetcher::SupergraphFetcherBuilder; use hive_console_sdk::supergraph_fetcher::SupergraphFetcherSyncState; use sha2::Digest; use sha2::Sha256; use std::env; use std::io::Write; use std::thread; -use std::time::Duration; #[derive(Debug)] pub struct HiveRegistry { @@ -120,19 +120,18 @@ impl HiveRegistry { .to_string_lossy() .to_string(), ); - env::set_var("APOLLO_ROUTER_SUPERGRAPH_PATH", file_name.clone()); - env::set_var("APOLLO_ROUTER_HOT_RELOAD", "true"); - - let fetcher = SupergraphFetcher::try_new_sync( - endpoint, - &key, - format!("hive-apollo-router/{}", PLUGIN_VERSION), - Duration::from_secs(5), - Duration::from_secs(60), - accept_invalid_certs, - 3, - ) - .map_err(|e| anyhow!("Failed to create SupergraphFetcher: {}", e))?; + unsafe { + env::set_var("APOLLO_ROUTER_SUPERGRAPH_PATH", file_name.clone()); + env::set_var("APOLLO_ROUTER_HOT_RELOAD", "true"); + } + + let fetcher = SupergraphFetcherBuilder::new() + .endpoint(endpoint) + .key(key) + .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)) + .accept_invalid_certs(accept_invalid_certs) + .build_sync() + .map_err(|e| anyhow!("Failed to create SupergraphFetcher: {}", e))?; let registry = HiveRegistry { fetcher, diff --git a/packages/libraries/router/src/usage.rs b/packages/libraries/router/src/usage.rs index 1964a6e04e5..0d28575675e 100644 --- a/packages/libraries/router/src/usage.rs +++ b/packages/libraries/router/src/usage.rs @@ -8,8 +8,7 @@ use core::ops::Drop; use futures::StreamExt; use graphql_parser::parse_schema; use graphql_parser::schema::Document; -use hive_console_sdk::agent::UsageAgentExt; -use hive_console_sdk::agent::{ExecutionReport, UsageAgent}; +use hive_console_sdk::agent::usage_agent::{ExecutionReport, UsageAgent, UsageAgentExt}; use http::HeaderValue; use rand::Rng; use schemars::JsonSchema; @@ -244,20 +243,27 @@ impl Plugin for UsagePlugin { .expect("Failed to parse schema") .into_static(); + let token = token.expect("token is set"); + let agent = if enabled { let flush_interval = Duration::from_secs(flush_interval); - let agent = UsageAgent::try_new( - &token.expect("token is set"), - endpoint, - target_id, - buffer_size, - Duration::from_secs(connect_timeout), - Duration::from_secs(request_timeout), - accept_invalid_certs, - flush_interval, - format!("hive-apollo-router/{}", PLUGIN_VERSION), - ) - .map_err(Box::new)?; + + let mut agent = UsageAgent::builder() + .token(token) + .endpoint(endpoint) + .buffer_size(buffer_size) + .connect_timeout(Duration::from_secs(connect_timeout)) + .request_timeout(Duration::from_secs(request_timeout)) + .accept_invalid_certs(accept_invalid_certs) + .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)) + .flush_interval(flush_interval); + + if let Some(target_id) = target_id { + agent = agent.target_id(target_id); + } + + let agent = agent.build().map_err(Box::new)?; + start_flush_interval(agent.clone()); Some(agent) } else { diff --git a/packages/libraries/sdk-rs/Cargo.toml b/packages/libraries/sdk-rs/Cargo.toml index 34521f1582e..316de0f86dc 100644 --- a/packages/libraries/sdk-rs/Cargo.toml +++ b/packages/libraries/sdk-rs/Cargo.toml @@ -32,6 +32,7 @@ serde_json = "1" moka = { version = "0.12.10", features = ["future", "sync"] } sha2 = { version = "0.10.8", features = ["std"] } tokio-util = "0.7.16" +regex-automata = "0.4.10" [dev-dependencies] mockito = "1.7.0" diff --git a/packages/libraries/sdk-rs/src/agent/builder.rs b/packages/libraries/sdk-rs/src/agent/builder.rs new file mode 100644 index 00000000000..e96bc3b1a16 --- /dev/null +++ b/packages/libraries/sdk-rs/src/agent/builder.rs @@ -0,0 +1,209 @@ +use std::{sync::Arc, time::Duration}; + +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest_middleware::ClientBuilder; +use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; + +use crate::agent::usage_agent::{non_empty_string, AgentError, Buffer, UsageAgent}; +use crate::agent::utils::OperationProcessor; + +pub struct UsageAgentBuilder { + _token: Option, + _endpoint: String, + _target_id: Option, + _buffer_size: usize, + _connect_timeout: Duration, + _request_timeout: Duration, + _accept_invalid_certs: bool, + _flush_interval: Duration, + _retry_policy: ExponentialBackoff, + _user_agent: Option, +} + +pub static DEFAULT_HIVE_USAGE_ENDPOINT: &str = "https://app.graphql-hive.com/usage"; + +impl Default for UsageAgentBuilder { + fn default() -> Self { + Self { + _endpoint: DEFAULT_HIVE_USAGE_ENDPOINT.to_string(), + _token: None, + _target_id: None, + _buffer_size: 1000, + _connect_timeout: Duration::from_secs(5), + _request_timeout: Duration::from_secs(15), + _accept_invalid_certs: false, + _flush_interval: Duration::from_secs(5), + _retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), + _user_agent: None, + } + } +} + +fn is_legacy_token(token: &str) -> bool { + !token.starts_with("hvo1/") && !token.starts_with("hvu1/") && !token.starts_with("hvp1/") +} + +impl UsageAgentBuilder { + /// Your [Registry Access Token](https://the-guild.dev/graphql/hive/docs/management/targets#registry-access-tokens) with write permission. + pub fn token(mut self, token: String) -> Self { + self._token = non_empty_string(Some(token)); + self + } + /// For self-hosting, you can override `/usage` endpoint (defaults to `https://app.graphql-hive.com/usage`). + pub fn endpoint(mut self, endpoint: String) -> Self { + if let Some(endpoint) = non_empty_string(Some(endpoint)) { + self._endpoint = endpoint; + } + self + } + /// A target ID, this can either be a slug following the format “$organizationSlug/$projectSlug/$targetSlug” (e.g “the-guild/graphql-hive/staging”) or an UUID (e.g. “a0f4c605-6541-4350-8cfe-b31f21a4bf80”). To be used when the token is configured with an organization access token. + pub fn target_id(mut self, target_id: String) -> Self { + self._target_id = non_empty_string(Some(target_id)); + self + } + /// A maximum number of operations to hold in a buffer before sending to Hive Console + /// Default: 1000 + pub fn buffer_size(mut self, buffer_size: usize) -> Self { + self._buffer_size = buffer_size; + self + } + /// A timeout for only the connect phase of a request to Hive Console + /// Default: 5 seconds + pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self { + self._connect_timeout = connect_timeout; + self + } + /// A timeout for the entire request to Hive Console + /// Default: 15 seconds + pub fn request_timeout(mut self, request_timeout: Duration) -> Self { + self._request_timeout = request_timeout; + self + } + /// Accepts invalid SSL certificates + /// Default: false + pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { + self._accept_invalid_certs = accept_invalid_certs; + self + } + /// Frequency of flushing the buffer to the server + /// Default: 5 seconds + pub fn flush_interval(mut self, flush_interval: Duration) -> Self { + self._flush_interval = flush_interval; + self + } + /// User-Agent header to be sent with each request + pub fn user_agent(mut self, user_agent: String) -> Self { + self._user_agent = non_empty_string(Some(user_agent)); + self + } + /// Retry policy for sending reports + /// Default: ExponentialBackoff with max 3 retries + pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { + self._retry_policy = retry_policy; + self + } + /// Maximum number of retries for sending reports + /// Default: ExponentialBackoff with max 3 retries + pub fn max_retries(mut self, max_retries: u32) -> Self { + self._retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); + self + } + pub fn build(self) -> Result, AgentError> { + let mut default_headers = HeaderMap::new(); + + default_headers.insert("X-Usage-API-Version", HeaderValue::from_static("2")); + + if let Some(token) = self._token { + let mut authorization_header = HeaderValue::from_str(&format!("Bearer {}", token)) + .map_err(|_| AgentError::InvalidToken)?; + + authorization_header.set_sensitive(true); + + default_headers.insert(reqwest::header::AUTHORIZATION, authorization_header); + + default_headers.insert( + reqwest::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + + let mut reqwest_agent = reqwest::Client::builder() + .danger_accept_invalid_certs(self._accept_invalid_certs) + .connect_timeout(self._connect_timeout) + .timeout(self._request_timeout) + .default_headers(default_headers); + + if let Some(user_agent) = &self._user_agent { + reqwest_agent = reqwest_agent.user_agent(user_agent); + } + + let reqwest_agent = reqwest_agent + .build() + .map_err(AgentError::HTTPClientCreationError)?; + let client = ClientBuilder::new(reqwest_agent) + .with(RetryTransientMiddleware::new_with_policy( + self._retry_policy, + )) + .build(); + + let mut endpoint = self._endpoint; + + match self._target_id { + Some(_) if is_legacy_token(&token) => { + return Err(AgentError::TargetIdWithLegacyToken) + } + Some(target_id) if !is_legacy_token(&token) => { + let target_id = validate_target_id(&target_id)?; + endpoint.push_str(&format!("/{}", target_id)); + } + None if !is_legacy_token(&token) => return Err(AgentError::MissingTargetId), + _ => {} + } + + Ok(Arc::new(UsageAgent { + endpoint, + buffer: Buffer::new(self._buffer_size), + processor: OperationProcessor::new(), + client, + flush_interval: self._flush_interval, + })) + } else { + Err(AgentError::MissingToken) + } + } +} + +// Target ID regexp for validation: slug format +const TARGET_ID_SLUG_REGEX: &str = r"^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+$"; +// Target ID regexp for validation: UUID format +const TARGET_ID_UUID_REGEX: &str = + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + +fn validate_target_id(target_id: &str) -> Result<&str, AgentError> { + let trimmed_s = target_id.trim(); + if trimmed_s.is_empty() { + Err(AgentError::InvalidTargetId("".to_string())) + } else { + let slug_regex = regex_automata::meta::Regex::new(TARGET_ID_SLUG_REGEX).map_err(|err| { + AgentError::TargetIdRegexError(format!( + "Failed to compile target_id slug regex: {}", + err + )) + })?; + if slug_regex.is_match(trimmed_s) { + return Ok(trimmed_s); + } + let uuid_regex = regex_automata::meta::Regex::new(TARGET_ID_UUID_REGEX).map_err(|err| { + AgentError::TargetIdRegexError(format!( + "Failed to compile target_id UUID regex: {}", + err + )) + })?; + if uuid_regex.is_match(trimmed_s) { + return Ok(trimmed_s); + } + Err(AgentError::InvalidTargetId(format!( + "Invalid target_id format: '{}'. It must be either in slug format '$organizationSlug/$projectSlug/$targetSlug' or UUID format 'a0f4c605-6541-4350-8cfe-b31f21a4bf80'", + trimmed_s + ))) + } +} diff --git a/packages/libraries/sdk-rs/src/agent/mod.rs b/packages/libraries/sdk-rs/src/agent/mod.rs new file mode 100644 index 00000000000..2e8250459f7 --- /dev/null +++ b/packages/libraries/sdk-rs/src/agent/mod.rs @@ -0,0 +1,3 @@ +pub mod builder; +pub mod usage_agent; +pub mod utils; diff --git a/packages/libraries/sdk-rs/src/agent.rs b/packages/libraries/sdk-rs/src/agent/usage_agent.rs similarity index 82% rename from packages/libraries/sdk-rs/src/agent.rs rename to packages/libraries/sdk-rs/src/agent/usage_agent.rs index 20c1d013a89..8d94de20258 100644 --- a/packages/libraries/sdk-rs/src/agent.rs +++ b/packages/libraries/sdk-rs/src/agent/usage_agent.rs @@ -1,8 +1,5 @@ -use super::graphql::OperationProcessor; use graphql_parser::schema::Document; -use reqwest::header::{HeaderMap, HeaderValue}; -use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; -use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; +use reqwest_middleware::ClientWithMiddleware; use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, VecDeque}, @@ -12,6 +9,9 @@ use std::{ use thiserror::Error; use tokio_util::sync::CancellationToken; +use crate::agent::builder::UsageAgentBuilder; +use crate::agent::utils::OperationProcessor; + #[derive(Serialize, Deserialize, Debug)] pub struct Report { size: usize, @@ -77,18 +77,26 @@ pub struct ExecutionReport { } #[derive(Debug, Default)] -pub struct Buffer(Mutex>); +pub struct Buffer { + queue: Mutex>, + size: usize, +} impl Buffer { - fn new() -> Self { - Self(Mutex::new(VecDeque::new())) + pub fn new(size: usize) -> Self { + Self { + queue: Mutex::new(VecDeque::new()), + size, + } } fn lock_buffer( &self, ) -> Result>, AgentError> { - let buffer: Result>, AgentError> = - self.0.lock().map_err(|e| AgentError::Lock(e.to_string())); + let buffer: Result>, AgentError> = self + .queue + .lock() + .map_err(|e| AgentError::Lock(e.to_string())); buffer } @@ -105,15 +113,14 @@ impl Buffer { } } pub struct UsageAgent { - buffer_size: usize, - endpoint: String, - buffer: Buffer, - processor: OperationProcessor, - client: ClientWithMiddleware, - flush_interval: Duration, + pub(crate) endpoint: String, + pub(crate) buffer: Buffer, + pub(crate) processor: OperationProcessor, + pub(crate) client: ClientWithMiddleware, + pub(crate) flush_interval: Duration, } -fn non_empty_string(value: Option) -> Option { +pub fn non_empty_string(value: Option) -> Option { value.filter(|str| !str.is_empty()) } @@ -127,75 +134,28 @@ pub enum AgentError { Forbidden, #[error("unable to send report: rate limited")] RateLimited, - #[error("invalid token provided: {0}")] - InvalidToken(String), + #[error("missing token")] + MissingToken, + #[error("your access token requires providing a 'target_id' option.")] + MissingTargetId, + #[error("using 'target_id' with legacy tokens is not supported")] + TargetIdWithLegacyToken, + #[error("invalid token provided")] + InvalidToken, + #[error("invalid target id provided: {0}, it should be either a slug like \"$organizationSlug/$projectSlug/$targetSlug\" or an UUID")] + InvalidTargetId(String), #[error("unable to instantiate the http client for reports sending: {0}")] HTTPClientCreationError(reqwest::Error), + #[error("unable to build regex for target id validation: {0}")] + TargetIdRegexError(String), #[error("unable to send report: {0}")] Unknown(String), } impl UsageAgent { - #[allow(clippy::too_many_arguments)] - pub fn try_new( - token: &str, - endpoint: String, - target_id: Option, - buffer_size: usize, - connect_timeout: Duration, - request_timeout: Duration, - accept_invalid_certs: bool, - flush_interval: Duration, - user_agent: String, - ) -> Result, AgentError> { - let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); - - let mut default_headers = HeaderMap::new(); - - default_headers.insert("X-Usage-API-Version", HeaderValue::from_static("2")); - - let mut authorization_header = HeaderValue::from_str(&format!("Bearer {}", token)) - .map_err(|_| AgentError::InvalidToken(token.to_string()))?; - - authorization_header.set_sensitive(true); - - default_headers.insert(reqwest::header::AUTHORIZATION, authorization_header); - - default_headers.insert( - reqwest::header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ); - - let reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(accept_invalid_certs) - .connect_timeout(connect_timeout) - .timeout(request_timeout) - .user_agent(user_agent) - .default_headers(default_headers) - .build() - .map_err(AgentError::HTTPClientCreationError)?; - let client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy(retry_policy)) - .build(); - - let mut endpoint = endpoint; - - if token.starts_with("hvo1/") || token.starts_with("hvu1/") || token.starts_with("hvp1/") { - if let Some(target_id) = target_id { - endpoint.push_str(&format!("/{}", target_id)); - } - } - - Ok(Arc::new(Self { - buffer_size, - endpoint, - buffer: Buffer::new(), - processor: OperationProcessor::new(), - client, - flush_interval, - })) + pub fn builder() -> UsageAgentBuilder { + UsageAgentBuilder::default() } - fn produce_report(&self, reports: Vec) -> Result { let mut report = Report { size: 0, @@ -269,7 +229,7 @@ impl UsageAgent { Ok(report) } - pub async fn send_report(&self, report: Report) -> Result<(), AgentError> { + async fn send_report(&self, report: Report) -> Result<(), AgentError> { if report.size == 0 { return Ok(()); } @@ -343,7 +303,7 @@ pub trait UsageAgentExt { impl UsageAgentExt for Arc { fn flush_if_full(&self, size: usize) -> Result<(), AgentError> { - if size >= self.buffer_size { + if size >= self.buffer.size { let cloned_self = self.clone(); tokio::task::spawn(async move { cloned_self.flush().await; @@ -369,7 +329,7 @@ mod tests { use graphql_parser::{parse_query, parse_schema}; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; - use crate::agent::{ExecutionReport, Report, UsageAgent, UsageAgentExt}; + use crate::agent::usage_agent::{ExecutionReport, Report, UsageAgent, UsageAgentExt}; const CONTENT_TYPE_VALUE: &'static str = "application/json"; const GRAPHQL_CLIENT_NAME: &'static str = "Hive Client"; @@ -525,18 +485,17 @@ mod tests { ) .expect("Failed to parse query"); - let usage_agent = UsageAgent::try_new( - token, - format!("{}/200", server_url), - None, - 10, - Duration::from_millis(500), - Duration::from_millis(500), - false, - Duration::from_millis(10), - user_agent, - ) - .expect("Failed to create UsageAgent"); + let usage_agent = UsageAgent::builder() + .token(token.to_string()) + .endpoint(format!("{}/200", server_url)) + .buffer_size(10) + .connect_timeout(Duration::from_millis(500)) + .request_timeout(Duration::from_millis(500)) + .accept_invalid_certs(false) + .flush_interval(Duration::from_millis(10)) + .user_agent(user_agent.clone()) + .build() + .expect("Failed to create UsageAgent"); usage_agent .add_report(ExecutionReport { diff --git a/packages/libraries/sdk-rs/src/graphql.rs b/packages/libraries/sdk-rs/src/agent/utils.rs similarity index 100% rename from packages/libraries/sdk-rs/src/graphql.rs rename to packages/libraries/sdk-rs/src/agent/utils.rs diff --git a/packages/libraries/sdk-rs/src/lib.rs b/packages/libraries/sdk-rs/src/lib.rs index ec6f97886e0..d5a593ee98a 100644 --- a/packages/libraries/sdk-rs/src/lib.rs +++ b/packages/libraries/sdk-rs/src/lib.rs @@ -1,4 +1,3 @@ pub mod agent; -pub mod graphql; pub mod persisted_documents; pub mod supergraph_fetcher; diff --git a/packages/libraries/sdk-rs/src/persisted_documents.rs b/packages/libraries/sdk-rs/src/persisted_documents.rs index 02ce66132c8..2d47729a588 100644 --- a/packages/libraries/sdk-rs/src/persisted_documents.rs +++ b/packages/libraries/sdk-rs/src/persisted_documents.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use crate::agent::usage_agent::non_empty_string; use moka::future::Cache; use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; @@ -10,7 +11,7 @@ use tracing::{debug, info, warn}; #[derive(Debug)] pub struct PersistedDocumentsManager { - agent: ClientWithMiddleware, + client: ClientWithMiddleware, cache: Cache, endpoint: String, } @@ -31,6 +32,8 @@ pub enum PersistedDocumentsError { FailedToReadCDNResponse(reqwest::Error), #[error("No persisted document provided, or document id cannot be resolved.")] PersistedDocumentRequired, + #[error("Missing required configuration option: {0}")] + MissingConfigurationOption(String), } impl PersistedDocumentsError { @@ -51,47 +54,17 @@ impl PersistedDocumentsError { PersistedDocumentsError::PersistedDocumentRequired => { "PERSISTED_DOCUMENT_REQUIRED".into() } + PersistedDocumentsError::MissingConfigurationOption(_) => { + "MISSING_CONFIGURATION_OPTION".into() + } } } } impl PersistedDocumentsManager { - #[allow(clippy::too_many_arguments)] - pub fn new( - key: String, - endpoint: String, - accept_invalid_certs: bool, - connect_timeout: Duration, - request_timeout: Duration, - retry_count: u32, - cache_size: u64, - user_agent: String, - ) -> Self { - let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retry_count); - - let mut default_headers = HeaderMap::new(); - default_headers.insert("X-Hive-CDN-Key", HeaderValue::from_str(&key).unwrap()); - let reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(accept_invalid_certs) - .connect_timeout(connect_timeout) - .timeout(request_timeout) - .user_agent(user_agent) - .default_headers(default_headers) - .build() - .expect("Failed to create reqwest client"); - let agent = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy(retry_policy)) - .build(); - - let cache = Cache::::new(cache_size); - - Self { - agent, - cache, - endpoint, - } + pub fn builder() -> PersistedDocumentsManagerBuilder { + PersistedDocumentsManagerBuilder::default() } - /// Resolves the document from the cache, or from the CDN pub async fn resolve_document( &self, @@ -116,7 +89,7 @@ impl PersistedDocumentsManager { "Fetching document {} from CDN: {}", document_id, cdn_artifact_url ); - let cdn_response = self.agent.get(cdn_artifact_url).send().await; + let cdn_response = self.client.get(cdn_artifact_url).send().await; match cdn_response { Ok(response) => { @@ -157,3 +130,145 @@ impl PersistedDocumentsManager { } } } + +pub struct PersistedDocumentsManagerBuilder { + _key: Option, + _endpoint: Option, + _accept_invalid_certs: bool, + _connect_timeout: Duration, + /// Request timeout for the Hive Console CDN requests. + _request_timeout: Duration, + /// Interval at which the Hive Console should be retried upon failure. + /// + /// By default, an exponential backoff retry policy is used, with 3 attempts. + _retry_policy: ExponentialBackoff, + /// Configuration for the size of the in-memory caching of persisted documents. + _cache_size: u64, + /// User-Agent header to be sent with each request + _user_agent: Option, +} + +impl Default for PersistedDocumentsManagerBuilder { + fn default() -> Self { + Self { + _key: None, + _endpoint: None, + _accept_invalid_certs: false, + _connect_timeout: Duration::from_secs(5), + _request_timeout: Duration::from_secs(15), + _retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), + _cache_size: 10_000, + _user_agent: None, + } + } +} + +impl PersistedDocumentsManagerBuilder { + /// The CDN Access Token with from the Hive Console target. + pub fn key(mut self, key: String) -> Self { + self._key = non_empty_string(Some(key)); + self + } + + /// The CDN endpoint from Hive Console target. + pub fn endpoint(mut self, endpoint: String) -> Self { + self._endpoint = non_empty_string(Some(endpoint)); + self + } + + /// Accept invalid SSL certificates + /// default: false + pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { + self._accept_invalid_certs = accept_invalid_certs; + self + } + + /// Connection timeout for the Hive Console CDN requests. + /// Default: 5 seconds + pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self { + self._connect_timeout = connect_timeout; + self + } + + /// Request timeout for the Hive Console CDN requests. + /// Default: 15 seconds + pub fn request_timeout(mut self, request_timeout: Duration) -> Self { + self._request_timeout = request_timeout; + self + } + + /// Retry policy for fetching persisted documents + /// Default: ExponentialBackoff with max 3 retries + pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { + self._retry_policy = retry_policy; + self + } + + /// Maximum number of retries for fetching persisted documents + /// Default: ExponentialBackoff with max 3 retries + pub fn max_retries(mut self, max_retries: u32) -> Self { + self._retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); + self + } + + /// Size of the in-memory cache for persisted documents + /// Default: 10,000 entries + pub fn cache_size(mut self, cache_size: u64) -> Self { + self._cache_size = cache_size; + self + } + + /// User-Agent header to be sent with each request + pub fn user_agent(mut self, user_agent: String) -> Self { + self._user_agent = non_empty_string(Some(user_agent)); + self + } + + pub fn build(self) -> Result { + let mut default_headers = HeaderMap::new(); + let key = match self._key { + Some(key) => key, + None => { + return Err(PersistedDocumentsError::MissingConfigurationOption( + "key".to_string(), + )); + } + }; + default_headers.insert("X-Hive-CDN-Key", HeaderValue::from_str(&key).unwrap()); + let mut reqwest_agent = reqwest::Client::builder() + .danger_accept_invalid_certs(self._accept_invalid_certs) + .connect_timeout(self._connect_timeout) + .timeout(self._request_timeout) + .default_headers(default_headers); + + if let Some(user_agent) = self._user_agent { + reqwest_agent = reqwest_agent.user_agent(user_agent); + } + + let reqwest_agent = reqwest_agent + .build() + .expect("Failed to create reqwest client"); + let client = ClientBuilder::new(reqwest_agent) + .with(RetryTransientMiddleware::new_with_policy( + self._retry_policy, + )) + .build(); + + let cache = Cache::::new(self._cache_size); + + let endpoint = match self._endpoint { + Some(endpoint) => endpoint, + None => { + return Err(PersistedDocumentsError::MissingConfigurationOption( + "endpoint".to_string(), + )); + } + }; + + Ok(PersistedDocumentsManager { + client, + cache, + endpoint, + }) + } +} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs index 98c2540eed7..ae6a1dd96d8 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs @@ -44,6 +44,7 @@ pub enum SupergraphFetcherError { NetworkResponseError(reqwest::Error), Lock(String), InvalidKey(InvalidHeaderValue), + MissingConfigurationOption(String), } impl Display for SupergraphFetcherError { @@ -58,6 +59,9 @@ impl Display for SupergraphFetcherError { } SupergraphFetcherError::Lock(e) => write!(f, "Lock error: {}", e), SupergraphFetcherError::InvalidKey(e) => write!(f, "Invalid CDN key: {}", e), + SupergraphFetcherError::MissingConfigurationOption(e) => { + write!(f, "Missing configuration option: {}", e) + } } } } @@ -65,8 +69,7 @@ impl Display for SupergraphFetcherError { fn prepare_client_config( mut endpoint: String, key: &str, - retry_count: u32, -) -> Result<(String, HeaderMap, ExponentialBackoff), SupergraphFetcherError> { +) -> Result<(String, HeaderMap), SupergraphFetcherError> { if !endpoint.ends_with("/supergraph") { if endpoint.ends_with("/") { endpoint.push_str("supergraph"); @@ -81,42 +84,10 @@ fn prepare_client_config( cdn_key_header.set_sensitive(true); headers.insert("X-Hive-CDN-Key", cdn_key_header); - let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retry_count); - - Ok((endpoint, headers, retry_policy)) + Ok((endpoint, headers)) } impl SupergraphFetcher { - #[allow(clippy::too_many_arguments)] - pub fn try_new_sync( - endpoint: String, - key: &str, - user_agent: String, - connect_timeout: Duration, - request_timeout: Duration, - accept_invalid_certs: bool, - retry_count: u32, - ) -> Result { - let (endpoint, headers, retry_policy) = prepare_client_config(endpoint, key, retry_count)?; - - Ok(Self { - client: SupergraphFetcherAsyncOrSyncClient::Sync { - reqwest_client: reqwest::blocking::Client::builder() - .danger_accept_invalid_certs(accept_invalid_certs) - .connect_timeout(connect_timeout) - .timeout(request_timeout) - .user_agent(user_agent) - .default_headers(headers) - .build() - .map_err(SupergraphFetcherError::FetcherCreationError)?, - retry_policy, - }, - endpoint, - etag: RwLock::new(None), - state: std::marker::PhantomData, - }) - } - pub fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { let request_start_time = SystemTime::now(); // Implementing retry logic for sync client @@ -170,37 +141,6 @@ impl SupergraphFetcher { } impl SupergraphFetcher { - #[allow(clippy::too_many_arguments)] - pub fn try_new_async( - endpoint: String, - key: &str, - user_agent: String, - connect_timeout: Duration, - request_timeout: Duration, - accept_invalid_certs: bool, - retry_count: u32, - ) -> Result { - let (endpoint, headers, retry_policy) = prepare_client_config(endpoint, key, retry_count)?; - - let reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(accept_invalid_certs) - .connect_timeout(connect_timeout) - .timeout(request_timeout) - .default_headers(headers) - .user_agent(user_agent) - .build() - .map_err(SupergraphFetcherError::FetcherCreationError)?; - let reqwest_client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy(retry_policy)) - .build(); - - Ok(Self { - client: SupergraphFetcherAsyncOrSyncClient::Async { reqwest_client }, - endpoint, - etag: RwLock::new(None), - state: std::marker::PhantomData, - }) - } pub async fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { let reqwest_client = match &self.client { SupergraphFetcherAsyncOrSyncClient::Async { reqwest_client } => reqwest_client, @@ -258,3 +198,182 @@ impl SupergraphFetcher { Ok(()) } } + +pub struct SupergraphFetcherBuilder { + _endpoint: Option, + _key: Option, + _user_agent: Option, + _connect_timeout: Duration, + _request_timeout: Duration, + _accept_invalid_certs: bool, + _retry_policy: ExponentialBackoff, +} + +impl Default for SupergraphFetcherBuilder { + fn default() -> Self { + Self { + _endpoint: None, + _key: None, + _user_agent: None, + _connect_timeout: Duration::from_secs(5), + _request_timeout: Duration::from_secs(60), + _accept_invalid_certs: false, + _retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), + } + } +} + +impl SupergraphFetcherBuilder { + pub fn new() -> Self { + Self::default() + } + + /// The CDN endpoint from Hive Console target. + pub fn endpoint(mut self, endpoint: String) -> Self { + self._endpoint = Some(endpoint); + self + } + + /// The CDN Access Token with from the Hive Console target. + pub fn key(mut self, key: String) -> Self { + self._key = Some(key); + self + } + + pub fn user_agent(mut self, user_agent: String) -> Self { + self._user_agent = Some(user_agent); + self + } + + /// Connection timeout for the Hive Console CDN requests. + /// Default: 5 seconds + pub fn connect_timeout(mut self, timeout: Duration) -> Self { + self._connect_timeout = timeout; + self + } + + /// Request timeout for the Hive Console CDN requests. + /// Default: 60 seconds + pub fn request_timeout(mut self, timeout: Duration) -> Self { + self._request_timeout = timeout; + self + } + + pub fn accept_invalid_certs(mut self, accept: bool) -> Self { + self._accept_invalid_certs = accept; + self + } + + /// Policy for retrying failed requests. + /// + /// By default, an exponential backoff retry policy is used, with 10 attempts. + pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { + self._retry_policy = retry_policy; + self + } + + /// Maximum number of retries for failed requests. + /// + /// By default, an exponential backoff retry policy is used, with 10 attempts. + pub fn max_retries(mut self, max_retries: u32) -> Self { + self._retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); + self + } + + /// Builds a synchronous SupergraphFetcher + pub fn build_sync( + self, + ) -> Result, SupergraphFetcherError> { + let endpoint = match self._endpoint { + Some(endpoint) => endpoint, + None => { + return Err(SupergraphFetcherError::MissingConfigurationOption( + "endpoint".to_string(), + )) + } + }; + let key = match self._key { + Some(key) => key, + None => { + return Err(SupergraphFetcherError::MissingConfigurationOption( + "key".to_string(), + )) + } + }; + let (endpoint, headers) = prepare_client_config(endpoint, &key)?; + + let mut reqwest_client = reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(self._accept_invalid_certs) + .connect_timeout(self._connect_timeout) + .timeout(self._request_timeout) + .default_headers(headers); + + if let Some(user_agent) = &self._user_agent { + reqwest_client = reqwest_client.user_agent(user_agent); + } + + let reqwest_client = reqwest_client + .build() + .map_err(SupergraphFetcherError::FetcherCreationError)?; + let fetcher: SupergraphFetcher = SupergraphFetcher { + client: SupergraphFetcherAsyncOrSyncClient::Sync { + reqwest_client, + retry_policy: self._retry_policy, + }, + endpoint, + etag: RwLock::new(None), + state: std::marker::PhantomData, + }; + Ok(fetcher) + } + + /// Builds an asynchronous SupergraphFetcher + pub fn build_async( + self, + ) -> Result, SupergraphFetcherError> { + let endpoint = match self._endpoint { + Some(endpoint) => endpoint, + None => { + return Err(SupergraphFetcherError::MissingConfigurationOption( + "endpoint".to_string(), + )) + } + }; + let key = match self._key { + Some(key) => key, + None => { + return Err(SupergraphFetcherError::MissingConfigurationOption( + "key".to_string(), + )) + } + }; + + let (endpoint, headers) = prepare_client_config(endpoint, &key)?; + + let mut reqwest_agent = reqwest::Client::builder() + .danger_accept_invalid_certs(self._accept_invalid_certs) + .connect_timeout(self._connect_timeout) + .timeout(self._request_timeout) + .default_headers(headers); + + if let Some(user_agent) = self._user_agent { + reqwest_agent = reqwest_agent.user_agent(user_agent); + } + + let reqwest_agent = reqwest_agent + .build() + .map_err(SupergraphFetcherError::FetcherCreationError)?; + let reqwest_client = ClientBuilder::new(reqwest_agent) + .with(RetryTransientMiddleware::new_with_policy( + self._retry_policy, + )) + .build(); + + Ok(SupergraphFetcher { + client: SupergraphFetcherAsyncOrSyncClient::Async { reqwest_client }, + endpoint, + etag: RwLock::new(None), + state: std::marker::PhantomData, + }) + } +} From 64737293abd44788ade6009a1f7d7debd3011914 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 5 Dec 2025 18:44:59 +0300 Subject: [PATCH 02/16] Changeeset --- .changeset/busy-cloths-search.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/busy-cloths-search.md b/.changeset/busy-cloths-search.md index 03f4d5529c5..7aed840f79e 100644 --- a/.changeset/busy-cloths-search.md +++ b/.changeset/busy-cloths-search.md @@ -6,6 +6,8 @@ Breaking Changes to avoid future breaking changes; Switch to [Builder](https://rust-unofficial.github.io/patterns/patterns/creational/builder.html) pattern for `SupergraphFetcher`, `PersistedDocumentsManager` and `UsageAgent` structs. +No more `try_new` or `try_new_async` or `try_new_sync` functions, instead use `SupergraphFetcherBuilder`, `PersistedDocumentsManagerBuilder` and `UsageAgentBuilder` structs to create instances. + Benefits; - No need to provide all parameters at once when creating an instance even for default values. From c1f7f77ca0e4d08d43a647094eedbb03c8469262 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 5 Dec 2025 18:53:14 +0300 Subject: [PATCH 03/16] Apply suggestions --- configs/cargo/Cargo.lock | 2 + packages/libraries/sdk-rs/Cargo.toml | 2 + .../libraries/sdk-rs/src/agent/builder.rs | 116 +++++++++--------- .../libraries/sdk-rs/src/agent/usage_agent.rs | 2 - .../sdk-rs/src/persisted_documents.rs | 71 ++++++----- .../sdk-rs/src/supergraph_fetcher.rs | 76 ++++++------ 6 files changed, 131 insertions(+), 138 deletions(-) diff --git a/configs/cargo/Cargo.lock b/configs/cargo/Cargo.lock index c80aa2e53ec..43d83b3e62e 100644 --- a/configs/cargo/Cargo.lock +++ b/configs/cargo/Cargo.lock @@ -2657,10 +2657,12 @@ dependencies = [ "md5", "mockito", "moka", + "once_cell", "regex-automata", "reqwest", "reqwest-middleware", "reqwest-retry 0.8.0", + "retry-policies 0.5.1", "serde", "serde_json", "sha2", diff --git a/packages/libraries/sdk-rs/Cargo.toml b/packages/libraries/sdk-rs/Cargo.toml index 316de0f86dc..32cd34c3cb2 100644 --- a/packages/libraries/sdk-rs/Cargo.toml +++ b/packages/libraries/sdk-rs/Cargo.toml @@ -33,6 +33,8 @@ moka = { version = "0.12.10", features = ["future", "sync"] } sha2 = { version = "0.10.8", features = ["std"] } tokio-util = "0.7.16" regex-automata = "0.4.10" +once_cell = "1.21.3" +retry-policies = "0.5.0" [dev-dependencies] mockito = "1.7.0" diff --git a/packages/libraries/sdk-rs/src/agent/builder.rs b/packages/libraries/sdk-rs/src/agent/builder.rs index e96bc3b1a16..576718b9506 100644 --- a/packages/libraries/sdk-rs/src/agent/builder.rs +++ b/packages/libraries/sdk-rs/src/agent/builder.rs @@ -1,23 +1,25 @@ use std::{sync::Arc, time::Duration}; +use once_cell::sync::Lazy; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest_middleware::ClientBuilder; -use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; +use reqwest_retry::RetryTransientMiddleware; use crate::agent::usage_agent::{non_empty_string, AgentError, Buffer, UsageAgent}; use crate::agent::utils::OperationProcessor; +use retry_policies::policies::ExponentialBackoff; pub struct UsageAgentBuilder { - _token: Option, - _endpoint: String, - _target_id: Option, - _buffer_size: usize, - _connect_timeout: Duration, - _request_timeout: Duration, - _accept_invalid_certs: bool, - _flush_interval: Duration, - _retry_policy: ExponentialBackoff, - _user_agent: Option, + token: Option, + endpoint: String, + target_id: Option, + buffer_size: usize, + connect_timeout: Duration, + request_timeout: Duration, + accept_invalid_certs: bool, + flush_interval: Duration, + retry_policy: ExponentialBackoff, + user_agent: Option, } pub static DEFAULT_HIVE_USAGE_ENDPOINT: &str = "https://app.graphql-hive.com/usage"; @@ -25,16 +27,16 @@ pub static DEFAULT_HIVE_USAGE_ENDPOINT: &str = "https://app.graphql-hive.com/usa impl Default for UsageAgentBuilder { fn default() -> Self { Self { - _endpoint: DEFAULT_HIVE_USAGE_ENDPOINT.to_string(), - _token: None, - _target_id: None, - _buffer_size: 1000, - _connect_timeout: Duration::from_secs(5), - _request_timeout: Duration::from_secs(15), - _accept_invalid_certs: false, - _flush_interval: Duration::from_secs(5), - _retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), - _user_agent: None, + endpoint: DEFAULT_HIVE_USAGE_ENDPOINT.to_string(), + token: None, + target_id: None, + buffer_size: 1000, + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(15), + accept_invalid_certs: false, + flush_interval: Duration::from_secs(5), + retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), + user_agent: None, } } } @@ -46,66 +48,66 @@ fn is_legacy_token(token: &str) -> bool { impl UsageAgentBuilder { /// Your [Registry Access Token](https://the-guild.dev/graphql/hive/docs/management/targets#registry-access-tokens) with write permission. pub fn token(mut self, token: String) -> Self { - self._token = non_empty_string(Some(token)); + self.token = non_empty_string(Some(token)); self } /// For self-hosting, you can override `/usage` endpoint (defaults to `https://app.graphql-hive.com/usage`). pub fn endpoint(mut self, endpoint: String) -> Self { if let Some(endpoint) = non_empty_string(Some(endpoint)) { - self._endpoint = endpoint; + self.endpoint = endpoint; } self } /// A target ID, this can either be a slug following the format “$organizationSlug/$projectSlug/$targetSlug” (e.g “the-guild/graphql-hive/staging”) or an UUID (e.g. “a0f4c605-6541-4350-8cfe-b31f21a4bf80”). To be used when the token is configured with an organization access token. pub fn target_id(mut self, target_id: String) -> Self { - self._target_id = non_empty_string(Some(target_id)); + self.target_id = non_empty_string(Some(target_id)); self } /// A maximum number of operations to hold in a buffer before sending to Hive Console /// Default: 1000 pub fn buffer_size(mut self, buffer_size: usize) -> Self { - self._buffer_size = buffer_size; + self.buffer_size = buffer_size; self } /// A timeout for only the connect phase of a request to Hive Console /// Default: 5 seconds pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self { - self._connect_timeout = connect_timeout; + self.connect_timeout = connect_timeout; self } /// A timeout for the entire request to Hive Console /// Default: 15 seconds pub fn request_timeout(mut self, request_timeout: Duration) -> Self { - self._request_timeout = request_timeout; + self.request_timeout = request_timeout; self } /// Accepts invalid SSL certificates /// Default: false pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { - self._accept_invalid_certs = accept_invalid_certs; + self.accept_invalid_certs = accept_invalid_certs; self } /// Frequency of flushing the buffer to the server /// Default: 5 seconds pub fn flush_interval(mut self, flush_interval: Duration) -> Self { - self._flush_interval = flush_interval; + self.flush_interval = flush_interval; self } /// User-Agent header to be sent with each request pub fn user_agent(mut self, user_agent: String) -> Self { - self._user_agent = non_empty_string(Some(user_agent)); + self.user_agent = non_empty_string(Some(user_agent)); self } /// Retry policy for sending reports /// Default: ExponentialBackoff with max 3 retries pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { - self._retry_policy = retry_policy; + self.retry_policy = retry_policy; self } /// Maximum number of retries for sending reports /// Default: ExponentialBackoff with max 3 retries pub fn max_retries(mut self, max_retries: u32) -> Self { - self._retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); + self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); self } pub fn build(self) -> Result, AgentError> { @@ -113,7 +115,7 @@ impl UsageAgentBuilder { default_headers.insert("X-Usage-API-Version", HeaderValue::from_static("2")); - if let Some(token) = self._token { + if let Some(token) = self.token { let mut authorization_header = HeaderValue::from_str(&format!("Bearer {}", token)) .map_err(|_| AgentError::InvalidToken)?; @@ -127,12 +129,12 @@ impl UsageAgentBuilder { ); let mut reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(self._accept_invalid_certs) - .connect_timeout(self._connect_timeout) - .timeout(self._request_timeout) + .danger_accept_invalid_certs(self.accept_invalid_certs) + .connect_timeout(self.connect_timeout) + .timeout(self.request_timeout) .default_headers(default_headers); - if let Some(user_agent) = &self._user_agent { + if let Some(user_agent) = &self.user_agent { reqwest_agent = reqwest_agent.user_agent(user_agent); } @@ -140,14 +142,12 @@ impl UsageAgentBuilder { .build() .map_err(AgentError::HTTPClientCreationError)?; let client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy( - self._retry_policy, - )) + .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) .build(); - let mut endpoint = self._endpoint; + let mut endpoint = self.endpoint; - match self._target_id { + match self.target_id { Some(_) if is_legacy_token(&token) => { return Err(AgentError::TargetIdWithLegacyToken) } @@ -161,10 +161,10 @@ impl UsageAgentBuilder { Ok(Arc::new(UsageAgent { endpoint, - buffer: Buffer::new(self._buffer_size), + buffer: Buffer::new(self.buffer_size), processor: OperationProcessor::new(), client, - flush_interval: self._flush_interval, + flush_interval: self.flush_interval, })) } else { Err(AgentError::MissingToken) @@ -173,32 +173,26 @@ impl UsageAgentBuilder { } // Target ID regexp for validation: slug format -const TARGET_ID_SLUG_REGEX: &str = r"^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+$"; +static SLUG_REGEX: Lazy = Lazy::new(|| { + regex_automata::meta::Regex::new(r"^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+$").unwrap() +}); // Target ID regexp for validation: UUID format -const TARGET_ID_UUID_REGEX: &str = - r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; +static UUID_REGEX: Lazy = Lazy::new(|| { + regex_automata::meta::Regex::new( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + ) + .unwrap() +}); fn validate_target_id(target_id: &str) -> Result<&str, AgentError> { let trimmed_s = target_id.trim(); if trimmed_s.is_empty() { Err(AgentError::InvalidTargetId("".to_string())) } else { - let slug_regex = regex_automata::meta::Regex::new(TARGET_ID_SLUG_REGEX).map_err(|err| { - AgentError::TargetIdRegexError(format!( - "Failed to compile target_id slug regex: {}", - err - )) - })?; - if slug_regex.is_match(trimmed_s) { + if SLUG_REGEX.is_match(trimmed_s) { return Ok(trimmed_s); } - let uuid_regex = regex_automata::meta::Regex::new(TARGET_ID_UUID_REGEX).map_err(|err| { - AgentError::TargetIdRegexError(format!( - "Failed to compile target_id UUID regex: {}", - err - )) - })?; - if uuid_regex.is_match(trimmed_s) { + if UUID_REGEX.is_match(trimmed_s) { return Ok(trimmed_s); } Err(AgentError::InvalidTargetId(format!( diff --git a/packages/libraries/sdk-rs/src/agent/usage_agent.rs b/packages/libraries/sdk-rs/src/agent/usage_agent.rs index 8d94de20258..34fbe07df3b 100644 --- a/packages/libraries/sdk-rs/src/agent/usage_agent.rs +++ b/packages/libraries/sdk-rs/src/agent/usage_agent.rs @@ -146,8 +146,6 @@ pub enum AgentError { InvalidTargetId(String), #[error("unable to instantiate the http client for reports sending: {0}")] HTTPClientCreationError(reqwest::Error), - #[error("unable to build regex for target id validation: {0}")] - TargetIdRegexError(String), #[error("unable to send report: {0}")] Unknown(String), } diff --git a/packages/libraries/sdk-rs/src/persisted_documents.rs b/packages/libraries/sdk-rs/src/persisted_documents.rs index 2d47729a588..17ae0b13ca1 100644 --- a/packages/libraries/sdk-rs/src/persisted_documents.rs +++ b/packages/libraries/sdk-rs/src/persisted_documents.rs @@ -6,7 +6,8 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; use reqwest_middleware::ClientBuilder; use reqwest_middleware::ClientWithMiddleware; -use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; +use reqwest_retry::RetryTransientMiddleware; +use retry_policies::policies::ExponentialBackoff; use tracing::{debug, info, warn}; #[derive(Debug)] @@ -132,33 +133,33 @@ impl PersistedDocumentsManager { } pub struct PersistedDocumentsManagerBuilder { - _key: Option, - _endpoint: Option, - _accept_invalid_certs: bool, - _connect_timeout: Duration, + key: Option, + endpoint: Option, + accept_invalid_certs: bool, + connect_timeout: Duration, /// Request timeout for the Hive Console CDN requests. - _request_timeout: Duration, + request_timeout: Duration, /// Interval at which the Hive Console should be retried upon failure. /// /// By default, an exponential backoff retry policy is used, with 3 attempts. - _retry_policy: ExponentialBackoff, + retry_policy: ExponentialBackoff, /// Configuration for the size of the in-memory caching of persisted documents. - _cache_size: u64, + cache_size: u64, /// User-Agent header to be sent with each request - _user_agent: Option, + user_agent: Option, } impl Default for PersistedDocumentsManagerBuilder { fn default() -> Self { Self { - _key: None, - _endpoint: None, - _accept_invalid_certs: false, - _connect_timeout: Duration::from_secs(5), - _request_timeout: Duration::from_secs(15), - _retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), - _cache_size: 10_000, - _user_agent: None, + key: None, + endpoint: None, + accept_invalid_certs: false, + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(15), + retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), + cache_size: 10_000, + user_agent: None, } } } @@ -166,67 +167,67 @@ impl Default for PersistedDocumentsManagerBuilder { impl PersistedDocumentsManagerBuilder { /// The CDN Access Token with from the Hive Console target. pub fn key(mut self, key: String) -> Self { - self._key = non_empty_string(Some(key)); + self.key = non_empty_string(Some(key)); self } /// The CDN endpoint from Hive Console target. pub fn endpoint(mut self, endpoint: String) -> Self { - self._endpoint = non_empty_string(Some(endpoint)); + self.endpoint = non_empty_string(Some(endpoint)); self } /// Accept invalid SSL certificates /// default: false pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { - self._accept_invalid_certs = accept_invalid_certs; + self.accept_invalid_certs = accept_invalid_certs; self } /// Connection timeout for the Hive Console CDN requests. /// Default: 5 seconds pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self { - self._connect_timeout = connect_timeout; + self.connect_timeout = connect_timeout; self } /// Request timeout for the Hive Console CDN requests. /// Default: 15 seconds pub fn request_timeout(mut self, request_timeout: Duration) -> Self { - self._request_timeout = request_timeout; + self.request_timeout = request_timeout; self } /// Retry policy for fetching persisted documents /// Default: ExponentialBackoff with max 3 retries pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { - self._retry_policy = retry_policy; + self.retry_policy = retry_policy; self } /// Maximum number of retries for fetching persisted documents /// Default: ExponentialBackoff with max 3 retries pub fn max_retries(mut self, max_retries: u32) -> Self { - self._retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); + self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); self } /// Size of the in-memory cache for persisted documents /// Default: 10,000 entries pub fn cache_size(mut self, cache_size: u64) -> Self { - self._cache_size = cache_size; + self.cache_size = cache_size; self } /// User-Agent header to be sent with each request pub fn user_agent(mut self, user_agent: String) -> Self { - self._user_agent = non_empty_string(Some(user_agent)); + self.user_agent = non_empty_string(Some(user_agent)); self } pub fn build(self) -> Result { let mut default_headers = HeaderMap::new(); - let key = match self._key { + let key = match self.key { Some(key) => key, None => { return Err(PersistedDocumentsError::MissingConfigurationOption( @@ -236,12 +237,12 @@ impl PersistedDocumentsManagerBuilder { }; default_headers.insert("X-Hive-CDN-Key", HeaderValue::from_str(&key).unwrap()); let mut reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(self._accept_invalid_certs) - .connect_timeout(self._connect_timeout) - .timeout(self._request_timeout) + .danger_accept_invalid_certs(self.accept_invalid_certs) + .connect_timeout(self.connect_timeout) + .timeout(self.request_timeout) .default_headers(default_headers); - if let Some(user_agent) = self._user_agent { + if let Some(user_agent) = self.user_agent { reqwest_agent = reqwest_agent.user_agent(user_agent); } @@ -249,14 +250,12 @@ impl PersistedDocumentsManagerBuilder { .build() .expect("Failed to create reqwest client"); let client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy( - self._retry_policy, - )) + .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) .build(); - let cache = Cache::::new(self._cache_size); + let cache = Cache::::new(self.cache_size); - let endpoint = match self._endpoint { + let endpoint = match self.endpoint { Some(endpoint) => endpoint, None => { return Err(PersistedDocumentsError::MissingConfigurationOption( diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs index ae6a1dd96d8..29a0b973df4 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs @@ -9,10 +9,10 @@ use reqwest::header::InvalidHeaderValue; use reqwest::header::IF_NONE_MATCH; use reqwest_middleware::ClientBuilder; use reqwest_middleware::ClientWithMiddleware; -use reqwest_retry::policies::ExponentialBackoff; use reqwest_retry::RetryDecision; use reqwest_retry::RetryPolicy; use reqwest_retry::RetryTransientMiddleware; +use retry_policies::policies::ExponentialBackoff; #[derive(Debug)] pub struct SupergraphFetcher { @@ -200,25 +200,25 @@ impl SupergraphFetcher { } pub struct SupergraphFetcherBuilder { - _endpoint: Option, - _key: Option, - _user_agent: Option, - _connect_timeout: Duration, - _request_timeout: Duration, - _accept_invalid_certs: bool, - _retry_policy: ExponentialBackoff, + endpoint: Option, + key: Option, + user_agent: Option, + connect_timeout: Duration, + request_timeout: Duration, + accept_invalid_certs: bool, + retry_policy: ExponentialBackoff, } impl Default for SupergraphFetcherBuilder { fn default() -> Self { Self { - _endpoint: None, - _key: None, - _user_agent: None, - _connect_timeout: Duration::from_secs(5), - _request_timeout: Duration::from_secs(60), - _accept_invalid_certs: false, - _retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), + endpoint: None, + key: None, + user_agent: None, + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(60), + accept_invalid_certs: false, + retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), } } } @@ -230,37 +230,37 @@ impl SupergraphFetcherBuilder { /// The CDN endpoint from Hive Console target. pub fn endpoint(mut self, endpoint: String) -> Self { - self._endpoint = Some(endpoint); + self.endpoint = Some(endpoint); self } /// The CDN Access Token with from the Hive Console target. pub fn key(mut self, key: String) -> Self { - self._key = Some(key); + self.key = Some(key); self } pub fn user_agent(mut self, user_agent: String) -> Self { - self._user_agent = Some(user_agent); + self.user_agent = Some(user_agent); self } /// Connection timeout for the Hive Console CDN requests. /// Default: 5 seconds pub fn connect_timeout(mut self, timeout: Duration) -> Self { - self._connect_timeout = timeout; + self.connect_timeout = timeout; self } /// Request timeout for the Hive Console CDN requests. /// Default: 60 seconds pub fn request_timeout(mut self, timeout: Duration) -> Self { - self._request_timeout = timeout; + self.request_timeout = timeout; self } pub fn accept_invalid_certs(mut self, accept: bool) -> Self { - self._accept_invalid_certs = accept; + self.accept_invalid_certs = accept; self } @@ -268,7 +268,7 @@ impl SupergraphFetcherBuilder { /// /// By default, an exponential backoff retry policy is used, with 10 attempts. pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { - self._retry_policy = retry_policy; + self.retry_policy = retry_policy; self } @@ -276,7 +276,7 @@ impl SupergraphFetcherBuilder { /// /// By default, an exponential backoff retry policy is used, with 10 attempts. pub fn max_retries(mut self, max_retries: u32) -> Self { - self._retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); + self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); self } @@ -284,7 +284,7 @@ impl SupergraphFetcherBuilder { pub fn build_sync( self, ) -> Result, SupergraphFetcherError> { - let endpoint = match self._endpoint { + let endpoint = match self.endpoint { Some(endpoint) => endpoint, None => { return Err(SupergraphFetcherError::MissingConfigurationOption( @@ -292,7 +292,7 @@ impl SupergraphFetcherBuilder { )) } }; - let key = match self._key { + let key = match self.key { Some(key) => key, None => { return Err(SupergraphFetcherError::MissingConfigurationOption( @@ -303,12 +303,12 @@ impl SupergraphFetcherBuilder { let (endpoint, headers) = prepare_client_config(endpoint, &key)?; let mut reqwest_client = reqwest::blocking::Client::builder() - .danger_accept_invalid_certs(self._accept_invalid_certs) - .connect_timeout(self._connect_timeout) - .timeout(self._request_timeout) + .danger_accept_invalid_certs(self.accept_invalid_certs) + .connect_timeout(self.connect_timeout) + .timeout(self.request_timeout) .default_headers(headers); - if let Some(user_agent) = &self._user_agent { + if let Some(user_agent) = &self.user_agent { reqwest_client = reqwest_client.user_agent(user_agent); } @@ -318,7 +318,7 @@ impl SupergraphFetcherBuilder { let fetcher: SupergraphFetcher = SupergraphFetcher { client: SupergraphFetcherAsyncOrSyncClient::Sync { reqwest_client, - retry_policy: self._retry_policy, + retry_policy: self.retry_policy, }, endpoint, etag: RwLock::new(None), @@ -331,7 +331,7 @@ impl SupergraphFetcherBuilder { pub fn build_async( self, ) -> Result, SupergraphFetcherError> { - let endpoint = match self._endpoint { + let endpoint = match self.endpoint { Some(endpoint) => endpoint, None => { return Err(SupergraphFetcherError::MissingConfigurationOption( @@ -339,7 +339,7 @@ impl SupergraphFetcherBuilder { )) } }; - let key = match self._key { + let key = match self.key { Some(key) => key, None => { return Err(SupergraphFetcherError::MissingConfigurationOption( @@ -351,12 +351,12 @@ impl SupergraphFetcherBuilder { let (endpoint, headers) = prepare_client_config(endpoint, &key)?; let mut reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(self._accept_invalid_certs) - .connect_timeout(self._connect_timeout) - .timeout(self._request_timeout) + .danger_accept_invalid_certs(self.accept_invalid_certs) + .connect_timeout(self.connect_timeout) + .timeout(self.request_timeout) .default_headers(headers); - if let Some(user_agent) = self._user_agent { + if let Some(user_agent) = self.user_agent { reqwest_agent = reqwest_agent.user_agent(user_agent); } @@ -364,9 +364,7 @@ impl SupergraphFetcherBuilder { .build() .map_err(SupergraphFetcherError::FetcherCreationError)?; let reqwest_client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy( - self._retry_policy, - )) + .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) .build(); Ok(SupergraphFetcher { From 6417b9e605141b86e654e9305d38f73c66f77687 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 5 Dec 2025 18:54:20 +0300 Subject: [PATCH 04/16] Lets go --- packages/libraries/sdk-rs/src/persisted_documents.rs | 6 ------ packages/libraries/sdk-rs/src/supergraph_fetcher.rs | 1 + 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/libraries/sdk-rs/src/persisted_documents.rs b/packages/libraries/sdk-rs/src/persisted_documents.rs index 17ae0b13ca1..dfb59a73f2e 100644 --- a/packages/libraries/sdk-rs/src/persisted_documents.rs +++ b/packages/libraries/sdk-rs/src/persisted_documents.rs @@ -137,15 +137,9 @@ pub struct PersistedDocumentsManagerBuilder { endpoint: Option, accept_invalid_certs: bool, connect_timeout: Duration, - /// Request timeout for the Hive Console CDN requests. request_timeout: Duration, - /// Interval at which the Hive Console should be retried upon failure. - /// - /// By default, an exponential backoff retry policy is used, with 3 attempts. retry_policy: ExponentialBackoff, - /// Configuration for the size of the in-memory caching of persisted documents. cache_size: u64, - /// User-Agent header to be sent with each request user_agent: Option, } diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs index 29a0b973df4..38e333b1b05 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs @@ -240,6 +240,7 @@ impl SupergraphFetcherBuilder { self } + /// User-Agent header to be sent with each request pub fn user_agent(mut self, user_agent: String) -> Self { self.user_agent = Some(user_agent); self From ca7572e32e3786369b66ea8b8a205564e3db72ac Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 5 Dec 2025 18:57:02 +0300 Subject: [PATCH 05/16] Lets go --- .../libraries/sdk-rs/src/persisted_documents.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/libraries/sdk-rs/src/persisted_documents.rs b/packages/libraries/sdk-rs/src/persisted_documents.rs index dfb59a73f2e..7db4364c22b 100644 --- a/packages/libraries/sdk-rs/src/persisted_documents.rs +++ b/packages/libraries/sdk-rs/src/persisted_documents.rs @@ -35,6 +35,10 @@ pub enum PersistedDocumentsError { PersistedDocumentRequired, #[error("Missing required configuration option: {0}")] MissingConfigurationOption(String), + #[error("Invalid CDN key {0}")] + InvalidCDNKey(String), + #[error("Failed to create HTTP client: {0}")] + HTTPClientCreationError(reqwest::Error), } impl PersistedDocumentsError { @@ -58,6 +62,10 @@ impl PersistedDocumentsError { PersistedDocumentsError::MissingConfigurationOption(_) => { "MISSING_CONFIGURATION_OPTION".into() } + PersistedDocumentsError::InvalidCDNKey(_) => "INVALID_CDN_KEY".into(), + PersistedDocumentsError::HTTPClientCreationError(_) => { + "HTTP_CLIENT_CREATION_ERROR".into() + } } } } @@ -229,7 +237,11 @@ impl PersistedDocumentsManagerBuilder { )); } }; - default_headers.insert("X-Hive-CDN-Key", HeaderValue::from_str(&key).unwrap()); + default_headers.insert( + "X-Hive-CDN-Key", + HeaderValue::from_str(&key) + .map_err(|e| PersistedDocumentsError::InvalidCDNKey(e.to_string()))?, + ); let mut reqwest_agent = reqwest::Client::builder() .danger_accept_invalid_certs(self.accept_invalid_certs) .connect_timeout(self.connect_timeout) @@ -242,7 +254,7 @@ impl PersistedDocumentsManagerBuilder { let reqwest_agent = reqwest_agent .build() - .expect("Failed to create reqwest client"); + .map_err(PersistedDocumentsError::HTTPClientCreationError)?; let client = ClientBuilder::new(reqwest_agent) .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) .build(); From 47e5a19dd3ca399ef6e35f8cea53e9b7c37ea96c Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 5 Dec 2025 21:00:32 +0300 Subject: [PATCH 06/16] feat(sdk-rs): circuit breaker and multiple endpoints --- .changeset/light-walls-vanish.md | 20 ++ .changeset/violet-waves-happen.md | 17 + configs/cargo/Cargo.lock | 12 + .../router/src/persisted_documents.rs | 39 ++- packages/libraries/router/src/registry.rs | 23 +- packages/libraries/sdk-rs/Cargo.toml | 2 + .../libraries/sdk-rs/src/agent/builder.rs | 13 + .../libraries/sdk-rs/src/agent/usage_agent.rs | 19 +- .../libraries/sdk-rs/src/circuit_breaker.rs | 78 +++++ packages/libraries/sdk-rs/src/lib.rs | 1 + .../sdk-rs/src/persisted_documents.rs | 155 ++++++--- .../sdk-rs/src/supergraph_fetcher.rs | 316 +++++++++++------- 12 files changed, 499 insertions(+), 196 deletions(-) create mode 100644 .changeset/light-walls-vanish.md create mode 100644 .changeset/violet-waves-happen.md create mode 100644 packages/libraries/sdk-rs/src/circuit_breaker.rs diff --git a/.changeset/light-walls-vanish.md b/.changeset/light-walls-vanish.md new file mode 100644 index 00000000000..3f65db264e0 --- /dev/null +++ b/.changeset/light-walls-vanish.md @@ -0,0 +1,20 @@ +--- +'hive-console-sdk-rs': minor +--- + +Circuit Breaker Implementation and Multiple Endpoints Support + +Implementation of Circuit Breakers in Hive Console Rust SDK, you can learn more [here](https://the-guild.dev/graphql/hive/product-updates/2025-12-04-cdn-mirror-and-circuit-breaker) + +Breaking Changes: + +Now `endpoint` configuration accepts multiple endpoints as an array for `SupergraphFetcherBuilder` and `PersistedDocumentsManager`. + +```diff +SupergraphFetcherBuilder::default() +- .endpoint(endpoint) ++ .add_endpoint(endpoint1) ++ .add_endpoint(endpoint2) +``` + +This change requires updating the configuration structure to accommodate multiple endpoints. diff --git a/.changeset/violet-waves-happen.md b/.changeset/violet-waves-happen.md new file mode 100644 index 00000000000..67aabe639d1 --- /dev/null +++ b/.changeset/violet-waves-happen.md @@ -0,0 +1,17 @@ +--- +'hive-apollo-router-plugin': major +--- + +- Multiple endpoints support for `HiveRegistry` and `PersistedOperationsPlugin` + +Breaking Changes: +- Now there is no `endpoint` field in the configuration, it has been replaced with `endpoints`, which is an array of strings. You are not affected if you use environment variables to set the endpoint. + +```diff +HiveRegistry::new( + Some( + HiveRegistryConfig { +- endpoint: String::from("CDN_ENDPOINT"), ++ endpoints: vec![String::from("CDN_ENDPOINT1"), String::from("CDN_ENDPOINT2")], + ) +) diff --git a/configs/cargo/Cargo.lock b/configs/cargo/Cargo.lock index 43d83b3e62e..101fb9345c3 100644 --- a/configs/cargo/Cargo.lock +++ b/configs/cargo/Cargo.lock @@ -2652,12 +2652,14 @@ dependencies = [ "anyhow", "async-trait", "axum-core 0.5.5", + "futures-util", "graphql-parser", "graphql-tools", "md5", "mockito", "moka", "once_cell", + "recloser", "regex-automata", "reqwest", "reqwest-middleware", @@ -4673,6 +4675,16 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "recloser" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ac0d06281c3556fea72cef9e5372d9ac172335be0d71c3b4f3db900483e0eb" +dependencies = [ + "crossbeam-epoch", + "pin-project", +] + [[package]] name = "redis-protocol" version = "6.0.0" diff --git a/packages/libraries/router/src/persisted_documents.rs b/packages/libraries/router/src/persisted_documents.rs index c47f4cc3eeb..1fa5daaf30c 100644 --- a/packages/libraries/router/src/persisted_documents.rs +++ b/packages/libraries/router/src/persisted_documents.rs @@ -32,7 +32,7 @@ pub static PERSISTED_DOCUMENT_HASH_KEY: &str = "hive::persisted_document_hash"; pub struct Config { pub enabled: Option, /// GraphQL Hive persisted documents CDN endpoint URL. - pub endpoint: Option, + pub endpoint: Option, /// GraphQL Hive persisted documents CDN access token. pub key: Option, /// Whether arbitrary documents should be allowed along-side persisted documents. @@ -57,6 +57,25 @@ pub struct Config { pub cache_size: Option, } +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum EndpointConfig { + Single(String), + Multiple(Vec), +} + +impl From<&str> for EndpointConfig { + fn from(value: &str) -> Self { + EndpointConfig::Single(value.into()) + } +} + +impl From<&[&str]> for EndpointConfig { + fn from(value: &[&str]) -> Self { + EndpointConfig::Multiple(value.iter().map(|s| s.to_string()).collect()) + } +} + pub struct PersistedDocumentsPlugin { persisted_documents_manager: Option>, allow_arbitrary_documents: bool, @@ -72,11 +91,14 @@ impl PersistedDocumentsPlugin { allow_arbitrary_documents, }); } - let endpoint = match &config.endpoint { - Some(ep) => ep.clone(), + let endpoints = match &config.endpoint { + Some(ep) => match ep { + EndpointConfig::Single(url) => vec![url.clone()], + EndpointConfig::Multiple(urls) => urls.clone(), + }, None => { if let Ok(ep) = env::var("HIVE_CDN_ENDPOINT") { - ep + vec![ep] } else { return Err( "Endpoint for persisted documents CDN is not configured. Please set it via the plugin configuration or HIVE_CDN_ENDPOINT environment variable." @@ -102,9 +124,12 @@ impl PersistedDocumentsPlugin { let mut persisted_documents_manager = PersistedDocumentsManager::builder() .key(key) - .endpoint(endpoint) .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)); + for endpoint in endpoints { + persisted_documents_manager = persisted_documents_manager.add_endpoint(endpoint); + } + if let Some(connect_timeout) = config.connect_timeout { persisted_documents_manager = persisted_documents_manager.connect_timeout(Duration::from_secs(connect_timeout)); @@ -365,8 +390,8 @@ mod hive_persisted_documents_tests { Self { server } } - fn endpoint(&self) -> String { - self.server.url("") + fn endpoint(&self) -> EndpointConfig { + EndpointConfig::Single(self.server.url("")) } /// Registers a valid artifact URL with an actual GraphQL document diff --git a/packages/libraries/router/src/registry.rs b/packages/libraries/router/src/registry.rs index ec8a2879f7e..70381a02923 100644 --- a/packages/libraries/router/src/registry.rs +++ b/packages/libraries/router/src/registry.rs @@ -18,7 +18,7 @@ pub struct HiveRegistry { } pub struct HiveRegistryConfig { - endpoint: Option, + endpoints: Vec, key: Option, poll_interval: Option, accept_invalid_certs: Option, @@ -29,7 +29,7 @@ impl HiveRegistry { #[allow(clippy::new_ret_no_self)] pub fn new(user_config: Option) -> Result<()> { let mut config = HiveRegistryConfig { - endpoint: None, + endpoints: vec![], key: None, poll_interval: None, accept_invalid_certs: Some(true), @@ -38,7 +38,7 @@ impl HiveRegistry { // Pass values from user's config if let Some(user_config) = user_config { - config.endpoint = user_config.endpoint; + config.endpoints = user_config.endpoints; config.key = user_config.key; config.poll_interval = user_config.poll_interval; config.accept_invalid_certs = user_config.accept_invalid_certs; @@ -47,9 +47,9 @@ impl HiveRegistry { // Pass values from environment variables if they are not set in the user's config - if config.endpoint.is_none() { + if config.endpoints.is_empty() { if let Ok(endpoint) = env::var("HIVE_CDN_ENDPOINT") { - config.endpoint = Some(endpoint); + config.endpoints.push(endpoint); } } @@ -86,7 +86,7 @@ impl HiveRegistry { } // Resolve values - let endpoint = config.endpoint.unwrap_or_default(); + let endpoint = config.endpoints; let key = config.key.unwrap_or_default(); let poll_interval: u64 = config.poll_interval.unwrap_or(10); let accept_invalid_certs = config.accept_invalid_certs.unwrap_or(false); @@ -125,11 +125,16 @@ impl HiveRegistry { env::set_var("APOLLO_ROUTER_HOT_RELOAD", "true"); } - let fetcher = SupergraphFetcherBuilder::new() - .endpoint(endpoint) + let mut fetcher = SupergraphFetcherBuilder::new() .key(key) .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)) - .accept_invalid_certs(accept_invalid_certs) + .accept_invalid_certs(accept_invalid_certs); + + for ep in endpoint { + fetcher = fetcher.add_endpoint(ep); + } + + let fetcher = fetcher .build_sync() .map_err(|e| anyhow!("Failed to create SupergraphFetcher: {}", e))?; diff --git a/packages/libraries/sdk-rs/Cargo.toml b/packages/libraries/sdk-rs/Cargo.toml index 32cd34c3cb2..50606e1aec0 100644 --- a/packages/libraries/sdk-rs/Cargo.toml +++ b/packages/libraries/sdk-rs/Cargo.toml @@ -35,6 +35,8 @@ tokio-util = "0.7.16" regex-automata = "0.4.10" once_cell = "1.21.3" retry-policies = "0.5.0" +recloser = "1.3.1" +futures-util = "0.3.31" [dev-dependencies] mockito = "1.7.0" diff --git a/packages/libraries/sdk-rs/src/agent/builder.rs b/packages/libraries/sdk-rs/src/agent/builder.rs index 576718b9506..c0824e1f711 100644 --- a/packages/libraries/sdk-rs/src/agent/builder.rs +++ b/packages/libraries/sdk-rs/src/agent/builder.rs @@ -1,12 +1,14 @@ use std::{sync::Arc, time::Duration}; use once_cell::sync::Lazy; +use recloser::AsyncRecloser; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest_middleware::ClientBuilder; use reqwest_retry::RetryTransientMiddleware; use crate::agent::usage_agent::{non_empty_string, AgentError, Buffer, UsageAgent}; use crate::agent::utils::OperationProcessor; +use crate::circuit_breaker; use retry_policies::policies::ExponentialBackoff; pub struct UsageAgentBuilder { @@ -20,6 +22,7 @@ pub struct UsageAgentBuilder { flush_interval: Duration, retry_policy: ExponentialBackoff, user_agent: Option, + circuit_breaker: Option, } pub static DEFAULT_HIVE_USAGE_ENDPOINT: &str = "https://app.graphql-hive.com/usage"; @@ -37,6 +40,7 @@ impl Default for UsageAgentBuilder { flush_interval: Duration::from_secs(5), retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), user_agent: None, + circuit_breaker: None, } } } @@ -159,12 +163,21 @@ impl UsageAgentBuilder { _ => {} } + let circuit_breaker = if let Some(cb) = self.circuit_breaker { + cb + } else { + circuit_breaker::CircuitBreakerBuilder::default() + .build_async() + .map_err(AgentError::CircuitBreakerCreationError)? + }; + Ok(Arc::new(UsageAgent { endpoint, buffer: Buffer::new(self.buffer_size), processor: OperationProcessor::new(), client, flush_interval: self.flush_interval, + circuit_breaker, })) } else { Err(AgentError::MissingToken) diff --git a/packages/libraries/sdk-rs/src/agent/usage_agent.rs b/packages/libraries/sdk-rs/src/agent/usage_agent.rs index 34fbe07df3b..3e532ee6189 100644 --- a/packages/libraries/sdk-rs/src/agent/usage_agent.rs +++ b/packages/libraries/sdk-rs/src/agent/usage_agent.rs @@ -1,4 +1,5 @@ use graphql_parser::schema::Document; +use recloser::AsyncRecloser; use reqwest_middleware::ClientWithMiddleware; use serde::{Deserialize, Serialize}; use std::{ @@ -118,6 +119,7 @@ pub struct UsageAgent { pub(crate) processor: OperationProcessor, pub(crate) client: ClientWithMiddleware, pub(crate) flush_interval: Duration, + pub(crate) circuit_breaker: AsyncRecloser, } pub fn non_empty_string(value: Option) -> Option { @@ -146,6 +148,10 @@ pub enum AgentError { InvalidTargetId(String), #[error("unable to instantiate the http client for reports sending: {0}")] HTTPClientCreationError(reqwest::Error), + #[error("unable to create circuit breaker: {0}")] + CircuitBreakerCreationError(#[from] crate::circuit_breaker::CircuitBreakerError), + #[error("rejected by the circuit breaker")] + CircuitBreakerRejected, #[error("unable to send report: {0}")] Unknown(String), } @@ -234,14 +240,21 @@ impl UsageAgent { let report_body = serde_json::to_vec(&report).map_err(|e| AgentError::Unknown(e.to_string()))?; // Based on https://the-guild.dev/graphql/hive/docs/specs/usage-reports#data-structure - let resp = self + let resp_fut = self .client .post(&self.endpoint) .header(reqwest::header::CONTENT_LENGTH, report_body.len()) .body(report_body) - .send() + .send(); + + let resp = self + .circuit_breaker + .call(resp_fut) .await - .map_err(|e| AgentError::Unknown(e.to_string()))?; + .map_err(|e| match e { + recloser::Error::Inner(e) => AgentError::Unknown(e.to_string()), + recloser::Error::Rejected => AgentError::CircuitBreakerRejected, + })?; match resp.status() { reqwest::StatusCode::OK => Ok(()), diff --git a/packages/libraries/sdk-rs/src/circuit_breaker.rs b/packages/libraries/sdk-rs/src/circuit_breaker.rs new file mode 100644 index 00000000000..d8186748282 --- /dev/null +++ b/packages/libraries/sdk-rs/src/circuit_breaker.rs @@ -0,0 +1,78 @@ +use std::time::Duration; + +use recloser::{AsyncRecloser, Recloser}; + +#[derive(Clone)] +pub struct CircuitBreakerBuilder { + error_threshold: f32, + volume_threshold: usize, + reset_timeout: Duration, +} + +impl Default for CircuitBreakerBuilder { + fn default() -> Self { + Self { + error_threshold: 0.5, + volume_threshold: 5, + reset_timeout: Duration::from_secs(30), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CircuitBreakerError { + #[error("Invalid error threshold: {0}. It must be between 0.0 and 1.0")] + InvalidErrorThreshold(f32), +} + +impl CircuitBreakerBuilder { + /// Percentage after what the circuit breaker should kick in. + /// Default: .5 + pub fn error_threshold(mut self, percentage: f32) -> Self { + self.error_threshold = percentage; + self + } + /// Count of requests before starting evaluating. + /// Default: 5 + pub fn volume_threshold(mut self, threshold: usize) -> Self { + self.volume_threshold = threshold; + self + } + // After what time the circuit breaker is attempting to retry sending requests in milliseconds. + /// Default: 30s + pub fn reset_timeout(mut self, timeout: Duration) -> Self { + self.reset_timeout = timeout; + self + } + + pub fn build_async(self) -> Result { + let error_threshold = if self.error_threshold < 0.0 || self.error_threshold > 1.0 { + return Err(CircuitBreakerError::InvalidErrorThreshold( + self.error_threshold, + )); + } else { + self.error_threshold + }; + let recloser = Recloser::custom() + .error_rate(error_threshold) + .closed_len(self.volume_threshold) + .open_wait(self.reset_timeout) + .build(); + Ok(AsyncRecloser::from(recloser)) + } + pub fn build_sync(self) -> Result { + let error_threshold = if self.error_threshold < 0.0 || self.error_threshold > 1.0 { + return Err(CircuitBreakerError::InvalidErrorThreshold( + self.error_threshold, + )); + } else { + self.error_threshold + }; + let recloser = Recloser::custom() + .error_rate(error_threshold) + .closed_len(self.volume_threshold) + .open_wait(self.reset_timeout) + .build(); + Ok(recloser) + } +} diff --git a/packages/libraries/sdk-rs/src/lib.rs b/packages/libraries/sdk-rs/src/lib.rs index d5a593ee98a..0201c9cc2ca 100644 --- a/packages/libraries/sdk-rs/src/lib.rs +++ b/packages/libraries/sdk-rs/src/lib.rs @@ -1,3 +1,4 @@ pub mod agent; +pub mod circuit_breaker; pub mod persisted_documents; pub mod supergraph_fetcher; diff --git a/packages/libraries/sdk-rs/src/persisted_documents.rs b/packages/libraries/sdk-rs/src/persisted_documents.rs index 7db4364c22b..4a5aab95ccb 100644 --- a/packages/libraries/sdk-rs/src/persisted_documents.rs +++ b/packages/libraries/sdk-rs/src/persisted_documents.rs @@ -1,7 +1,9 @@ use std::time::Duration; use crate::agent::usage_agent::non_empty_string; +use crate::circuit_breaker::CircuitBreakerBuilder; use moka::future::Cache; +use recloser::AsyncRecloser; use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; use reqwest_middleware::ClientBuilder; @@ -14,7 +16,7 @@ use tracing::{debug, info, warn}; pub struct PersistedDocumentsManager { client: ClientWithMiddleware, cache: Cache, - endpoint: String, + endpoints_with_circuit_breakers: Vec<(String, AsyncRecloser)>, } #[derive(Debug, thiserror::Error)] @@ -39,6 +41,12 @@ pub enum PersistedDocumentsError { InvalidCDNKey(String), #[error("Failed to create HTTP client: {0}")] HTTPClientCreationError(reqwest::Error), + #[error("unable to create circuit breaker: {0}")] + CircuitBreakerCreationError(#[from] crate::circuit_breaker::CircuitBreakerError), + #[error("rejected by the circuit breaker")] + CircuitBreakerRejected, + #[error("unknown error")] + Unknown, } impl PersistedDocumentsError { @@ -66,6 +74,11 @@ impl PersistedDocumentsError { PersistedDocumentsError::HTTPClientCreationError(_) => { "HTTP_CLIENT_CREATION_ERROR".into() } + PersistedDocumentsError::CircuitBreakerCreationError(_) => { + "CIRCUIT_BREAKER_CREATION_ERROR".into() + } + PersistedDocumentsError::CircuitBreakerRejected => "CIRCUIT_BREAKER_REJECTED".into(), + PersistedDocumentsError::Unknown => "UNKNOWN_ERROR".into(), } } } @@ -74,6 +87,55 @@ impl PersistedDocumentsManager { pub fn builder() -> PersistedDocumentsManagerBuilder { PersistedDocumentsManagerBuilder::default() } + async fn resolve_from_endpoint( + &self, + endpoint: &str, + document_id: &str, + circuit_breaker: &AsyncRecloser, + ) -> Result { + let cdn_document_id = str::replace(document_id, "~", "/"); + let cdn_artifact_url = format!("{}/apps/{}", endpoint, cdn_document_id); + info!( + "Fetching document {} from CDN: {}", + document_id, cdn_artifact_url + ); + let response_fut = self.client.get(cdn_artifact_url).send(); + + let response = circuit_breaker + .call(response_fut) + .await + .map_err(|e| match e { + recloser::Error::Inner(e) => PersistedDocumentsError::FailedToFetchFromCDN(e), + recloser::Error::Rejected => PersistedDocumentsError::CircuitBreakerRejected, + })?; + + if response.status().is_success() { + let document = response + .text() + .await + .map_err(PersistedDocumentsError::FailedToReadCDNResponse)?; + debug!( + "Document fetched from CDN: {}, storing in local cache", + document + ); + self.cache + .insert(document_id.into(), document.clone()) + .await; + + return Ok(document); + } + + warn!( + "Document fetch from CDN failed: HTTP {}, Body: {:?}", + response.status(), + response + .text() + .await + .unwrap_or_else(|_| "Unavailable".to_string()) + ); + + Err(PersistedDocumentsError::DocumentNotFound) + } /// Resolves the document from the cache, or from the CDN pub async fn resolve_document( &self, @@ -92,49 +154,22 @@ impl PersistedDocumentsManager { "Document {} not found in cache. Fetching from CDN", document_id ); - let cdn_document_id = str::replace(document_id, "~", "/"); - let cdn_artifact_url = format!("{}/apps/{}", &self.endpoint, cdn_document_id); - info!( - "Fetching document {} from CDN: {}", - document_id, cdn_artifact_url - ); - let cdn_response = self.client.get(cdn_artifact_url).send().await; - - match cdn_response { - Ok(response) => { - if response.status().is_success() { - let document = response - .text() - .await - .map_err(PersistedDocumentsError::FailedToReadCDNResponse)?; - debug!( - "Document fetched from CDN: {}, storing in local cache", - document - ); - self.cache - .insert(document_id.into(), document.clone()) - .await; - - return Ok(document); + let mut last_error: Option = None; + for (endpoint, circuit_breaker) in &self.endpoints_with_circuit_breakers { + let result = self + .resolve_from_endpoint(endpoint, document_id, circuit_breaker) + .await; + match result { + Ok(document) => return Ok(document), + Err(e) => { + last_error = Some(e); } - - warn!( - "Document fetch from CDN failed: HTTP {}, Body: {:?}", - response.status(), - response - .text() - .await - .unwrap_or_else(|_| "Unavailable".to_string()) - ); - - Err(PersistedDocumentsError::DocumentNotFound) - } - Err(e) => { - warn!("Failed to fetch document from CDN: {:?}", e); - - Err(PersistedDocumentsError::FailedToFetchFromCDN(e)) } } + match last_error { + Some(e) => Err(e), + None => Err(PersistedDocumentsError::Unknown), + } } } } @@ -142,26 +177,28 @@ impl PersistedDocumentsManager { pub struct PersistedDocumentsManagerBuilder { key: Option, - endpoint: Option, + endpoints: Vec, accept_invalid_certs: bool, connect_timeout: Duration, request_timeout: Duration, retry_policy: ExponentialBackoff, cache_size: u64, user_agent: Option, + circuit_breaker: CircuitBreakerBuilder, } impl Default for PersistedDocumentsManagerBuilder { fn default() -> Self { Self { key: None, - endpoint: None, + endpoints: vec![], accept_invalid_certs: false, connect_timeout: Duration::from_secs(5), request_timeout: Duration::from_secs(15), retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), cache_size: 10_000, user_agent: None, + circuit_breaker: CircuitBreakerBuilder::default(), } } } @@ -174,8 +211,10 @@ impl PersistedDocumentsManagerBuilder { } /// The CDN endpoint from Hive Console target. - pub fn endpoint(mut self, endpoint: String) -> Self { - self.endpoint = non_empty_string(Some(endpoint)); + pub fn add_endpoint(mut self, endpoint: String) -> Self { + if let Some(endpoint) = non_empty_string(Some(endpoint)) { + self.endpoints.push(endpoint); + } self } @@ -261,19 +300,27 @@ impl PersistedDocumentsManagerBuilder { let cache = Cache::::new(self.cache_size); - let endpoint = match self.endpoint { - Some(endpoint) => endpoint, - None => { - return Err(PersistedDocumentsError::MissingConfigurationOption( - "endpoint".to_string(), - )); - } - }; + if self.endpoints.is_empty() { + return Err(PersistedDocumentsError::MissingConfigurationOption( + "endpoints".to_string(), + )); + } Ok(PersistedDocumentsManager { client, cache, - endpoint, + endpoints_with_circuit_breakers: self + .endpoints + .into_iter() + .map(move |endpoint| { + let circuit_breaker = self + .circuit_breaker + .clone() + .build_async() + .map_err(PersistedDocumentsError::CircuitBreakerCreationError)?; + Ok((endpoint, circuit_breaker)) + }) + .collect::, PersistedDocumentsError>>()?, }) } } diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs index 38e333b1b05..6d0c199f183 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs @@ -3,6 +3,9 @@ use std::sync::RwLock; use std::time::Duration; use std::time::SystemTime; +use crate::agent::usage_agent::non_empty_string; +use recloser::AsyncRecloser; +use recloser::Recloser; use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; use reqwest::header::InvalidHeaderValue; @@ -14,10 +17,11 @@ use reqwest_retry::RetryPolicy; use reqwest_retry::RetryTransientMiddleware; use retry_policies::policies::ExponentialBackoff; +use crate::circuit_breaker::CircuitBreakerBuilder; + #[derive(Debug)] pub struct SupergraphFetcher { client: SupergraphFetcherAsyncOrSyncClient, - endpoint: String, etag: RwLock>, state: std::marker::PhantomData, } @@ -30,9 +34,11 @@ pub struct SupergraphFetcherSyncState; #[derive(Debug)] enum SupergraphFetcherAsyncOrSyncClient { Async { + endpoints_with_circuit_breakers: Vec<(String, AsyncRecloser)>, reqwest_client: ClientWithMiddleware, }, Sync { + endpoints_with_circuit_breakers: Vec<(String, Recloser)>, reqwest_client: reqwest::blocking::Client, retry_policy: ExponentialBackoff, }, @@ -45,6 +51,7 @@ pub enum SupergraphFetcherError { Lock(String), InvalidKey(InvalidHeaderValue), MissingConfigurationOption(String), + RejectedByCircuitBreaker, } impl Display for SupergraphFetcherError { @@ -62,114 +69,153 @@ impl Display for SupergraphFetcherError { SupergraphFetcherError::MissingConfigurationOption(e) => { write!(f, "Missing configuration option: {}", e) } + SupergraphFetcherError::RejectedByCircuitBreaker => { + write!(f, "Request rejected by circuit breaker") + } } } } -fn prepare_client_config( - mut endpoint: String, - key: &str, -) -> Result<(String, HeaderMap), SupergraphFetcherError> { - if !endpoint.ends_with("/supergraph") { - if endpoint.ends_with("/") { - endpoint.push_str("supergraph"); - } else { - endpoint.push_str("/supergraph"); - } - } - - let mut headers = HeaderMap::new(); - let mut cdn_key_header = - HeaderValue::from_str(key).map_err(SupergraphFetcherError::InvalidKey)?; - cdn_key_header.set_sensitive(true); - headers.insert("X-Hive-CDN-Key", cdn_key_header); - - Ok((endpoint, headers)) -} - impl SupergraphFetcher { + fn fetch_from_endpoint( + &self, + reqwest_client: &reqwest::blocking::Client, + endpoint: &str, + circuit_breaker: &Recloser, + retry_policy: &ExponentialBackoff, + ) -> Result { + circuit_breaker + .call(|| { + let request_start_time = SystemTime::now(); + // Implementing retry logic for sync client + let mut n_past_retries = 0; + loop { + let mut req = reqwest_client.get(endpoint); + let etag = self.get_latest_etag()?; + if let Some(etag) = etag { + req = req.header(IF_NONE_MATCH, etag); + } + let response = req.send(); + + match response { + Ok(resp) => break Ok(resp), + Err(e) => { + match retry_policy.should_retry(request_start_time, n_past_retries) { + RetryDecision::DoNotRetry => { + return Err(SupergraphFetcherError::NetworkError( + reqwest_middleware::Error::Reqwest(e), + )); + } + RetryDecision::Retry { execute_after } => { + n_past_retries += 1; + if let Ok(duration) = execute_after.elapsed() { + std::thread::sleep(duration); + } + } + } + } + } + } + }) + .map_err(|e| match e { + recloser::Error::Inner(e) => e, + recloser::Error::Rejected => SupergraphFetcherError::RejectedByCircuitBreaker, + }) + } pub fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { - let request_start_time = SystemTime::now(); - // Implementing retry logic for sync client - let mut n_past_retries = 0; - let (reqwest_client, retry_policy) = match &self.client { + let (endpoints_with_circuit_breakers, reqwest_client, retry_policy) = match &self.client { SupergraphFetcherAsyncOrSyncClient::Sync { + endpoints_with_circuit_breakers, + reqwest_client, + retry_policy, + } => ( + endpoints_with_circuit_breakers, reqwest_client, retry_policy, - } => (reqwest_client, retry_policy), + ), _ => unreachable!(), }; - let resp = loop { - let mut req = reqwest_client.get(&self.endpoint); - let etag = self.get_latest_etag()?; - if let Some(etag) = etag { - req = req.header(IF_NONE_MATCH, etag); - } - let response = req.send(); - - match response { - Ok(resp) => break resp, - Err(e) => match retry_policy.should_retry(request_start_time, n_past_retries) { - RetryDecision::DoNotRetry => { - return Err(SupergraphFetcherError::NetworkError( - reqwest_middleware::Error::Reqwest(e), - )); - } - RetryDecision::Retry { execute_after } => { - n_past_retries += 1; - if let Ok(duration) = execute_after.elapsed() { - std::thread::sleep(duration); - } - } - }, + let mut last_error: Option = None; + let mut last_resp = None; + for (endpoint, circuit_breaker) in endpoints_with_circuit_breakers { + let resp = + self.fetch_from_endpoint(reqwest_client, endpoint, circuit_breaker, retry_policy); + match resp { + Err(e) => { + last_error = Some(e); + continue; + } + Ok(resp) => { + last_resp = Some(resp); + break; + } } - }; - - if resp.status().as_u16() == 304 { - return Ok(None); } - let etag = resp.headers().get("etag"); - self.update_latest_etag(etag)?; - - let text = resp - .text() - .map_err(SupergraphFetcherError::NetworkResponseError)?; - - Ok(Some(text)) + if let Some(last_resp) = last_resp { + if last_resp.status().as_u16() == 304 { + return Ok(None); + } + self.update_latest_etag(last_resp.headers().get("etag"))?; + let text = last_resp + .text() + .map_err(SupergraphFetcherError::NetworkResponseError)?; + Ok(Some(text)) + } else if let Some(error) = last_error { + Err(error) + } else { + Ok(None) + } } } impl SupergraphFetcher { pub async fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { - let reqwest_client = match &self.client { - SupergraphFetcherAsyncOrSyncClient::Async { reqwest_client } => reqwest_client, + let (endpoints_with_circuit_breakers, reqwest_client) = match &self.client { + SupergraphFetcherAsyncOrSyncClient::Async { + endpoints_with_circuit_breakers, + reqwest_client, + } => (endpoints_with_circuit_breakers, reqwest_client), _ => unreachable!(), }; - let mut req = reqwest_client.get(&self.endpoint); - let etag = self.get_latest_etag()?; - if let Some(etag) = etag { - req = req.header(IF_NONE_MATCH, etag); + let mut last_error: Option = None; + let mut last_resp = None; + for (endpoint, circuit_breaker) in endpoints_with_circuit_breakers { + let mut req = reqwest_client.get(endpoint); + let etag = self.get_latest_etag()?; + if let Some(etag) = etag { + req = req.header(IF_NONE_MATCH, etag); + } + let resp = circuit_breaker.call(req.send()).await; + match resp { + Err(recloser::Error::Inner(e)) => { + last_error = Some(SupergraphFetcherError::NetworkError(e)); + continue; + } + Err(recloser::Error::Rejected) => { + last_error = Some(SupergraphFetcherError::RejectedByCircuitBreaker); + continue; + } + Ok(resp) => { + last_resp = Some(resp); + break; + } + } } - let resp = req - .send() - .await - .map_err(SupergraphFetcherError::NetworkError)?; - - if resp.status().as_u16() == 304 { - return Ok(None); + if let Some(last_resp) = last_resp { + let etag = last_resp.headers().get("etag"); + self.update_latest_etag(etag)?; + let text = last_resp + .text() + .await + .map_err(SupergraphFetcherError::NetworkResponseError)?; + Ok(Some(text)) + } else if let Some(error) = last_error { + Err(error) + } else { + Ok(None) } - - let etag = resp.headers().get("etag"); - self.update_latest_etag(etag)?; - - let text = resp - .text() - .await - .map_err(SupergraphFetcherError::NetworkResponseError)?; - - Ok(Some(text)) } } @@ -200,25 +246,27 @@ impl SupergraphFetcher { } pub struct SupergraphFetcherBuilder { - endpoint: Option, + endpoints: Vec, key: Option, user_agent: Option, connect_timeout: Duration, request_timeout: Duration, accept_invalid_certs: bool, retry_policy: ExponentialBackoff, + circuit_breaker: CircuitBreakerBuilder, } impl Default for SupergraphFetcherBuilder { fn default() -> Self { Self { - endpoint: None, + endpoints: vec![], key: None, user_agent: None, connect_timeout: Duration::from_secs(5), request_timeout: Duration::from_secs(60), accept_invalid_certs: false, retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), + circuit_breaker: CircuitBreakerBuilder::default(), } } } @@ -229,8 +277,17 @@ impl SupergraphFetcherBuilder { } /// The CDN endpoint from Hive Console target. - pub fn endpoint(mut self, endpoint: String) -> Self { - self.endpoint = Some(endpoint); + pub fn add_endpoint(mut self, endpoint: String) -> Self { + if let Some(mut endpoint) = non_empty_string(Some(endpoint)) { + if !endpoint.ends_with("/supergraph") { + if endpoint.ends_with("/") { + endpoint.push_str("supergraph"); + } else { + endpoint.push_str("/supergraph"); + } + } + self.endpoints.push(endpoint); + } self } @@ -281,19 +338,17 @@ impl SupergraphFetcherBuilder { self } - /// Builds a synchronous SupergraphFetcher - pub fn build_sync( - self, - ) -> Result, SupergraphFetcherError> { - let endpoint = match self.endpoint { - Some(endpoint) => endpoint, - None => { - return Err(SupergraphFetcherError::MissingConfigurationOption( - "endpoint".to_string(), - )) - } - }; - let key = match self.key { + fn validate_endpoints(&self) -> Result<(), SupergraphFetcherError> { + if self.endpoints.is_empty() { + return Err(SupergraphFetcherError::MissingConfigurationOption( + "endpoint".to_string(), + )); + } + Ok(()) + } + + fn prepare_headers(&self) -> Result { + let key = match &self.key { Some(key) => key, None => { return Err(SupergraphFetcherError::MissingConfigurationOption( @@ -301,7 +356,21 @@ impl SupergraphFetcherBuilder { )) } }; - let (endpoint, headers) = prepare_client_config(endpoint, &key)?; + let mut headers = HeaderMap::new(); + let mut cdn_key_header = + HeaderValue::from_str(key).map_err(SupergraphFetcherError::InvalidKey)?; + cdn_key_header.set_sensitive(true); + headers.insert("X-Hive-CDN-Key", cdn_key_header); + + Ok(headers) + } + + /// Builds a synchronous SupergraphFetcher + pub fn build_sync( + self, + ) -> Result, SupergraphFetcherError> { + self.validate_endpoints()?; + let headers = self.prepare_headers()?; let mut reqwest_client = reqwest::blocking::Client::builder() .danger_accept_invalid_certs(self.accept_invalid_certs) @@ -320,8 +389,15 @@ impl SupergraphFetcherBuilder { client: SupergraphFetcherAsyncOrSyncClient::Sync { reqwest_client, retry_policy: self.retry_policy, + endpoints_with_circuit_breakers: self + .endpoints + .into_iter() + .map(|endpoint| { + let circuit_breaker = self.circuit_breaker.clone().build_sync().unwrap(); + (endpoint, circuit_breaker) + }) + .collect(), }, - endpoint, etag: RwLock::new(None), state: std::marker::PhantomData, }; @@ -332,24 +408,9 @@ impl SupergraphFetcherBuilder { pub fn build_async( self, ) -> Result, SupergraphFetcherError> { - let endpoint = match self.endpoint { - Some(endpoint) => endpoint, - None => { - return Err(SupergraphFetcherError::MissingConfigurationOption( - "endpoint".to_string(), - )) - } - }; - let key = match self.key { - Some(key) => key, - None => { - return Err(SupergraphFetcherError::MissingConfigurationOption( - "key".to_string(), - )) - } - }; + self.validate_endpoints()?; - let (endpoint, headers) = prepare_client_config(endpoint, &key)?; + let headers = self.prepare_headers()?; let mut reqwest_agent = reqwest::Client::builder() .danger_accept_invalid_certs(self.accept_invalid_certs) @@ -369,8 +430,17 @@ impl SupergraphFetcherBuilder { .build(); Ok(SupergraphFetcher { - client: SupergraphFetcherAsyncOrSyncClient::Async { reqwest_client }, - endpoint, + client: SupergraphFetcherAsyncOrSyncClient::Async { + reqwest_client, + endpoints_with_circuit_breakers: self + .endpoints + .into_iter() + .map(|endpoint| { + let circuit_breaker = self.circuit_breaker.clone().build_async().unwrap(); + (endpoint, circuit_breaker) + }) + .collect(), + }, etag: RwLock::new(None), state: std::marker::PhantomData, }) From 85e90f63a585eca280d18c936887cdaea7449d31 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 5 Dec 2025 21:03:54 +0300 Subject: [PATCH 07/16] patch --- .changeset/light-walls-vanish.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/light-walls-vanish.md b/.changeset/light-walls-vanish.md index 3f65db264e0..7a1f4f89105 100644 --- a/.changeset/light-walls-vanish.md +++ b/.changeset/light-walls-vanish.md @@ -1,5 +1,5 @@ --- -'hive-console-sdk-rs': minor +'hive-console-sdk-rs': patch --- Circuit Breaker Implementation and Multiple Endpoints Support From 6308388d2c46b50c33b57d73ed7d1a4cf616f896 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 5 Dec 2025 21:11:17 +0300 Subject: [PATCH 08/16] Go --- .../libraries/sdk-rs/src/circuit_breaker.rs | 15 ++--------- .../sdk-rs/src/supergraph_fetcher.rs | 25 ++++++++++++++----- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/libraries/sdk-rs/src/circuit_breaker.rs b/packages/libraries/sdk-rs/src/circuit_breaker.rs index d8186748282..0dbd4529c2f 100644 --- a/packages/libraries/sdk-rs/src/circuit_breaker.rs +++ b/packages/libraries/sdk-rs/src/circuit_breaker.rs @@ -38,7 +38,7 @@ impl CircuitBreakerBuilder { self.volume_threshold = threshold; self } - // After what time the circuit breaker is attempting to retry sending requests in milliseconds. + /// After what time the circuit breaker is attempting to retry sending requests in milliseconds. /// Default: 30s pub fn reset_timeout(mut self, timeout: Duration) -> Self { self.reset_timeout = timeout; @@ -46,18 +46,7 @@ impl CircuitBreakerBuilder { } pub fn build_async(self) -> Result { - let error_threshold = if self.error_threshold < 0.0 || self.error_threshold > 1.0 { - return Err(CircuitBreakerError::InvalidErrorThreshold( - self.error_threshold, - )); - } else { - self.error_threshold - }; - let recloser = Recloser::custom() - .error_rate(error_threshold) - .closed_len(self.volume_threshold) - .open_wait(self.reset_timeout) - .build(); + let recloser = self.build_sync()?; Ok(AsyncRecloser::from(recloser)) } pub fn build_sync(self) -> Result { diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs index 6d0c199f183..2300a91bb17 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs @@ -4,6 +4,7 @@ use std::time::Duration; use std::time::SystemTime; use crate::agent::usage_agent::non_empty_string; +use crate::circuit_breaker::CircuitBreakerError; use recloser::AsyncRecloser; use recloser::Recloser; use reqwest::header::HeaderMap; @@ -52,6 +53,7 @@ pub enum SupergraphFetcherError { InvalidKey(InvalidHeaderValue), MissingConfigurationOption(String), RejectedByCircuitBreaker, + CircuitBreakerCreationError(CircuitBreakerError), } impl Display for SupergraphFetcherError { @@ -72,6 +74,9 @@ impl Display for SupergraphFetcherError { SupergraphFetcherError::RejectedByCircuitBreaker => { write!(f, "Request rejected by circuit breaker") } + SupergraphFetcherError::CircuitBreakerCreationError(e) => { + write!(f, "Creating circuit breaker failed: {}", e) + } } } } @@ -393,10 +398,14 @@ impl SupergraphFetcherBuilder { .endpoints .into_iter() .map(|endpoint| { - let circuit_breaker = self.circuit_breaker.clone().build_sync().unwrap(); - (endpoint, circuit_breaker) + let circuit_breaker = self + .circuit_breaker + .clone() + .build_sync() + .map_err(SupergraphFetcherError::CircuitBreakerCreationError); + circuit_breaker.map(|cb| (endpoint, cb)) }) - .collect(), + .collect::, _>>()?, }, etag: RwLock::new(None), state: std::marker::PhantomData, @@ -436,10 +445,14 @@ impl SupergraphFetcherBuilder { .endpoints .into_iter() .map(|endpoint| { - let circuit_breaker = self.circuit_breaker.clone().build_async().unwrap(); - (endpoint, circuit_breaker) + let circuit_breaker = self + .circuit_breaker + .clone() + .build_async() + .map_err(SupergraphFetcherError::CircuitBreakerCreationError); + circuit_breaker.map(|cb| (endpoint, cb)) }) - .collect(), + .collect::, _>>()?, }, etag: RwLock::new(None), state: std::marker::PhantomData, From 7a97a593981db779fc6c37b2a10631cd1251c514 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Sat, 6 Dec 2025 02:42:37 +0300 Subject: [PATCH 09/16] More --- .../sdk-rs/src/supergraph_fetcher.rs | 199 +++++++++++------- 1 file changed, 123 insertions(+), 76 deletions(-) diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs index 2300a91bb17..4dcf978dc4c 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs @@ -1,10 +1,11 @@ use std::fmt::Display; -use std::sync::RwLock; use std::time::Duration; use std::time::SystemTime; +use tokio::sync::RwLock; use crate::agent::usage_agent::non_empty_string; use crate::circuit_breaker::CircuitBreakerError; +use futures_util::TryFutureExt; use recloser::AsyncRecloser; use recloser::Recloser; use reqwest::header::HeaderMap; @@ -82,51 +83,6 @@ impl Display for SupergraphFetcherError { } impl SupergraphFetcher { - fn fetch_from_endpoint( - &self, - reqwest_client: &reqwest::blocking::Client, - endpoint: &str, - circuit_breaker: &Recloser, - retry_policy: &ExponentialBackoff, - ) -> Result { - circuit_breaker - .call(|| { - let request_start_time = SystemTime::now(); - // Implementing retry logic for sync client - let mut n_past_retries = 0; - loop { - let mut req = reqwest_client.get(endpoint); - let etag = self.get_latest_etag()?; - if let Some(etag) = etag { - req = req.header(IF_NONE_MATCH, etag); - } - let response = req.send(); - - match response { - Ok(resp) => break Ok(resp), - Err(e) => { - match retry_policy.should_retry(request_start_time, n_past_retries) { - RetryDecision::DoNotRetry => { - return Err(SupergraphFetcherError::NetworkError( - reqwest_middleware::Error::Reqwest(e), - )); - } - RetryDecision::Retry { execute_after } => { - n_past_retries += 1; - if let Ok(duration) = execute_after.elapsed() { - std::thread::sleep(duration); - } - } - } - } - } - } - }) - .map_err(|e| match e { - recloser::Error::Inner(e) => e, - recloser::Error::Rejected => SupergraphFetcherError::RejectedByCircuitBreaker, - }) - } pub fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { let (endpoints_with_circuit_breakers, reqwest_client, retry_policy) = match &self.client { SupergraphFetcherAsyncOrSyncClient::Sync { @@ -138,13 +94,71 @@ impl SupergraphFetcher { reqwest_client, retry_policy, ), - _ => unreachable!(), + _ => unreachable!("Called sync fetcher on async client"), }; let mut last_error: Option = None; let mut last_resp = None; for (endpoint, circuit_breaker) in endpoints_with_circuit_breakers { - let resp = - self.fetch_from_endpoint(reqwest_client, endpoint, circuit_breaker, retry_policy); + let resp = { + circuit_breaker + .call(|| { + let request_start_time = SystemTime::now(); + // Implementing retry logic for sync client + let mut n_past_retries = 0; + loop { + let mut req = reqwest_client.get(endpoint); + let etag = self.get_latest_etag()?; + if let Some(etag) = etag { + req = req.header(IF_NONE_MATCH, etag); + } + let mut response = req.send().map_err(|err| { + SupergraphFetcherError::NetworkError( + reqwest_middleware::Error::Reqwest(err), + ) + }); + + // Server errors (5xx) are considered retryable + if let Ok(ok_res) = response { + response = if ok_res.status().is_server_error() { + Err(SupergraphFetcherError::NetworkError( + reqwest_middleware::Error::Middleware(anyhow::anyhow!( + "Server error: {}", + ok_res.status() + )), + )) + } else { + Ok(ok_res) + } + } + + match response { + Ok(resp) => break Ok(resp), + Err(e) => { + match retry_policy + .should_retry(request_start_time, n_past_retries) + { + RetryDecision::DoNotRetry => { + return Err(e); + } + RetryDecision::Retry { execute_after } => { + n_past_retries += 1; + if let Ok(duration) = execute_after.elapsed() { + std::thread::sleep(duration); + } + } + } + } + } + } + }) + // Map recloser errors to SupergraphFetcherError + .map_err(|e| match e { + recloser::Error::Inner(e) => e, + recloser::Error::Rejected => { + SupergraphFetcherError::RejectedByCircuitBreaker + } + }) + }; match resp { Err(e) => { last_error = Some(e); @@ -172,6 +186,26 @@ impl SupergraphFetcher { Ok(None) } } + pub fn get_latest_etag(&self) -> Result, SupergraphFetcherError> { + let guard = self.etag.try_read().map_err(|e| { + SupergraphFetcherError::Lock(format!("Failed to read the etag record: {:?}", e)) + })?; + + Ok(guard.clone()) + } + fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> Result<(), SupergraphFetcherError> { + let mut guard = self.etag.try_write().map_err(|e| { + SupergraphFetcherError::Lock(format!("Failed to update the etag record: {:?}", e)) + })?; + + if let Some(etag_value) = etag { + *guard = Some(etag_value.clone()); + } else { + *guard = None; + } + + Ok(()) + } } impl SupergraphFetcher { @@ -181,24 +215,47 @@ impl SupergraphFetcher { endpoints_with_circuit_breakers, reqwest_client, } => (endpoints_with_circuit_breakers, reqwest_client), - _ => unreachable!(), + _ => unreachable!("Called async fetcher on sync client"), }; let mut last_error: Option = None; let mut last_resp = None; for (endpoint, circuit_breaker) in endpoints_with_circuit_breakers { let mut req = reqwest_client.get(endpoint); - let etag = self.get_latest_etag()?; + let etag = self.get_latest_etag().await; if let Some(etag) = etag { req = req.header(IF_NONE_MATCH, etag); } - let resp = circuit_breaker.call(req.send()).await; - match resp { - Err(recloser::Error::Inner(e)) => { - last_error = Some(SupergraphFetcherError::NetworkError(e)); - continue; + let resp_fut = async { + let mut resp = req + .send() + .await + .map_err(SupergraphFetcherError::NetworkError); + // Server errors (5xx) are considered errors + if let Ok(ok_res) = resp { + resp = if ok_res.status().is_server_error() { + return Err(SupergraphFetcherError::NetworkError( + reqwest_middleware::Error::Middleware(anyhow::anyhow!( + "Server error: {}", + ok_res.status() + )), + )); + } else { + Ok(ok_res) + } } - Err(recloser::Error::Rejected) => { - last_error = Some(SupergraphFetcherError::RejectedByCircuitBreaker); + resp + }; + let resp = circuit_breaker + .call(resp_fut) + // Map recloser errors to SupergraphFetcherError + .map_err(|e| match e { + recloser::Error::Inner(e) => e, + recloser::Error::Rejected => SupergraphFetcherError::RejectedByCircuitBreaker, + }) + .await; + match resp { + Err(err) => { + last_error = Some(err); continue; } Ok(resp) => { @@ -210,7 +267,7 @@ impl SupergraphFetcher { if let Some(last_resp) = last_resp { let etag = last_resp.headers().get("etag"); - self.update_latest_etag(etag)?; + self.update_latest_etag(etag).await; let text = last_resp .text() .await @@ -222,34 +279,24 @@ impl SupergraphFetcher { Ok(None) } } -} - -impl SupergraphFetcher { - fn get_latest_etag(&self) -> Result, SupergraphFetcherError> { - let guard: std::sync::RwLockReadGuard<'_, Option> = - self.etag.try_read().map_err(|e| { - SupergraphFetcherError::Lock(format!("Failed to read the etag record: {:?}", e)) - })?; + pub async fn get_latest_etag(&self) -> Option { + let guard = self.etag.read().await; - Ok(guard.clone()) + guard.clone() } - - fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> Result<(), SupergraphFetcherError> { - let mut guard: std::sync::RwLockWriteGuard<'_, Option> = - self.etag.try_write().map_err(|e| { - SupergraphFetcherError::Lock(format!("Failed to update the etag record: {:?}", e)) - })?; + async fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> () { + let mut guard = self.etag.write().await; if let Some(etag_value) = etag { *guard = Some(etag_value.clone()); } else { *guard = None; } - - Ok(()) } } +impl SupergraphFetcher {} + pub struct SupergraphFetcherBuilder { endpoints: Vec, key: Option, From 42defde210b6baaee3c19ab2f64471417480a1e4 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Sat, 6 Dec 2025 02:51:13 +0300 Subject: [PATCH 10/16] Refactor --- packages/libraries/router/src/registry.rs | 4 +- .../sdk-rs/src/supergraph_fetcher.rs | 508 ------------------ .../sdk-rs/src/supergraph_fetcher/async_.rs | 148 +++++ .../sdk-rs/src/supergraph_fetcher/builder.rs | 130 +++++ .../sdk-rs/src/supergraph_fetcher/mod.rs | 70 +++ .../sdk-rs/src/supergraph_fetcher/sync.rs | 184 +++++++ 6 files changed, 534 insertions(+), 510 deletions(-) delete mode 100644 packages/libraries/sdk-rs/src/supergraph_fetcher.rs create mode 100644 packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs create mode 100644 packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs create mode 100644 packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs create mode 100644 packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs diff --git a/packages/libraries/router/src/registry.rs b/packages/libraries/router/src/registry.rs index 70381a02923..9c06040401e 100644 --- a/packages/libraries/router/src/registry.rs +++ b/packages/libraries/router/src/registry.rs @@ -1,9 +1,9 @@ use crate::consts::PLUGIN_VERSION; use crate::registry_logger::Logger; use anyhow::{anyhow, Result}; +use hive_console_sdk::supergraph_fetcher::builder::SupergraphFetcherBuilder; +use hive_console_sdk::supergraph_fetcher::sync::SupergraphFetcherSyncState; use hive_console_sdk::supergraph_fetcher::SupergraphFetcher; -use hive_console_sdk::supergraph_fetcher::SupergraphFetcherBuilder; -use hive_console_sdk::supergraph_fetcher::SupergraphFetcherSyncState; use sha2::Digest; use sha2::Sha256; use std::env; diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher.rs deleted file mode 100644 index 4dcf978dc4c..00000000000 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher.rs +++ /dev/null @@ -1,508 +0,0 @@ -use std::fmt::Display; -use std::time::Duration; -use std::time::SystemTime; -use tokio::sync::RwLock; - -use crate::agent::usage_agent::non_empty_string; -use crate::circuit_breaker::CircuitBreakerError; -use futures_util::TryFutureExt; -use recloser::AsyncRecloser; -use recloser::Recloser; -use reqwest::header::HeaderMap; -use reqwest::header::HeaderValue; -use reqwest::header::InvalidHeaderValue; -use reqwest::header::IF_NONE_MATCH; -use reqwest_middleware::ClientBuilder; -use reqwest_middleware::ClientWithMiddleware; -use reqwest_retry::RetryDecision; -use reqwest_retry::RetryPolicy; -use reqwest_retry::RetryTransientMiddleware; -use retry_policies::policies::ExponentialBackoff; - -use crate::circuit_breaker::CircuitBreakerBuilder; - -#[derive(Debug)] -pub struct SupergraphFetcher { - client: SupergraphFetcherAsyncOrSyncClient, - etag: RwLock>, - state: std::marker::PhantomData, -} - -#[derive(Debug)] -pub struct SupergraphFetcherAsyncState; -#[derive(Debug)] -pub struct SupergraphFetcherSyncState; - -#[derive(Debug)] -enum SupergraphFetcherAsyncOrSyncClient { - Async { - endpoints_with_circuit_breakers: Vec<(String, AsyncRecloser)>, - reqwest_client: ClientWithMiddleware, - }, - Sync { - endpoints_with_circuit_breakers: Vec<(String, Recloser)>, - reqwest_client: reqwest::blocking::Client, - retry_policy: ExponentialBackoff, - }, -} - -pub enum SupergraphFetcherError { - FetcherCreationError(reqwest::Error), - NetworkError(reqwest_middleware::Error), - NetworkResponseError(reqwest::Error), - Lock(String), - InvalidKey(InvalidHeaderValue), - MissingConfigurationOption(String), - RejectedByCircuitBreaker, - CircuitBreakerCreationError(CircuitBreakerError), -} - -impl Display for SupergraphFetcherError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SupergraphFetcherError::FetcherCreationError(e) => { - write!(f, "Creating fetcher failed: {}", e) - } - SupergraphFetcherError::NetworkError(e) => write!(f, "Network error: {}", e), - SupergraphFetcherError::NetworkResponseError(e) => { - write!(f, "Network response error: {}", e) - } - SupergraphFetcherError::Lock(e) => write!(f, "Lock error: {}", e), - SupergraphFetcherError::InvalidKey(e) => write!(f, "Invalid CDN key: {}", e), - SupergraphFetcherError::MissingConfigurationOption(e) => { - write!(f, "Missing configuration option: {}", e) - } - SupergraphFetcherError::RejectedByCircuitBreaker => { - write!(f, "Request rejected by circuit breaker") - } - SupergraphFetcherError::CircuitBreakerCreationError(e) => { - write!(f, "Creating circuit breaker failed: {}", e) - } - } - } -} - -impl SupergraphFetcher { - pub fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { - let (endpoints_with_circuit_breakers, reqwest_client, retry_policy) = match &self.client { - SupergraphFetcherAsyncOrSyncClient::Sync { - endpoints_with_circuit_breakers, - reqwest_client, - retry_policy, - } => ( - endpoints_with_circuit_breakers, - reqwest_client, - retry_policy, - ), - _ => unreachable!("Called sync fetcher on async client"), - }; - let mut last_error: Option = None; - let mut last_resp = None; - for (endpoint, circuit_breaker) in endpoints_with_circuit_breakers { - let resp = { - circuit_breaker - .call(|| { - let request_start_time = SystemTime::now(); - // Implementing retry logic for sync client - let mut n_past_retries = 0; - loop { - let mut req = reqwest_client.get(endpoint); - let etag = self.get_latest_etag()?; - if let Some(etag) = etag { - req = req.header(IF_NONE_MATCH, etag); - } - let mut response = req.send().map_err(|err| { - SupergraphFetcherError::NetworkError( - reqwest_middleware::Error::Reqwest(err), - ) - }); - - // Server errors (5xx) are considered retryable - if let Ok(ok_res) = response { - response = if ok_res.status().is_server_error() { - Err(SupergraphFetcherError::NetworkError( - reqwest_middleware::Error::Middleware(anyhow::anyhow!( - "Server error: {}", - ok_res.status() - )), - )) - } else { - Ok(ok_res) - } - } - - match response { - Ok(resp) => break Ok(resp), - Err(e) => { - match retry_policy - .should_retry(request_start_time, n_past_retries) - { - RetryDecision::DoNotRetry => { - return Err(e); - } - RetryDecision::Retry { execute_after } => { - n_past_retries += 1; - if let Ok(duration) = execute_after.elapsed() { - std::thread::sleep(duration); - } - } - } - } - } - } - }) - // Map recloser errors to SupergraphFetcherError - .map_err(|e| match e { - recloser::Error::Inner(e) => e, - recloser::Error::Rejected => { - SupergraphFetcherError::RejectedByCircuitBreaker - } - }) - }; - match resp { - Err(e) => { - last_error = Some(e); - continue; - } - Ok(resp) => { - last_resp = Some(resp); - break; - } - } - } - - if let Some(last_resp) = last_resp { - if last_resp.status().as_u16() == 304 { - return Ok(None); - } - self.update_latest_etag(last_resp.headers().get("etag"))?; - let text = last_resp - .text() - .map_err(SupergraphFetcherError::NetworkResponseError)?; - Ok(Some(text)) - } else if let Some(error) = last_error { - Err(error) - } else { - Ok(None) - } - } - pub fn get_latest_etag(&self) -> Result, SupergraphFetcherError> { - let guard = self.etag.try_read().map_err(|e| { - SupergraphFetcherError::Lock(format!("Failed to read the etag record: {:?}", e)) - })?; - - Ok(guard.clone()) - } - fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> Result<(), SupergraphFetcherError> { - let mut guard = self.etag.try_write().map_err(|e| { - SupergraphFetcherError::Lock(format!("Failed to update the etag record: {:?}", e)) - })?; - - if let Some(etag_value) = etag { - *guard = Some(etag_value.clone()); - } else { - *guard = None; - } - - Ok(()) - } -} - -impl SupergraphFetcher { - pub async fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { - let (endpoints_with_circuit_breakers, reqwest_client) = match &self.client { - SupergraphFetcherAsyncOrSyncClient::Async { - endpoints_with_circuit_breakers, - reqwest_client, - } => (endpoints_with_circuit_breakers, reqwest_client), - _ => unreachable!("Called async fetcher on sync client"), - }; - let mut last_error: Option = None; - let mut last_resp = None; - for (endpoint, circuit_breaker) in endpoints_with_circuit_breakers { - let mut req = reqwest_client.get(endpoint); - let etag = self.get_latest_etag().await; - if let Some(etag) = etag { - req = req.header(IF_NONE_MATCH, etag); - } - let resp_fut = async { - let mut resp = req - .send() - .await - .map_err(SupergraphFetcherError::NetworkError); - // Server errors (5xx) are considered errors - if let Ok(ok_res) = resp { - resp = if ok_res.status().is_server_error() { - return Err(SupergraphFetcherError::NetworkError( - reqwest_middleware::Error::Middleware(anyhow::anyhow!( - "Server error: {}", - ok_res.status() - )), - )); - } else { - Ok(ok_res) - } - } - resp - }; - let resp = circuit_breaker - .call(resp_fut) - // Map recloser errors to SupergraphFetcherError - .map_err(|e| match e { - recloser::Error::Inner(e) => e, - recloser::Error::Rejected => SupergraphFetcherError::RejectedByCircuitBreaker, - }) - .await; - match resp { - Err(err) => { - last_error = Some(err); - continue; - } - Ok(resp) => { - last_resp = Some(resp); - break; - } - } - } - - if let Some(last_resp) = last_resp { - let etag = last_resp.headers().get("etag"); - self.update_latest_etag(etag).await; - let text = last_resp - .text() - .await - .map_err(SupergraphFetcherError::NetworkResponseError)?; - Ok(Some(text)) - } else if let Some(error) = last_error { - Err(error) - } else { - Ok(None) - } - } - pub async fn get_latest_etag(&self) -> Option { - let guard = self.etag.read().await; - - guard.clone() - } - async fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> () { - let mut guard = self.etag.write().await; - - if let Some(etag_value) = etag { - *guard = Some(etag_value.clone()); - } else { - *guard = None; - } - } -} - -impl SupergraphFetcher {} - -pub struct SupergraphFetcherBuilder { - endpoints: Vec, - key: Option, - user_agent: Option, - connect_timeout: Duration, - request_timeout: Duration, - accept_invalid_certs: bool, - retry_policy: ExponentialBackoff, - circuit_breaker: CircuitBreakerBuilder, -} - -impl Default for SupergraphFetcherBuilder { - fn default() -> Self { - Self { - endpoints: vec![], - key: None, - user_agent: None, - connect_timeout: Duration::from_secs(5), - request_timeout: Duration::from_secs(60), - accept_invalid_certs: false, - retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), - circuit_breaker: CircuitBreakerBuilder::default(), - } - } -} - -impl SupergraphFetcherBuilder { - pub fn new() -> Self { - Self::default() - } - - /// The CDN endpoint from Hive Console target. - pub fn add_endpoint(mut self, endpoint: String) -> Self { - if let Some(mut endpoint) = non_empty_string(Some(endpoint)) { - if !endpoint.ends_with("/supergraph") { - if endpoint.ends_with("/") { - endpoint.push_str("supergraph"); - } else { - endpoint.push_str("/supergraph"); - } - } - self.endpoints.push(endpoint); - } - self - } - - /// The CDN Access Token with from the Hive Console target. - pub fn key(mut self, key: String) -> Self { - self.key = Some(key); - self - } - - /// User-Agent header to be sent with each request - pub fn user_agent(mut self, user_agent: String) -> Self { - self.user_agent = Some(user_agent); - self - } - - /// Connection timeout for the Hive Console CDN requests. - /// Default: 5 seconds - pub fn connect_timeout(mut self, timeout: Duration) -> Self { - self.connect_timeout = timeout; - self - } - - /// Request timeout for the Hive Console CDN requests. - /// Default: 60 seconds - pub fn request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; - self - } - - pub fn accept_invalid_certs(mut self, accept: bool) -> Self { - self.accept_invalid_certs = accept; - self - } - - /// Policy for retrying failed requests. - /// - /// By default, an exponential backoff retry policy is used, with 10 attempts. - pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { - self.retry_policy = retry_policy; - self - } - - /// Maximum number of retries for failed requests. - /// - /// By default, an exponential backoff retry policy is used, with 10 attempts. - pub fn max_retries(mut self, max_retries: u32) -> Self { - self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); - self - } - - fn validate_endpoints(&self) -> Result<(), SupergraphFetcherError> { - if self.endpoints.is_empty() { - return Err(SupergraphFetcherError::MissingConfigurationOption( - "endpoint".to_string(), - )); - } - Ok(()) - } - - fn prepare_headers(&self) -> Result { - let key = match &self.key { - Some(key) => key, - None => { - return Err(SupergraphFetcherError::MissingConfigurationOption( - "key".to_string(), - )) - } - }; - let mut headers = HeaderMap::new(); - let mut cdn_key_header = - HeaderValue::from_str(key).map_err(SupergraphFetcherError::InvalidKey)?; - cdn_key_header.set_sensitive(true); - headers.insert("X-Hive-CDN-Key", cdn_key_header); - - Ok(headers) - } - - /// Builds a synchronous SupergraphFetcher - pub fn build_sync( - self, - ) -> Result, SupergraphFetcherError> { - self.validate_endpoints()?; - let headers = self.prepare_headers()?; - - let mut reqwest_client = reqwest::blocking::Client::builder() - .danger_accept_invalid_certs(self.accept_invalid_certs) - .connect_timeout(self.connect_timeout) - .timeout(self.request_timeout) - .default_headers(headers); - - if let Some(user_agent) = &self.user_agent { - reqwest_client = reqwest_client.user_agent(user_agent); - } - - let reqwest_client = reqwest_client - .build() - .map_err(SupergraphFetcherError::FetcherCreationError)?; - let fetcher: SupergraphFetcher = SupergraphFetcher { - client: SupergraphFetcherAsyncOrSyncClient::Sync { - reqwest_client, - retry_policy: self.retry_policy, - endpoints_with_circuit_breakers: self - .endpoints - .into_iter() - .map(|endpoint| { - let circuit_breaker = self - .circuit_breaker - .clone() - .build_sync() - .map_err(SupergraphFetcherError::CircuitBreakerCreationError); - circuit_breaker.map(|cb| (endpoint, cb)) - }) - .collect::, _>>()?, - }, - etag: RwLock::new(None), - state: std::marker::PhantomData, - }; - Ok(fetcher) - } - - /// Builds an asynchronous SupergraphFetcher - pub fn build_async( - self, - ) -> Result, SupergraphFetcherError> { - self.validate_endpoints()?; - - let headers = self.prepare_headers()?; - - let mut reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(self.accept_invalid_certs) - .connect_timeout(self.connect_timeout) - .timeout(self.request_timeout) - .default_headers(headers); - - if let Some(user_agent) = self.user_agent { - reqwest_agent = reqwest_agent.user_agent(user_agent); - } - - let reqwest_agent = reqwest_agent - .build() - .map_err(SupergraphFetcherError::FetcherCreationError)?; - let reqwest_client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) - .build(); - - Ok(SupergraphFetcher { - client: SupergraphFetcherAsyncOrSyncClient::Async { - reqwest_client, - endpoints_with_circuit_breakers: self - .endpoints - .into_iter() - .map(|endpoint| { - let circuit_breaker = self - .circuit_breaker - .clone() - .build_async() - .map_err(SupergraphFetcherError::CircuitBreakerCreationError); - circuit_breaker.map(|cb| (endpoint, cb)) - }) - .collect::, _>>()?, - }, - etag: RwLock::new(None), - state: std::marker::PhantomData, - }) - } -} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs new file mode 100644 index 00000000000..27523e4a803 --- /dev/null +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs @@ -0,0 +1,148 @@ +use futures_util::TryFutureExt; +use reqwest::header::{HeaderValue, IF_NONE_MATCH}; +use reqwest_middleware::ClientBuilder; +use reqwest_retry::RetryTransientMiddleware; +use tokio::sync::RwLock; + +use crate::supergraph_fetcher::{ + builder::SupergraphFetcherBuilder, SupergraphFetcher, SupergraphFetcherAsyncOrSyncClient, + SupergraphFetcherError, +}; + +#[derive(Debug)] +pub struct SupergraphFetcherAsyncState; + +impl SupergraphFetcher { + pub async fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { + let (endpoints_with_circuit_breakers, reqwest_client) = match &self.client { + SupergraphFetcherAsyncOrSyncClient::Async { + endpoints_with_circuit_breakers, + reqwest_client, + } => (endpoints_with_circuit_breakers, reqwest_client), + _ => unreachable!("Called async fetcher on sync client"), + }; + let mut last_error: Option = None; + let mut last_resp = None; + for (endpoint, circuit_breaker) in endpoints_with_circuit_breakers { + let mut req = reqwest_client.get(endpoint); + let etag = self.get_latest_etag().await; + if let Some(etag) = etag { + req = req.header(IF_NONE_MATCH, etag); + } + let resp_fut = async { + let mut resp = req + .send() + .await + .map_err(SupergraphFetcherError::NetworkError); + // Server errors (5xx) are considered errors + if let Ok(ok_res) = resp { + resp = if ok_res.status().is_server_error() { + return Err(SupergraphFetcherError::NetworkError( + reqwest_middleware::Error::Middleware(anyhow::anyhow!( + "Server error: {}", + ok_res.status() + )), + )); + } else { + Ok(ok_res) + } + } + resp + }; + let resp = circuit_breaker + .call(resp_fut) + // Map recloser errors to SupergraphFetcherError + .map_err(|e| match e { + recloser::Error::Inner(e) => e, + recloser::Error::Rejected => SupergraphFetcherError::RejectedByCircuitBreaker, + }) + .await; + match resp { + Err(err) => { + last_error = Some(err); + continue; + } + Ok(resp) => { + last_resp = Some(resp); + break; + } + } + } + + if let Some(last_resp) = last_resp { + let etag = last_resp.headers().get("etag"); + self.update_latest_etag(etag).await; + let text = last_resp + .text() + .await + .map_err(SupergraphFetcherError::NetworkResponseError)?; + Ok(Some(text)) + } else if let Some(error) = last_error { + Err(error) + } else { + Ok(None) + } + } + async fn get_latest_etag(&self) -> Option { + let guard = self.etag.read().await; + + guard.clone() + } + async fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> () { + let mut guard = self.etag.write().await; + + if let Some(etag_value) = etag { + *guard = Some(etag_value.clone()); + } else { + *guard = None; + } + } +} + +impl SupergraphFetcherBuilder { + /// Builds an asynchronous SupergraphFetcher + pub fn build_async( + self, + ) -> Result, SupergraphFetcherError> { + self.validate_endpoints()?; + + let headers = self.prepare_headers()?; + + let mut reqwest_agent = reqwest::Client::builder() + .danger_accept_invalid_certs(self.accept_invalid_certs) + .connect_timeout(self.connect_timeout) + .timeout(self.request_timeout) + .default_headers(headers); + + if let Some(user_agent) = self.user_agent { + reqwest_agent = reqwest_agent.user_agent(user_agent); + } + + let reqwest_agent = reqwest_agent + .build() + .map_err(SupergraphFetcherError::FetcherCreationError)?; + let reqwest_client = ClientBuilder::new(reqwest_agent) + .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) + .build(); + + Ok(SupergraphFetcher { + client: SupergraphFetcherAsyncOrSyncClient::Async { + reqwest_client, + endpoints_with_circuit_breakers: self + .endpoints + .into_iter() + .map(|endpoint| { + let circuit_breaker = self + .circuit_breaker + .clone() + .build_async() + .map_err(SupergraphFetcherError::CircuitBreakerCreationError); + circuit_breaker.map(|cb| (endpoint, cb)) + }) + .collect::, _>>()?, + }, + etag: RwLock::new(None), + state: std::marker::PhantomData, + }) + } +} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs new file mode 100644 index 00000000000..6b4d6b42db3 --- /dev/null +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs @@ -0,0 +1,130 @@ +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue}; +use retry_policies::policies::ExponentialBackoff; + +use crate::{ + agent::usage_agent::non_empty_string, circuit_breaker::CircuitBreakerBuilder, + supergraph_fetcher::SupergraphFetcherError, +}; + +pub struct SupergraphFetcherBuilder { + pub(crate) endpoints: Vec, + pub(crate) key: Option, + pub(crate) user_agent: Option, + pub(crate) connect_timeout: Duration, + pub(crate) request_timeout: Duration, + pub(crate) accept_invalid_certs: bool, + pub(crate) retry_policy: ExponentialBackoff, + pub(crate) circuit_breaker: CircuitBreakerBuilder, +} + +impl Default for SupergraphFetcherBuilder { + fn default() -> Self { + Self { + endpoints: vec![], + key: None, + user_agent: None, + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(60), + accept_invalid_certs: false, + retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), + circuit_breaker: CircuitBreakerBuilder::default(), + } + } +} + +impl SupergraphFetcherBuilder { + pub fn new() -> Self { + Self::default() + } + + /// The CDN endpoint from Hive Console target. + pub fn add_endpoint(mut self, endpoint: String) -> Self { + if let Some(mut endpoint) = non_empty_string(Some(endpoint)) { + if !endpoint.ends_with("/supergraph") { + if endpoint.ends_with("/") { + endpoint.push_str("supergraph"); + } else { + endpoint.push_str("/supergraph"); + } + } + self.endpoints.push(endpoint); + } + self + } + + /// The CDN Access Token with from the Hive Console target. + pub fn key(mut self, key: String) -> Self { + self.key = Some(key); + self + } + + /// User-Agent header to be sent with each request + pub fn user_agent(mut self, user_agent: String) -> Self { + self.user_agent = Some(user_agent); + self + } + + /// Connection timeout for the Hive Console CDN requests. + /// Default: 5 seconds + pub fn connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = timeout; + self + } + + /// Request timeout for the Hive Console CDN requests. + /// Default: 60 seconds + pub fn request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + pub fn accept_invalid_certs(mut self, accept: bool) -> Self { + self.accept_invalid_certs = accept; + self + } + + /// Policy for retrying failed requests. + /// + /// By default, an exponential backoff retry policy is used, with 10 attempts. + pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { + self.retry_policy = retry_policy; + self + } + + /// Maximum number of retries for failed requests. + /// + /// By default, an exponential backoff retry policy is used, with 10 attempts. + pub fn max_retries(mut self, max_retries: u32) -> Self { + self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); + self + } + + pub(crate) fn validate_endpoints(&self) -> Result<(), SupergraphFetcherError> { + if self.endpoints.is_empty() { + return Err(SupergraphFetcherError::MissingConfigurationOption( + "endpoint".to_string(), + )); + } + Ok(()) + } + + pub(crate) fn prepare_headers(&self) -> Result { + let key = match &self.key { + Some(key) => key, + None => { + return Err(SupergraphFetcherError::MissingConfigurationOption( + "key".to_string(), + )) + } + }; + let mut headers = HeaderMap::new(); + let mut cdn_key_header = + HeaderValue::from_str(key).map_err(SupergraphFetcherError::InvalidKey)?; + cdn_key_header.set_sensitive(true); + headers.insert("X-Hive-CDN-Key", cdn_key_header); + + Ok(headers) + } +} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs new file mode 100644 index 00000000000..07f06cb68bf --- /dev/null +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs @@ -0,0 +1,70 @@ +use std::fmt::Display; +use tokio::sync::RwLock; + +use crate::circuit_breaker::CircuitBreakerError; +use recloser::AsyncRecloser; +use recloser::Recloser; +use reqwest::header::HeaderValue; +use reqwest::header::InvalidHeaderValue; +use reqwest_middleware::ClientWithMiddleware; +use retry_policies::policies::ExponentialBackoff; + +pub mod async_; +pub mod builder; +pub mod sync; + +#[derive(Debug)] +pub struct SupergraphFetcher { + client: SupergraphFetcherAsyncOrSyncClient, + etag: RwLock>, + state: std::marker::PhantomData, +} + +#[derive(Debug)] +enum SupergraphFetcherAsyncOrSyncClient { + Async { + endpoints_with_circuit_breakers: Vec<(String, AsyncRecloser)>, + reqwest_client: ClientWithMiddleware, + }, + Sync { + endpoints_with_circuit_breakers: Vec<(String, Recloser)>, + reqwest_client: reqwest::blocking::Client, + retry_policy: ExponentialBackoff, + }, +} + +pub enum SupergraphFetcherError { + FetcherCreationError(reqwest::Error), + NetworkError(reqwest_middleware::Error), + NetworkResponseError(reqwest::Error), + Lock(String), + InvalidKey(InvalidHeaderValue), + MissingConfigurationOption(String), + RejectedByCircuitBreaker, + CircuitBreakerCreationError(CircuitBreakerError), +} + +impl Display for SupergraphFetcherError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SupergraphFetcherError::FetcherCreationError(e) => { + write!(f, "Creating fetcher failed: {}", e) + } + SupergraphFetcherError::NetworkError(e) => write!(f, "Network error: {}", e), + SupergraphFetcherError::NetworkResponseError(e) => { + write!(f, "Network response error: {}", e) + } + SupergraphFetcherError::Lock(e) => write!(f, "Lock error: {}", e), + SupergraphFetcherError::InvalidKey(e) => write!(f, "Invalid CDN key: {}", e), + SupergraphFetcherError::MissingConfigurationOption(e) => { + write!(f, "Missing configuration option: {}", e) + } + SupergraphFetcherError::RejectedByCircuitBreaker => { + write!(f, "Request rejected by circuit breaker") + } + SupergraphFetcherError::CircuitBreakerCreationError(e) => { + write!(f, "Creating circuit breaker failed: {}", e) + } + } + } +} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs new file mode 100644 index 00000000000..1d78a397799 --- /dev/null +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs @@ -0,0 +1,184 @@ +use std::time::SystemTime; + +use reqwest::header::{HeaderValue, IF_NONE_MATCH}; +use reqwest_retry::{RetryDecision, RetryPolicy}; +use tokio::sync::RwLock; + +use crate::supergraph_fetcher::{ + builder::SupergraphFetcherBuilder, SupergraphFetcher, SupergraphFetcherAsyncOrSyncClient, + SupergraphFetcherError, +}; + +#[derive(Debug)] +pub struct SupergraphFetcherSyncState; + +impl SupergraphFetcher { + pub fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { + let (endpoints_with_circuit_breakers, reqwest_client, retry_policy) = match &self.client { + SupergraphFetcherAsyncOrSyncClient::Sync { + endpoints_with_circuit_breakers, + reqwest_client, + retry_policy, + } => ( + endpoints_with_circuit_breakers, + reqwest_client, + retry_policy, + ), + _ => unreachable!("Called sync fetcher on async client"), + }; + let mut last_error: Option = None; + let mut last_resp = None; + for (endpoint, circuit_breaker) in endpoints_with_circuit_breakers { + let resp = { + circuit_breaker + .call(|| { + let request_start_time = SystemTime::now(); + // Implementing retry logic for sync client + let mut n_past_retries = 0; + loop { + let mut req = reqwest_client.get(endpoint); + let etag = self.get_latest_etag()?; + if let Some(etag) = etag { + req = req.header(IF_NONE_MATCH, etag); + } + let mut response = req.send().map_err(|err| { + SupergraphFetcherError::NetworkError( + reqwest_middleware::Error::Reqwest(err), + ) + }); + + // Server errors (5xx) are considered retryable + if let Ok(ok_res) = response { + response = if ok_res.status().is_server_error() { + Err(SupergraphFetcherError::NetworkError( + reqwest_middleware::Error::Middleware(anyhow::anyhow!( + "Server error: {}", + ok_res.status() + )), + )) + } else { + Ok(ok_res) + } + } + + match response { + Ok(resp) => break Ok(resp), + Err(e) => { + match retry_policy + .should_retry(request_start_time, n_past_retries) + { + RetryDecision::DoNotRetry => { + return Err(e); + } + RetryDecision::Retry { execute_after } => { + n_past_retries += 1; + if let Ok(duration) = execute_after.elapsed() { + std::thread::sleep(duration); + } + } + } + } + } + } + }) + // Map recloser errors to SupergraphFetcherError + .map_err(|e| match e { + recloser::Error::Inner(e) => e, + recloser::Error::Rejected => { + SupergraphFetcherError::RejectedByCircuitBreaker + } + }) + }; + match resp { + Err(e) => { + last_error = Some(e); + continue; + } + Ok(resp) => { + last_resp = Some(resp); + break; + } + } + } + + if let Some(last_resp) = last_resp { + if last_resp.status().as_u16() == 304 { + return Ok(None); + } + self.update_latest_etag(last_resp.headers().get("etag"))?; + let text = last_resp + .text() + .map_err(SupergraphFetcherError::NetworkResponseError)?; + Ok(Some(text)) + } else if let Some(error) = last_error { + Err(error) + } else { + Ok(None) + } + } + fn get_latest_etag(&self) -> Result, SupergraphFetcherError> { + let guard = self.etag.try_read().map_err(|e| { + SupergraphFetcherError::Lock(format!("Failed to read the etag record: {:?}", e)) + })?; + + Ok(guard.clone()) + } + fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> Result<(), SupergraphFetcherError> { + let mut guard = self.etag.try_write().map_err(|e| { + SupergraphFetcherError::Lock(format!("Failed to update the etag record: {:?}", e)) + })?; + + if let Some(etag_value) = etag { + *guard = Some(etag_value.clone()); + } else { + *guard = None; + } + + Ok(()) + } +} + +impl SupergraphFetcherBuilder { + /// Builds a synchronous SupergraphFetcher + pub fn build_sync( + self, + ) -> Result, SupergraphFetcherError> { + self.validate_endpoints()?; + let headers = self.prepare_headers()?; + + let mut reqwest_client = reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(self.accept_invalid_certs) + .connect_timeout(self.connect_timeout) + .timeout(self.request_timeout) + .default_headers(headers); + + if let Some(user_agent) = &self.user_agent { + reqwest_client = reqwest_client.user_agent(user_agent); + } + + let reqwest_client = reqwest_client + .build() + .map_err(SupergraphFetcherError::FetcherCreationError)?; + let fetcher: SupergraphFetcher = SupergraphFetcher { + client: SupergraphFetcherAsyncOrSyncClient::Sync { + reqwest_client, + retry_policy: self.retry_policy, + endpoints_with_circuit_breakers: self + .endpoints + .into_iter() + .map(|endpoint| { + let circuit_breaker = self + .circuit_breaker + .clone() + .build_sync() + .map_err(SupergraphFetcherError::CircuitBreakerCreationError); + circuit_breaker.map(|cb| (endpoint, cb)) + }) + .collect::, _>>()?, + }, + etag: RwLock::new(None), + state: std::marker::PhantomData, + }; + Ok(fetcher) + } +} From ad9593d716889d98d700a6af9c7daee396b74a38 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Sat, 6 Dec 2025 02:58:52 +0300 Subject: [PATCH 11/16] More --- packages/libraries/router/src/registry.rs | 3 +-- .../libraries/sdk-rs/src/supergraph_fetcher/async_.rs | 1 + .../libraries/sdk-rs/src/supergraph_fetcher/builder.rs | 9 +++++++-- packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs | 8 ++++++++ packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs | 1 + 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/libraries/router/src/registry.rs b/packages/libraries/router/src/registry.rs index 9c06040401e..fb48b76bc35 100644 --- a/packages/libraries/router/src/registry.rs +++ b/packages/libraries/router/src/registry.rs @@ -1,7 +1,6 @@ use crate::consts::PLUGIN_VERSION; use crate::registry_logger::Logger; use anyhow::{anyhow, Result}; -use hive_console_sdk::supergraph_fetcher::builder::SupergraphFetcherBuilder; use hive_console_sdk::supergraph_fetcher::sync::SupergraphFetcherSyncState; use hive_console_sdk::supergraph_fetcher::SupergraphFetcher; use sha2::Digest; @@ -125,7 +124,7 @@ impl HiveRegistry { env::set_var("APOLLO_ROUTER_HOT_RELOAD", "true"); } - let mut fetcher = SupergraphFetcherBuilder::new() + let mut fetcher = SupergraphFetcher::builder() .key(key) .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)) .accept_invalid_certs(accept_invalid_certs); diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs index 27523e4a803..55994d392fb 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs @@ -135,6 +135,7 @@ impl SupergraphFetcherBuilder { let circuit_breaker = self .circuit_breaker .clone() + .unwrap_or_default() .build_async() .map_err(SupergraphFetcherError::CircuitBreakerCreationError); circuit_breaker.map(|cb| (endpoint, cb)) diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs index 6b4d6b42db3..adddc011232 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs @@ -16,7 +16,7 @@ pub struct SupergraphFetcherBuilder { pub(crate) request_timeout: Duration, pub(crate) accept_invalid_certs: bool, pub(crate) retry_policy: ExponentialBackoff, - pub(crate) circuit_breaker: CircuitBreakerBuilder, + pub(crate) circuit_breaker: Option, } impl Default for SupergraphFetcherBuilder { @@ -29,7 +29,7 @@ impl Default for SupergraphFetcherBuilder { request_timeout: Duration::from_secs(60), accept_invalid_certs: false, retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), - circuit_breaker: CircuitBreakerBuilder::default(), + circuit_breaker: None, } } } @@ -101,6 +101,11 @@ impl SupergraphFetcherBuilder { self } + pub fn circuit_breaker(&mut self, builder: CircuitBreakerBuilder) -> &mut Self { + self.circuit_breaker = Some(builder); + self + } + pub(crate) fn validate_endpoints(&self) -> Result<(), SupergraphFetcherError> { if self.endpoints.is_empty() { return Err(SupergraphFetcherError::MissingConfigurationOption( diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs index 07f06cb68bf..dba4cddcdb2 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs @@ -2,6 +2,7 @@ use std::fmt::Display; use tokio::sync::RwLock; use crate::circuit_breaker::CircuitBreakerError; +use crate::supergraph_fetcher::async_::SupergraphFetcherAsyncState; use recloser::AsyncRecloser; use recloser::Recloser; use reqwest::header::HeaderValue; @@ -33,6 +34,13 @@ enum SupergraphFetcherAsyncOrSyncClient { }, } +// Doesn't matter which one we implement this for, both have the same builder +impl SupergraphFetcher { + pub fn builder() -> builder::SupergraphFetcherBuilder { + builder::SupergraphFetcherBuilder::default() + } +} + pub enum SupergraphFetcherError { FetcherCreationError(reqwest::Error), NetworkError(reqwest_middleware::Error), diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs index 1d78a397799..726b354d6d5 100644 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs +++ b/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs @@ -170,6 +170,7 @@ impl SupergraphFetcherBuilder { let circuit_breaker = self .circuit_breaker .clone() + .unwrap_or_default() .build_sync() .map_err(SupergraphFetcherError::CircuitBreakerCreationError); circuit_breaker.map(|cb| (endpoint, cb)) From 6187c7f0d9299d9f2f5e18dc9478d1ad5b51c5a6 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 12 Dec 2025 22:14:24 +0300 Subject: [PATCH 12/16] .. --- .github/workflows/tests-integration.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests-integration.yaml b/.github/workflows/tests-integration.yaml index 3a77d428658..de7206218c9 100644 --- a/.github/workflows/tests-integration.yaml +++ b/.github/workflows/tests-integration.yaml @@ -23,6 +23,7 @@ jobs: integration: runs-on: ubuntu-22.04 strategy: + fail-fast: false matrix: # Divide integration tests into 3 shards, to run them in parallel. shardIndex: [1, 2, 3, 'apollo-router'] From 0a58592bb1a605066e10a701058c72004295147c Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 12 Dec 2025 23:01:42 +0300 Subject: [PATCH 13/16] Lets go --- .../tests/apollo-router/apollo-router.test.ts | 2 +- packages/libraries/router/src/usage.rs | 149 ++++++------------ .../libraries/sdk-rs/src/agent/builder.rs | 119 +++++++------- 3 files changed, 113 insertions(+), 157 deletions(-) diff --git a/integration-tests/tests/apollo-router/apollo-router.test.ts b/integration-tests/tests/apollo-router/apollo-router.test.ts index 980d26a9c18..89dc56c9aa5 100644 --- a/integration-tests/tests/apollo-router/apollo-router.test.ts +++ b/integration-tests/tests/apollo-router/apollo-router.test.ts @@ -28,7 +28,7 @@ describe('Apollo Router Integration', () => { const { createProject } = await createOrg(); const { createTargetAccessToken, createCdnAccess, target, waitForOperationsCollected } = await createProject(ProjectType.Federation); - const writeToken = await createTargetAccessToken({}); + const writeToken = await createTargetAccessToken({ target }); // Publish Schema const publishSchemaResult = await writeToken diff --git a/packages/libraries/router/src/usage.rs b/packages/libraries/router/src/usage.rs index 0d28575675e..8678f39f00a 100644 --- a/packages/libraries/router/src/usage.rs +++ b/packages/libraries/router/src/usage.rs @@ -50,7 +50,7 @@ pub struct UsagePlugin { schema: Arc>, } -#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Deserialize, JsonSchema, Default)] pub struct Config { /// Default: true enabled: Option, @@ -94,26 +94,6 @@ pub struct Config { flush_interval: Option, } -impl Default for Config { - fn default() -> Self { - Self { - enabled: Some(true), - registry_token: None, - registry_usage_endpoint: Some(DEFAULT_HIVE_USAGE_ENDPOINT.into()), - sample_rate: Some(1.0), - exclude: None, - client_name_header: Some(String::from("graphql-client-name")), - client_version_header: Some(String::from("graphql-client-version")), - accept_invalid_certs: Some(false), - buffer_size: Some(1000), - connect_timeout: Some(5), - request_timeout: Some(15), - flush_interval: Some(5), - target: None, - } - } -} - impl UsagePlugin { fn populate_context(config: OperationConfig, req: &supergraph::Request) { let context = &req.context; @@ -178,88 +158,59 @@ impl UsagePlugin { } } -static DEFAULT_HIVE_USAGE_ENDPOINT: &str = "https://app.graphql-hive.com/usage"; - #[async_trait::async_trait] impl Plugin for UsagePlugin { type Config = Config; async fn new(init: PluginInit) -> Result { - let token = init - .config - .registry_token - .clone() - .or_else(|| env::var("HIVE_TOKEN").ok()); - - if token.is_none() { - return Err("Hive token is required".into()); - } - - let endpoint = init - .config - .registry_usage_endpoint - .clone() - .unwrap_or_else(|| { - env::var("HIVE_ENDPOINT").unwrap_or(DEFAULT_HIVE_USAGE_ENDPOINT.to_string()) - }); - - let target_id = init - .config - .target - .clone() - .or_else(|| env::var("HIVE_TARGET_ID").ok()); - - let default_config = Config::default(); let user_config = init.config; - let enabled = user_config - .enabled - .or(default_config.enabled) - .expect("enabled has default value"); - let buffer_size = user_config - .buffer_size - .or(default_config.buffer_size) - .expect("buffer_size has no default value"); - let accept_invalid_certs = user_config - .accept_invalid_certs - .or(default_config.accept_invalid_certs) - .expect("accept_invalid_certs has no default value"); - let connect_timeout = user_config - .connect_timeout - .or(default_config.connect_timeout) - .expect("connect_timeout has no default value"); - let request_timeout = user_config - .request_timeout - .or(default_config.request_timeout) - .expect("request_timeout has no default value"); - let flush_interval = user_config - .flush_interval - .or(default_config.flush_interval) - .expect("request_timeout has no default value"); + + let enabled = user_config.enabled.unwrap_or(true); if enabled { tracing::info!("Starting GraphQL Hive Usage plugin"); } - let schema = parse_schema(&init.supergraph_sdl) - .expect("Failed to parse schema") - .into_static(); - - let token = token.expect("token is set"); let agent = if enabled { - let flush_interval = Duration::from_secs(flush_interval); - - let mut agent = UsageAgent::builder() - .token(token) - .endpoint(endpoint) - .buffer_size(buffer_size) - .connect_timeout(Duration::from_secs(connect_timeout)) - .request_timeout(Duration::from_secs(request_timeout)) - .accept_invalid_certs(accept_invalid_certs) - .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)) - .flush_interval(flush_interval); - - if let Some(target_id) = target_id { + let mut agent = + UsageAgent::builder().user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)); + + if let Some(endpoint) = user_config.registry_usage_endpoint { + agent = agent.endpoint(endpoint); + } else if let Ok(env_endpoint) = env::var("HIVE_USAGE_ENDPOINT") { + agent = agent.endpoint(env_endpoint); + } + + if let Some(token) = user_config.registry_token { + agent = agent.token(token); + } else if let Ok(env_token) = env::var("HIVE_TOKEN") { + agent = agent.token(env_token); + } + + if let Some(target_id) = user_config.target { agent = agent.target_id(target_id); + } else if let Ok(env_target) = env::var("HIVE_TARGET") { + agent = agent.target_id(env_target); + } + + if let Some(buffer_size) = user_config.buffer_size { + agent = agent.buffer_size(buffer_size); + } + + if let Some(connect_timeout) = user_config.connect_timeout { + agent = agent.connect_timeout(Duration::from_secs(connect_timeout)); + } + + if let Some(request_timeout) = user_config.request_timeout { + agent = agent.request_timeout(Duration::from_secs(request_timeout)); + } + + if let Some(accept_invalid_certs) = user_config.accept_invalid_certs { + agent = agent.accept_invalid_certs(accept_invalid_certs); + } + + if let Some(flush_interval) = user_config.flush_interval { + agent = agent.flush_interval(Duration::from_secs(flush_interval)); } let agent = agent.build().map_err(Box::new)?; @@ -269,22 +220,22 @@ impl Plugin for UsagePlugin { } else { None }; + + let schema = parse_schema(&init.supergraph_sdl) + .expect("Failed to parse schema") + .into_static(); + Ok(UsagePlugin { schema: Arc::new(schema), config: OperationConfig { - sample_rate: user_config - .sample_rate - .or(default_config.sample_rate) - .expect("sample_rate has no default value"), - exclude: user_config.exclude.or(default_config.exclude), + sample_rate: user_config.sample_rate.unwrap_or(1.0), + exclude: user_config.exclude, client_name_header: user_config .client_name_header - .or(default_config.client_name_header) - .expect("client_name_header has no default value"), + .unwrap_or("graphql-client-name".to_string()), client_version_header: user_config .client_version_header - .or(default_config.client_version_header) - .expect("client_version_header has no default value"), + .unwrap_or("graphql-client-version".to_string()), }, agent, }) diff --git a/packages/libraries/sdk-rs/src/agent/builder.rs b/packages/libraries/sdk-rs/src/agent/builder.rs index c0824e1f711..0bb061106ab 100644 --- a/packages/libraries/sdk-rs/src/agent/builder.rs +++ b/packages/libraries/sdk-rs/src/agent/builder.rs @@ -52,7 +52,9 @@ fn is_legacy_token(token: &str) -> bool { impl UsageAgentBuilder { /// Your [Registry Access Token](https://the-guild.dev/graphql/hive/docs/management/targets#registry-access-tokens) with write permission. pub fn token(mut self, token: String) -> Self { - self.token = non_empty_string(Some(token)); + if let Some(token) = non_empty_string(Some(token)) { + self.token = Some(token); + } self } /// For self-hosting, you can override `/usage` endpoint (defaults to `https://app.graphql-hive.com/usage`). @@ -64,7 +66,9 @@ impl UsageAgentBuilder { } /// A target ID, this can either be a slug following the format “$organizationSlug/$projectSlug/$targetSlug” (e.g “the-guild/graphql-hive/staging”) or an UUID (e.g. “a0f4c605-6541-4350-8cfe-b31f21a4bf80”). To be used when the token is configured with an organization access token. pub fn target_id(mut self, target_id: String) -> Self { - self.target_id = non_empty_string(Some(target_id)); + if let Some(target_id) = non_empty_string(Some(target_id)) { + self.target_id = Some(target_id); + } self } /// A maximum number of operations to hold in a buffer before sending to Hive Console @@ -99,7 +103,9 @@ impl UsageAgentBuilder { } /// User-Agent header to be sent with each request pub fn user_agent(mut self, user_agent: String) -> Self { - self.user_agent = non_empty_string(Some(user_agent)); + if let Some(user_agent) = non_empty_string(Some(user_agent)) { + self.user_agent = Some(user_agent); + } self } /// Retry policy for sending reports @@ -119,69 +125,68 @@ impl UsageAgentBuilder { default_headers.insert("X-Usage-API-Version", HeaderValue::from_static("2")); - if let Some(token) = self.token { - let mut authorization_header = HeaderValue::from_str(&format!("Bearer {}", token)) - .map_err(|_| AgentError::InvalidToken)?; + let token = match self.token { + Some(token) => token, + None => return Err(AgentError::MissingToken), + }; - authorization_header.set_sensitive(true); + let mut authorization_header = HeaderValue::from_str(&format!("Bearer {}", token)) + .map_err(|_| AgentError::InvalidToken)?; - default_headers.insert(reqwest::header::AUTHORIZATION, authorization_header); + authorization_header.set_sensitive(true); - default_headers.insert( - reqwest::header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ); + default_headers.insert(reqwest::header::AUTHORIZATION, authorization_header); - let mut reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(self.accept_invalid_certs) - .connect_timeout(self.connect_timeout) - .timeout(self.request_timeout) - .default_headers(default_headers); + default_headers.insert( + reqwest::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); - if let Some(user_agent) = &self.user_agent { - reqwest_agent = reqwest_agent.user_agent(user_agent); - } + let mut reqwest_agent = reqwest::Client::builder() + .danger_accept_invalid_certs(self.accept_invalid_certs) + .connect_timeout(self.connect_timeout) + .timeout(self.request_timeout) + .default_headers(default_headers); + + if let Some(user_agent) = &self.user_agent { + reqwest_agent = reqwest_agent.user_agent(user_agent); + } + + let reqwest_agent = reqwest_agent + .build() + .map_err(AgentError::HTTPClientCreationError)?; + let client = ClientBuilder::new(reqwest_agent) + .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) + .build(); + + let mut endpoint = self.endpoint; - let reqwest_agent = reqwest_agent - .build() - .map_err(AgentError::HTTPClientCreationError)?; - let client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) - .build(); - - let mut endpoint = self.endpoint; - - match self.target_id { - Some(_) if is_legacy_token(&token) => { - return Err(AgentError::TargetIdWithLegacyToken) - } - Some(target_id) if !is_legacy_token(&token) => { - let target_id = validate_target_id(&target_id)?; - endpoint.push_str(&format!("/{}", target_id)); - } - None if !is_legacy_token(&token) => return Err(AgentError::MissingTargetId), - _ => {} + match self.target_id { + Some(_) if is_legacy_token(&token) => return Err(AgentError::TargetIdWithLegacyToken), + Some(target_id) if !is_legacy_token(&token) => { + let target_id = validate_target_id(&target_id)?; + endpoint.push_str(&format!("/{}", target_id)); } + None if !is_legacy_token(&token) => return Err(AgentError::MissingTargetId), + _ => {} + } - let circuit_breaker = if let Some(cb) = self.circuit_breaker { - cb - } else { - circuit_breaker::CircuitBreakerBuilder::default() - .build_async() - .map_err(AgentError::CircuitBreakerCreationError)? - }; - - Ok(Arc::new(UsageAgent { - endpoint, - buffer: Buffer::new(self.buffer_size), - processor: OperationProcessor::new(), - client, - flush_interval: self.flush_interval, - circuit_breaker, - })) + let circuit_breaker = if let Some(cb) = self.circuit_breaker { + cb } else { - Err(AgentError::MissingToken) - } + circuit_breaker::CircuitBreakerBuilder::default() + .build_async() + .map_err(AgentError::CircuitBreakerCreationError)? + }; + + Ok(Arc::new(UsageAgent { + endpoint, + buffer: Buffer::new(self.buffer_size), + processor: OperationProcessor::new(), + client, + flush_interval: self.flush_interval, + circuit_breaker, + })) } } From c8ff97183186c1e2385564985470d27bff65f7ca Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 12 Dec 2025 23:33:48 +0300 Subject: [PATCH 14/16] Lets go --- integration-tests/tests/apollo-router/apollo-router.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/tests/apollo-router/apollo-router.test.ts b/integration-tests/tests/apollo-router/apollo-router.test.ts index 89dc56c9aa5..2aa84131264 100644 --- a/integration-tests/tests/apollo-router/apollo-router.test.ts +++ b/integration-tests/tests/apollo-router/apollo-router.test.ts @@ -28,7 +28,7 @@ describe('Apollo Router Integration', () => { const { createProject } = await createOrg(); const { createTargetAccessToken, createCdnAccess, target, waitForOperationsCollected } = await createProject(ProjectType.Federation); - const writeToken = await createTargetAccessToken({ target }); + const writeToken = await createTargetAccessToken({}); // Publish Schema const publishSchemaResult = await writeToken @@ -71,7 +71,7 @@ plugins: const routerProc = execa(routerBinPath, ['--dev', '--config', routerConfigPath], { all: true, env: { - HIVE_CDN_ENDPOINT: endpointBaseUrl + target.id, + HIVE_CDN_ENDPOINT: endpointBaseUrl, HIVE_CDN_KEY: cdnAccessResult.secretAccessToken, HIVE_ENDPOINT: `http://${usageAddress}`, HIVE_TOKEN: writeToken.secret, From 4b23cdb4a6466156af2f620a35a8da3eb51bc0b5 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 12 Dec 2025 23:34:18 +0300 Subject: [PATCH 15/16] Lets go --- integration-tests/tests/apollo-router/apollo-router.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/integration-tests/tests/apollo-router/apollo-router.test.ts b/integration-tests/tests/apollo-router/apollo-router.test.ts index 2aa84131264..15b863934b1 100644 --- a/integration-tests/tests/apollo-router/apollo-router.test.ts +++ b/integration-tests/tests/apollo-router/apollo-router.test.ts @@ -71,11 +71,10 @@ plugins: const routerProc = execa(routerBinPath, ['--dev', '--config', routerConfigPath], { all: true, env: { - HIVE_CDN_ENDPOINT: endpointBaseUrl, + HIVE_CDN_ENDPOINT: endpointBaseUrl + target.id, HIVE_CDN_KEY: cdnAccessResult.secretAccessToken, HIVE_ENDPOINT: `http://${usageAddress}`, HIVE_TOKEN: writeToken.secret, - HIVE_TARGET_ID: target.id, }, }); await new Promise((resolve, reject) => { From 5991f86e9ffb6cdc5913af8abc0855be5d0352dd Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Sat, 13 Dec 2025 00:28:00 +0300 Subject: [PATCH 16/16] Log router in case of error --- .../tests/apollo-router/apollo-router.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/integration-tests/tests/apollo-router/apollo-router.test.ts b/integration-tests/tests/apollo-router/apollo-router.test.ts index 15b863934b1..27b1e9baff3 100644 --- a/integration-tests/tests/apollo-router/apollo-router.test.ts +++ b/integration-tests/tests/apollo-router/apollo-router.test.ts @@ -77,6 +77,7 @@ plugins: HIVE_TOKEN: writeToken.secret, }, }); + let log = ''; await new Promise((resolve, reject) => { routerProc.catch(err => { if (!err.isCanceled) { @@ -87,7 +88,6 @@ plugins: if (!routerProcOut) { return reject(new Error('No stdout from Apollo Router process')); } - let log = ''; routerProcOut.on('data', data => { log += data.toString(); if (log.includes('GraphQL endpoint exposed at')) { @@ -105,14 +105,14 @@ plugins: 'content-type': 'application/json', }, body: JSON.stringify({ - query: ` - query TestQuery { - me { - id - name - } - } - `, + query: /* GraphQL */ ` + query TestQuery { + me { + id + name + } + } + `, }), }); @@ -127,6 +127,9 @@ plugins: }, }); await waitForOperationsCollected(1); + } catch (e) { + console.error('Router logs:\n', log); + throw e; } finally { routerProc.cancel(); rmSync(routerConfigPath);