use std::sync::Arc;
use std::time::Duration;
use reqwest::Client as ReqwestClient;
use reqwest::header::{ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::de::DeserializeOwned;
use tokio::sync::Semaphore;
use tracing::{debug, info, warn};
use url::Url;
use crate::core::auth::GoogleAuth;
use crate::core::error::{GrrError, Result, api_error};
pub(crate) const MAX_INFLIGHT_REQUESTS: usize = 64;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const POOL_IDLE_PER_HOST: usize = 32;
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
const TCP_KEEPALIVE: Duration = Duration::from_secs(60);
const H2_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
const RETRY_ATTEMPTS: u32 = 3;
const RETRY_BACKOFF_MS: u64 = 100;
const MAX_RATE_LIMIT_WAIT: Duration = Duration::from_secs(30);
#[derive(Default)]
pub(crate) struct QueryParams {
values: Vec<(String, String)>,
}
impl QueryParams {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn add(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.values.push((key.into(), value.into()));
self
}
pub(crate) fn add_optional(
mut self,
key: impl Into<String>,
value: Option<impl Into<String>>,
) -> Self {
if let Some(value) = value {
self.values.push((key.into(), value.into()));
}
self
}
pub(crate) fn add_page_token(self, token: Option<&str>) -> Self {
self.add_optional("pageToken", token)
}
pub(crate) fn apply(self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
request.query(&self.values)
}
}
pub(crate) fn parse_url(raw: &str, kind: &str) -> Result<Url> {
Url::parse(raw).map_err(|error| GrrError::Config(format!("Invalid {kind} URL: {error}")))
}
pub(crate) fn join_url(base: &Url, path: &str, kind: &str) -> Result<Url> {
base.join(path)
.map_err(|error| GrrError::Config(format!("Invalid {kind} URL: {error}")))
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TransportMode {
Http3PriorKnowledge,
Http2PriorKnowledge,
}
pub fn resolve_transport_mode(use_http3: bool) -> TransportMode {
if use_http3 {
TransportMode::Http3PriorKnowledge
} else {
TransportMode::Http2PriorKnowledge
}
}
#[derive(Debug, Clone)]
pub struct TransportInfo {
pub negotiated_version: String,
pub http3_requested: bool,
pub http3_effective: bool,
pub fell_back: bool,
}
impl Default for TransportInfo {
fn default() -> Self {
Self {
negotiated_version: "not-probed".into(),
http3_requested: true,
http3_effective: false,
fell_back: false,
}
}
}
#[derive(Clone)]
pub struct HttpCore {
auth: Arc<GoogleAuth>,
http_client: ReqwestClient,
semaphore: Arc<Semaphore>,
transport_info: TransportInfo,
}
impl HttpCore {
pub async fn connect(auth: GoogleAuth, base_url: &Url, probe_path: &str) -> Result<Self> {
let mut info = TransportInfo {
http3_requested: true,
http3_effective: true,
..TransportInfo::default()
};
let mut http_client = build_http_client()?;
match probe(
&http_client,
base_url,
&auth,
reqwest::Version::HTTP_3,
probe_path,
)
.await
{
Ok(version) => info.negotiated_version = version,
Err(GrrError::Auth(error)) => return Err(GrrError::Auth(error)),
Err(error) => {
warn!("HTTP/3 probe failed ({error}); rebuilding with HTTP/2");
info.http3_effective = false;
info.fell_back = true;
http_client = build_http_client_with_mode(TransportMode::Http2PriorKnowledge)?;
match probe(
&http_client,
base_url,
&auth,
reqwest::Version::HTTP_2,
probe_path,
)
.await
{
Ok(version) => info.negotiated_version = version,
Err(error) => {
warn!("HTTP/2 probe failed ({error}); continuing unprobed");
info.negotiated_version = "not-probed".into();
}
}
}
}
info!(
"HttpCore connected (probe {}: {}): http3_effective={}, max_inflight_requests={}",
probe_path, info.negotiated_version, info.http3_effective, MAX_INFLIGHT_REQUESTS
);
Ok(Self {
auth: Arc::new(auth),
http_client,
semaphore: Arc::new(Semaphore::new(MAX_INFLIGHT_REQUESTS)),
transport_info: info,
})
}
pub fn unprobed(auth: GoogleAuth, http_client: ReqwestClient) -> Self {
Self {
auth: Arc::new(auth),
http_client,
semaphore: Arc::new(Semaphore::new(MAX_INFLIGHT_REQUESTS)),
transport_info: TransportInfo {
negotiated_version: "not-probed".into(),
http3_requested: true,
http3_effective: false,
fell_back: false,
},
}
}
pub fn get(&self, url: Url) -> reqwest::RequestBuilder {
self.http_client.get(url)
}
pub fn post(&self, url: Url) -> reqwest::RequestBuilder {
self.http_client.post(url)
}
pub fn put(&self, url: Url) -> reqwest::RequestBuilder {
self.http_client.put(url)
}
pub fn patch(&self, url: Url) -> reqwest::RequestBuilder {
self.http_client.patch(url)
}
pub fn delete(&self, url: Url) -> reqwest::RequestBuilder {
self.http_client.delete(url)
}
pub fn http_client(&self) -> &ReqwestClient {
&self.http_client
}
pub fn auth(&self) -> &GoogleAuth {
&self.auth
}
pub fn transport_info(&self) -> &TransportInfo {
&self.transport_info
}
pub async fn execute(&self, mut request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
let _permit = self
.semaphore
.acquire()
.await
.map_err(|_| GrrError::Internal("Semaphore closed".into()))?;
let token = self.auth.get_access_token().await?;
request = request.header(AUTHORIZATION, format!("Bearer {}", token));
request = apply_transport_version(request, self.transport_info.http3_effective);
let mut pending = Some(request);
let mut last_error = None;
for attempt in 0..=RETRY_ATTEMPTS {
let to_send = match pending.take() {
None => break,
Some(builder) => match builder.try_clone() {
Some(replayable) => {
pending = Some(builder);
replayable
}
None => builder,
},
};
let response = to_send.send().await;
match response {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
match status.as_u16() {
401 => {
return Err(GrrError::Auth(
"request rejected as unauthorized (401); \
your access token is expired or invalid — \
rerun `grr auth login`"
.into(),
));
}
429 => {
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(60);
let wait = Duration::from_secs(retry_after);
if attempt < RETRY_ATTEMPTS && wait <= MAX_RATE_LIMIT_WAIT {
warn!(
"rate limited; honoring Retry-After of {retry_after}s \
(attempt {}/{})",
attempt + 1,
RETRY_ATTEMPTS
);
tokio::time::sleep(wait).await;
continue;
}
return Err(GrrError::RateLimited {
retry_after_secs: retry_after,
});
}
403 => {
return Err(GrrError::PermissionDenied(
"Insufficient permissions for this API or scope \
(was the API enabled in Google Cloud, and does \
your login include its scope? rerun `grr auth login` \
to grant new scopes)"
.into(),
));
}
404 => {
return Err(GrrError::NotFound("Resource not found".into()));
}
500..=599 => {
let body = resp.text().await.unwrap_or_default();
last_error = Some(api_error(status.as_u16(), &body));
}
_ => {
let error_text = resp.text().await.unwrap_or_default();
return Err(api_error(status.as_u16(), &error_text));
}
}
}
Err(e) => {
if e.is_timeout() || e.is_connect() || e.is_request() {
last_error = Some(GrrError::Http(e));
} else {
return Err(GrrError::Http(e));
}
}
}
if attempt < RETRY_ATTEMPTS {
let backoff = RETRY_BACKOFF_MS * (2_u64.pow(attempt));
debug!(
"Request failed, retrying in {:?} (attempt {}/{})",
backoff,
attempt + 1,
RETRY_ATTEMPTS
);
tokio::time::sleep(Duration::from_millis(backoff)).await;
}
}
Err(last_error.unwrap_or_else(|| GrrError::Internal("Max retries exceeded".into())))
}
pub(crate) async fn execute_json<T>(&self, request: reqwest::RequestBuilder) -> Result<T>
where
T: DeserializeOwned,
{
let response = self.execute(request).await?;
Ok(response.json().await?)
}
}
pub fn apply_transport_version(
builder: reqwest::RequestBuilder,
http3_effective: bool,
) -> reqwest::RequestBuilder {
if http3_effective {
builder.version(reqwest::Version::HTTP_3)
} else {
builder
}
}
async fn probe(
client: &ReqwestClient,
base: &Url,
auth: &GoogleAuth,
version: reqwest::Version,
probe_path: &str,
) -> std::result::Result<String, GrrError> {
let token = auth.get_access_token().await?;
let url = base.join(probe_path)?;
let resp = client
.get(url)
.bearer_auth(&token)
.version(version)
.send()
.await?;
Ok(format!("{:?}", resp.version()))
}
pub fn build_http_client() -> Result<ReqwestClient> {
build_http_client_with_mode(resolve_transport_mode(true))
}
fn build_http_client_with_mode(mode: TransportMode) -> Result<ReqwestClient> {
let mut builder = ReqwestClient::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.pool_max_idle_per_host(POOL_IDLE_PER_HOST)
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
.tcp_keepalive(TCP_KEEPALIVE)
.http2_keep_alive_interval(H2_KEEPALIVE_INTERVAL)
.http2_adaptive_window(true)
.brotli(true)
.zstd(true)
.gzip(true);
builder = match mode {
TransportMode::Http3PriorKnowledge => builder.http3_prior_knowledge(),
TransportMode::Http2PriorKnowledge => builder.http2_prior_knowledge(),
};
let mut headers = HeaderMap::new();
headers.insert(
ACCEPT_ENCODING,
HeaderValue::from_static("zstd, br, gzip, deflate"),
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
builder
.default_headers(headers)
.build()
.map_err(|error| GrrError::Config(format!("Failed to build HTTP client: {error}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_params_collects_optional_values_and_page_tokens() -> Result<()> {
let request = QueryParams::new()
.add("q", "in:inbox")
.add_optional("labelId", Some("INBOX"))
.add_optional("timeMin", None::<&str>)
.add_page_token(Some("next"))
.apply(ReqwestClient::new().get("https://example.com/messages"))
.build()?;
assert_eq!(
request.url().query(),
Some("q=in%3Ainbox&labelId=INBOX&pageToken=next")
);
Ok(())
}
}