/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::time::{Duration, Instant}; use serde_json::Value; use ureq::Agent; use ureq::config::{Config, RedirectAuthHeaders}; use ureq::{ResponseExt, http::Uri}; use crate::exchange_graph::error::GraphError; use crate::exchange_graph::retry::{HttpClass, classify_http_status, is_throttled}; use crate::jmap::http::{RetryPolicy, cross_host, retry_after_header}; use crate::jmap::retry::{self, RateLimitState}; use crate::logging::{HttpCall, LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger}; use crate::net::{CertOverride, tls, with_timeouts}; const MAX_BODY: u64 = 256 * 1024 * 1024; const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10); #[derive(Debug, Clone, Copy)] pub enum Accept { Json, Text, Binary, } impl Accept { fn header_value(self) -> &'static str { match self { Accept::Json => "application/json", Accept::Text => "text/plain", Accept::Binary => "application/octet-stream", } } } #[derive(Debug, Clone)] pub struct GraphResponse { pub status: u16, pub body: Vec, pub content_type: Option, } impl GraphResponse { pub fn as_str(&self) -> Result<&str, GraphError> { std::str::from_utf8(&self.body) .map_err(|e| GraphError::Malformed(format!("response is not utf-8: {e}"))) } pub fn json(&self) -> Result { serde_json::from_slice(&self.body) .map_err(|e| GraphError::Malformed(format!("response is not valid json: {e}"))) } } struct Inner { agent: Agent, lax_agent: Option, certs: CertOverride, bearer: Mutex, retry: RetryPolicy, rate_limit: RateLimitState, log_level: AtomicU8, retries_total: AtomicU64, retry_after_sleeps: AtomicU64, requests_total: AtomicU64, user_agent: String, } impl Inner { /// The agent for `url`: the one that accepts invalid certificates only for /// a host `--allow-invalid-certs` covers, and the verifying one otherwise. fn agent_for(&self, url: &str) -> &Agent { match &self.lax_agent { Some(lax) if self.certs.allows(url) => lax, _ => &self.agent, } } } #[derive(Clone)] pub struct GraphClient { inner: Arc, } enum Attempt { Ok { status: u16, body: Vec, retry_after: Option, content_type: Option, }, Transport(GraphError), } impl GraphClient { pub fn new(bearer: String, retry: RetryPolicy, certs: CertOverride) -> GraphClient { let build = |accept_invalid: bool| -> Agent { let config: Config = with_timeouts!( Config::builder() .http_status_as_error(false) .redirect_auth_headers(RedirectAuthHeaders::SameHost) .tls_config(tls(accept_invalid)) ) .build(); config.new_agent() }; let lax_agent = certs.is_active().then(|| build(true)); GraphClient { inner: Arc::new(Inner { agent: build(false), lax_agent, certs, bearer: Mutex::new(bearer), retry, rate_limit: RateLimitState::new(), log_level: AtomicU8::new(LEVEL_DEFAULT), retries_total: AtomicU64::new(0), retry_after_sleeps: AtomicU64::new(0), requests_total: AtomicU64::new(0), user_agent: format!("inbuxa-migrate/{}", env!("CARGO_PKG_VERSION")), }), } } pub fn set_bearer(&self, bearer: String) { let mut g = match self.inner.bearer.lock() { Ok(g) => g, Err(p) => p.into_inner(), }; *g = bearer; } pub fn set_logger(&self, logger: Logger) { self.inner .log_level .store(logger.level(), Ordering::Relaxed); } fn logger(&self) -> Logger { Logger::new(self.inner.log_level.load(Ordering::Relaxed)) } fn bearer(&self) -> String { match self.inner.bearer.lock() { Ok(g) => g.clone(), Err(p) => p.into_inner().clone(), } } fn auth_header(&self) -> String { format!("Bearer {}", self.bearer()) } pub fn retries_observed(&self) -> u64 { self.inner.retries_total.load(Ordering::Relaxed) } pub fn retry_after_sleeps(&self) -> u64 { self.inner.retry_after_sleeps.load(Ordering::Relaxed) } pub fn requests_observed(&self) -> u64 { self.inner.requests_total.load(Ordering::Relaxed) } pub fn rate_limit(&self) -> &RateLimitState { &self.inner.rate_limit } pub fn get(&self, url: &str, accept: Accept) -> Result { self.execute("GET", url, accept, &[]) } pub fn get_with_prefer( &self, url: &str, accept: Accept, prefer: &[&str], ) -> Result { self.execute("GET", url, accept, prefer) } pub fn get_json(&self, url: &str) -> Result { self.get(url, Accept::Json).and_then(|r| r.json()) } pub fn get_json_with_prefer(&self, url: &str, prefer: &[&str]) -> Result { self.get_with_prefer(url, Accept::Json, prefer) .and_then(|r| r.json()) } fn execute( &self, method: &str, url: &str, accept: Accept, extra_prefer: &[&str], ) -> Result { let policy = self.inner.retry; let logger = self.logger(); let mut attempt: u32 = 0; loop { self.inner.rate_limit.cooldown().wait(); self.inner.requests_total.fetch_add(1, Ordering::Relaxed); let outcome = self.one_attempt(method, url, accept, extra_prefer); match outcome { Attempt::Ok { status, body, retry_after, content_type, } => { let class = classify_http_status(status); match class { HttpClass::Success => { self.inner.rate_limit.on_success(); return Ok(GraphResponse { status, body, content_type, }); } HttpClass::Vanished => { return Err(GraphError::Vanished); } HttpClass::Auth => { return Err(GraphError::Auth(format!( "server returned {status}: {}", truncate(&body) ))); } HttpClass::Fatal => { return Err(GraphError::HttpStatus { status, body: truncate(&body), }); } HttpClass::Retryable => { attempt += 1; self.inner.retries_total.fetch_add(1, Ordering::Relaxed); if attempt > policy.max_retries { return Err(GraphError::RetriesExhausted(format!( "{method} {url} kept returning http {status}" ))); } if retry_after.is_some() { self.inner .retry_after_sleeps .fetch_add(1, Ordering::Relaxed); } let delay = if is_throttled(status) { self.inner.rate_limit.on_throttle(&policy, retry_after) } else { match retry_after { Some(d) => d, None => retry::backoff_delay(&policy, attempt), } }; if delay >= LONG_RETRY_THRESHOLD { logger.warn(&format!( "graph rate-limited (http {status}); waiting {} before retry {}/{} (shared level {})", format_retry_wait(delay), attempt, policy.max_retries, self.inner.rate_limit.level(), )); } self.log_retry( &logger, RetryLog { method, url, attempt, delay, status, body: &body, }, ); std::thread::sleep(delay); } } } Attempt::Transport(err) => { if matches!(err, GraphError::Connect(_)) { return Err(err); } attempt += 1; self.inner.retries_total.fetch_add(1, Ordering::Relaxed); if attempt > policy.max_retries { return Err(GraphError::RetriesExhausted(format!( "{method} {url}: {err}" ))); } let delay = retry::backoff_delay(&policy, attempt); if delay >= LONG_RETRY_THRESHOLD { logger.warn(&format!( "transient transport failure ({err}); waiting {} before retry {}/{}", format_retry_wait(delay), attempt, policy.max_retries )); } std::thread::sleep(delay); } } } } fn one_attempt( &self, method: &str, url: &str, accept: Accept, extra_prefer: &[&str], ) -> Attempt { let mut req = match method { "GET" => self.inner.agent_for(url).get(url), other => { return Attempt::Transport(GraphError::Connect(format!( "unsupported method {other} (graph importer is read-only)" ))); } }; req = req .header("Authorization", self.auth_header()) .header("Accept", accept.header_value()) .header("User-Agent", self.inner.user_agent.as_str()); req = req.header("Prefer", "IdType=\"ImmutableId\""); for value in extra_prefer { req = req.header("Prefer", *value); } let logger = self.logger(); let started = Instant::now(); match req.call() { Ok(mut resp) => { let status = resp.status().as_u16(); let final_uri = resp.get_uri().clone(); let retry_after = resp .headers() .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(retry_after_header); let content_type = resp .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .map(str::to_owned); match resp.body_mut().with_config().limit(MAX_BODY).read_to_vec() { Ok(bytes) => { warn_on_redirect(&logger, method, url, &final_uri); logger.trace_http(&HttpCall { proto: "Graph", method, url, status, elapsed: started.elapsed(), note: None, request: None, request_type: None, response: &bytes, response_type: content_type.as_deref(), }); Attempt::Ok { status, body: bytes, retry_after, content_type, } } Err(e) => Attempt::Transport(GraphError::Transport(format!( "reading response body: {e}" ))), } } Err(e) => { let err = map_ureq_error(e); logger.trace_http_error("Graph", method, url, &err.to_string(), started.elapsed()); Attempt::Transport(err) } } } fn log_retry(&self, logger: &Logger, info: RetryLog<'_>) { let RetryLog { method, url, attempt, delay, status, body, } = info; if logger.enabled(LEVEL_BODIES) { eprintln!( "retry {}/{} {} {} after {:?} (http {status}) body={}", attempt, self.inner.retry.max_retries, method, url, delay, truncate(body) ); } else if logger.enabled(LEVEL_PROGRESS) { eprintln!( "retry {}/{} (http {status})", attempt, self.inner.retry.max_retries ); } } } struct RetryLog<'a> { method: &'a str, url: &'a str, attempt: u32, delay: Duration, status: u16, body: &'a [u8], } fn warn_on_redirect(logger: &Logger, method: &str, requested: &str, final_uri: &Uri) { if requested.ends_with("/content") { return; } let landed = final_uri.to_string(); if cross_host(requested, &landed) { let host = url::Url::parse(&landed) .ok() .and_then(|u| u.host_str().map(str::to_owned)) .unwrap_or_else(|| "another host".to_owned()); logger.warn(&format!( "{method} {requested} was redirected across hosts to {host}; verify the Graph endpoint host is reachable directly without redirection" )); } } fn map_ureq_error(err: ureq::Error) -> GraphError { match err { ureq::Error::Io(e) => GraphError::Transport(format!("io: {e}")), ureq::Error::Timeout(t) => GraphError::Transport(format!("timeout: {t}")), ureq::Error::HostNotFound => GraphError::Transport("host not found".to_owned()), ureq::Error::ConnectionFailed => GraphError::Transport("connection failed".to_owned()), ureq::Error::BodyStalled => GraphError::Transport("body stalled".to_owned()), ureq::Error::Tls(m) => GraphError::Transport(format!("tls: {m}")), ureq::Error::TooManyRedirects => GraphError::Connect("too many redirects".to_owned()), ureq::Error::RedirectFailed => GraphError::Connect("redirect failed".to_owned()), ureq::Error::TlsRequired => { GraphError::Connect("server requires TLS but transport is unsecured".to_owned()) } other => GraphError::Connect(other.to_string()), } } fn truncate(body: &[u8]) -> String { let text = String::from_utf8_lossy(body); if text.len() <= 512 { return text.into_owned(); } let end = text.floor_char_boundary(512); format!("{}...", &text[..end]) } fn format_retry_wait(d: Duration) -> String { let total = d.as_secs(); if total < 60 { format!("{total}s") } else if total < 3600 { format!("{}m{:02}s", total / 60, total % 60) } else { format!( "{}h{:02}m{:02}s", total / 3600, (total % 3600) / 60, total % 60 ) } } #[cfg(test)] mod tests { use super::*; use crate::net::CertOverride; #[test] fn every_timeout_is_a_transport_error_and_so_retried() { // `execute` retries every GraphError::Transport; only Connect is fatal. for t in [ ureq::Timeout::Connect, ureq::Timeout::SendRequest, ureq::Timeout::SendBody, ureq::Timeout::RecvResponse, ureq::Timeout::RecvBody, ] { let err = map_ureq_error(ureq::Error::Timeout(t)); assert!(matches!(err, GraphError::Transport(_)), "{t:?} -> {err:?}"); } } #[test] fn defaults_construct() { let c = GraphClient::new( "token".to_owned(), RetryPolicy::new(3), CertOverride::none(), ); assert_eq!(c.retries_observed(), 0); assert_eq!(c.retry_after_sleeps(), 0); assert_eq!(c.requests_observed(), 0); assert_eq!(c.auth_header(), "Bearer token"); } #[test] fn bearer_can_be_swapped_at_runtime() { let c = GraphClient::new("old".to_owned(), RetryPolicy::new(0), CertOverride::none()); c.set_bearer("new".to_owned()); assert_eq!(c.auth_header(), "Bearer new"); } #[test] fn accept_header_values_are_what_the_spec_requires() { assert_eq!(Accept::Json.header_value(), "application/json"); assert_eq!(Accept::Text.header_value(), "text/plain"); } }