Skip to main content

grr_cli/core/
http.rs

1//! Service-agnostic HTTP core: the transport probe behind `grr transport`,
2//! and the request-execution engine (`execute` plus the verb builders) it
3//! hosts.
4//!
5//! Zero-config by construction: all tuning is compile-time constants.
6//! tokio sizes OS threads to cores; the semaphore bounds only in-flight
7//! HTTP requests so bursts stay under Google's per-user rate limits.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use reqwest::Client as ReqwestClient;
13use reqwest::header::{ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
14use tokio::sync::Semaphore;
15use tracing::{debug, info, warn};
16use url::Url;
17
18use crate::core::auth::GoogleAuth;
19use crate::core::error::{GrrError, Result, api_error};
20
21// ── Auto-tuned transport constants ────────────────────────────────────────
22
23/// Hard ceiling on in-flight HTTP requests. Google's per-user rate limits
24/// (not CPU) are the scarce resource; exceeding this burns quota into 429s.
25pub(crate) const MAX_INFLIGHT_REQUESTS: usize = 64;
26/// Whole-request timeout (includes server processing time).
27const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
28/// TCP+TLS establishment only.
29const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
30/// Idle keepalive connections per host for instant reuse.
31const POOL_IDLE_PER_HOST: usize = 32;
32const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
33const TCP_KEEPALIVE: Duration = Duration::from_secs(60);
34const H2_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
35/// Transient-failure retries (connect/timeout/5xx/429).
36const RETRY_ATTEMPTS: u32 = 3;
37/// Exponential backoff base; grows 100ms -> 200ms -> 400ms.
38const RETRY_BACKOFF_MS: u64 = 100;
39/// Never sleep longer than this honoring a 429 Retry-After mid-command.
40const MAX_RATE_LIMIT_WAIT: Duration = Duration::from_secs(30);
41
42pub(crate) fn parse_url(raw: &str, kind: &str) -> Result<Url> {
43    Url::parse(raw).map_err(|error| GrrError::Config(format!("Invalid {kind} URL: {error}")))
44}
45
46#[derive(Debug, PartialEq, Eq, Clone, Copy)]
47pub enum TransportMode {
48    Http3PriorKnowledge,
49    Http2PriorKnowledge,
50}
51
52pub fn resolve_transport_mode(use_http3: bool) -> TransportMode {
53    if use_http3 {
54        TransportMode::Http3PriorKnowledge
55    } else {
56        TransportMode::Http2PriorKnowledge
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct TransportInfo {
62    pub negotiated_version: String,
63    pub http3_requested: bool,
64    pub http3_effective: bool,
65    pub fell_back: bool,
66}
67
68impl Default for TransportInfo {
69    fn default() -> Self {
70        Self {
71            negotiated_version: "not-probed".into(),
72            http3_requested: true,
73            http3_effective: false,
74            fell_back: false,
75        }
76    }
77}
78
79/// The authenticated HTTP engine. [`HttpCore::connect`] negotiates the
80/// transport version against a service base URL and records it in
81/// [`TransportInfo`]; `grr transport` reports that result.
82#[derive(Clone)]
83pub struct HttpCore {
84    auth: Arc<GoogleAuth>,
85    http_client: ReqwestClient,
86    semaphore: Arc<Semaphore>,
87    transport_info: TransportInfo,
88}
89
90impl HttpCore {
91    /// Build the core, probing the negotiated HTTP version against the
92    /// service base URL (any authenticated probe path works; a dead
93    /// stored token must not brick construction — `grr auth login`
94    /// recovers without one).
95    pub async fn connect(auth: GoogleAuth, base_url: &Url, probe_path: &str) -> Result<Self> {
96        let mut info = TransportInfo {
97            http3_requested: true,
98            http3_effective: true,
99            ..TransportInfo::default()
100        };
101        let mut http_client = build_http_client()?;
102
103        match probe(
104            &http_client,
105            base_url,
106            &auth,
107            reqwest::Version::HTTP_3,
108            probe_path,
109        )
110        .await
111        {
112            Ok(version) => info.negotiated_version = version,
113            Err(GrrError::Auth(error)) => return Err(GrrError::Auth(error)),
114            Err(error) => {
115                warn!("HTTP/3 probe failed ({error}); rebuilding with HTTP/2");
116                info.http3_effective = false;
117                info.fell_back = true;
118                http_client = build_http_client_with_mode(TransportMode::Http2PriorKnowledge)?;
119                match probe(
120                    &http_client,
121                    base_url,
122                    &auth,
123                    reqwest::Version::HTTP_2,
124                    probe_path,
125                )
126                .await
127                {
128                    Ok(version) => info.negotiated_version = version,
129                    Err(error) => {
130                        warn!("HTTP/2 probe failed ({error}); continuing unprobed");
131                        info.negotiated_version = "not-probed".into();
132                    }
133                }
134            }
135        }
136
137        info!(
138            "HttpCore connected (probe {}: {}): http3_effective={}, max_inflight_requests={}",
139            probe_path, info.negotiated_version, info.http3_effective, MAX_INFLIGHT_REQUESTS
140        );
141
142        Ok(Self {
143            auth: Arc::new(auth),
144            http_client,
145            semaphore: Arc::new(Semaphore::new(MAX_INFLIGHT_REQUESTS)),
146            transport_info: info,
147        })
148    }
149
150    /// Start a GET request against an absolute URL.
151    pub fn get(&self, url: Url) -> reqwest::RequestBuilder {
152        self.http_client.get(url)
153    }
154
155    /// Start a POST request against an absolute URL.
156    pub fn post(&self, url: Url) -> reqwest::RequestBuilder {
157        self.http_client.post(url)
158    }
159
160    /// Start a PUT request against an absolute URL.
161    pub fn put(&self, url: Url) -> reqwest::RequestBuilder {
162        self.http_client.put(url)
163    }
164
165    /// Start a PATCH request against an absolute URL.
166    pub fn patch(&self, url: Url) -> reqwest::RequestBuilder {
167        self.http_client.patch(url)
168    }
169
170    /// Start a DELETE request against an absolute URL.
171    pub fn delete(&self, url: Url) -> reqwest::RequestBuilder {
172        self.http_client.delete(url)
173    }
174
175    /// The underlying reqwest client (multipart uploads, raw control).
176    pub fn http_client(&self) -> &ReqwestClient {
177        &self.http_client
178    }
179
180    /// Shared Google OAuth handle (login, device flow, token backend).
181    pub fn auth(&self) -> &GoogleAuth {
182        &self.auth
183    }
184
185    /// Transport negotiation details observed during connection.
186    pub fn transport_info(&self) -> &TransportInfo {
187        &self.transport_info
188    }
189
190    /// Execute a request: bearer auth, HTTP/3 version pinning, in-flight
191    /// valve, and the retry policy (429 Retry-After honored up to
192    /// [`MAX_RATE_LIMIT_WAIT`]; 401 fails fast with a `grr auth login`
193    /// hint; transient errors back off exponentially).
194    pub async fn execute(&self, mut request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
195        let _permit = self
196            .semaphore
197            .acquire()
198            .await
199            .map_err(|_| GrrError::Internal("Semaphore closed".into()))?;
200
201        let token = self.auth.get_access_token().await?;
202        request = request.header(AUTHORIZATION, format!("Bearer {}", token));
203        request = apply_transport_version(request, self.transport_info.http3_effective);
204
205        let mut pending = Some(request);
206        let mut last_error = None;
207
208        for attempt in 0..=RETRY_ATTEMPTS {
209            // Streaming bodies are non-replayable: try_clone() yields None and
210            // only one send attempt is possible, so the builder is consumed.
211            let to_send = match pending.take() {
212                None => break,
213                Some(builder) => match builder.try_clone() {
214                    Some(replayable) => {
215                        pending = Some(builder);
216                        replayable
217                    }
218                    None => builder,
219                },
220            };
221
222            let response = to_send.send().await;
223
224            match response {
225                Ok(resp) => {
226                    let status = resp.status();
227
228                    if status.is_success() {
229                        return Ok(resp);
230                    }
231
232                    match status.as_u16() {
233                        401 => {
234                            // The bearer token above was fetched once before
235                            // this loop, so retrying re-sends the same stale
236                            // credential and can never succeed — and clearing
237                            // storage would drop callers into the implicit
238                            // OAuth flow mid-request. Fail fast instead.
239                            return Err(GrrError::Auth(
240                                "request rejected as unauthorized (401); \
241                                 your access token is expired or invalid — \
242                                 rerun `grr auth login`"
243                                    .into(),
244                            ));
245                        }
246                        429 => {
247                            // Rate limited. Honor the server's Retry-After and
248                            // keep going when the wait is short; surface the
249                            // wait immediately when it is not.
250                            let retry_after = resp
251                                .headers()
252                                .get("retry-after")
253                                .and_then(|h| h.to_str().ok())
254                                .and_then(|s| s.parse::<u64>().ok())
255                                .unwrap_or(60);
256                            let wait = Duration::from_secs(retry_after);
257                            if attempt < RETRY_ATTEMPTS && wait <= MAX_RATE_LIMIT_WAIT {
258                                warn!(
259                                    "rate limited; honoring Retry-After of {retry_after}s \
260                                     (attempt {}/{})",
261                                    attempt + 1,
262                                    RETRY_ATTEMPTS
263                                );
264                                tokio::time::sleep(wait).await;
265                                continue;
266                            }
267                            return Err(GrrError::RateLimited {
268                                retry_after_secs: retry_after,
269                            });
270                        }
271                        403 => {
272                            return Err(GrrError::PermissionDenied(
273                                "Insufficient permissions for this API or scope \
274                                 (was the API enabled in Google Cloud, and does \
275                                 your login include its scope? rerun `grr auth login` \
276                                 to grant new scopes)"
277                                    .into(),
278                            ));
279                        }
280                        404 => {
281                            return Err(GrrError::NotFound("Resource not found".into()));
282                        }
283                        500..=599 => {
284                            let body = resp.text().await.unwrap_or_default();
285                            last_error = Some(api_error(status.as_u16(), &body));
286                        }
287                        _ => {
288                            let error_text = resp.text().await.unwrap_or_default();
289                            return Err(api_error(status.as_u16(), &error_text));
290                        }
291                    }
292                }
293                Err(e) => {
294                    if e.is_timeout() || e.is_connect() || e.is_request() {
295                        last_error = Some(GrrError::Http(e));
296                    } else {
297                        return Err(GrrError::Http(e));
298                    }
299                }
300            }
301
302            if attempt < RETRY_ATTEMPTS {
303                let backoff = RETRY_BACKOFF_MS * (2_u64.pow(attempt));
304                debug!(
305                    "Request failed, retrying in {:?} (attempt {}/{})",
306                    backoff,
307                    attempt + 1,
308                    RETRY_ATTEMPTS
309                );
310                tokio::time::sleep(Duration::from_millis(backoff)).await;
311            }
312        }
313
314        Err(last_error.unwrap_or_else(|| GrrError::Internal("Max retries exceeded".into())))
315    }
316}
317
318/// Apply the transport-level HTTP version override for data-plane requests.
319///
320/// reqwest only routes an individual request over HTTP/3 when the request
321/// itself carries `version(HTTP_3)`; client-level `http3_prior_knowledge()`
322/// merely builds the QUIC connector. When h3 is effective, every API request
323/// must be explicitly versioned or it silently rides TCP.
324pub fn apply_transport_version(
325    builder: reqwest::RequestBuilder,
326    http3_effective: bool,
327) -> reqwest::RequestBuilder {
328    if http3_effective {
329        builder.version(reqwest::Version::HTTP_3)
330    } else {
331        builder
332    }
333}
334
335/// Probe the negotiated HTTP version with a real authenticated request.
336///
337/// Any HTTP response — even a 404/401 — proves the transport negotiated
338/// (some services, e.g. Forms, have no cheap "list" endpoint to hit).
339/// Only transport-level failures (connect/timeout) fail the probe.
340async fn probe(
341    client: &ReqwestClient,
342    base: &Url,
343    auth: &GoogleAuth,
344    version: reqwest::Version,
345    probe_path: &str,
346) -> std::result::Result<String, GrrError> {
347    let token = auth.get_access_token().await?;
348    let url = base.join(probe_path)?;
349    let resp = client
350        .get(url)
351        .bearer_auth(&token)
352        .version(version)
353        .send()
354        .await?;
355    Ok(format!("{:?}", resp.version()))
356}
357
358/// Build HTTP client with auto-tuned transport settings.
359///
360/// Zero-config: every value is a constant tuned for Google frontends and
361/// bounded async I/O; the tokio runtime already sizes OS threads to cores.
362pub fn build_http_client() -> Result<ReqwestClient> {
363    build_http_client_with_mode(resolve_transport_mode(true))
364}
365
366fn build_http_client_with_mode(mode: TransportMode) -> Result<ReqwestClient> {
367    let mut builder = ReqwestClient::builder()
368        .timeout(REQUEST_TIMEOUT)
369        .connect_timeout(CONNECT_TIMEOUT)
370        .pool_max_idle_per_host(POOL_IDLE_PER_HOST)
371        .pool_idle_timeout(POOL_IDLE_TIMEOUT)
372        .tcp_keepalive(TCP_KEEPALIVE)
373        .http2_keep_alive_interval(H2_KEEPALIVE_INTERVAL)
374        .http2_adaptive_window(true)
375        .brotli(true)
376        .zstd(true)
377        .gzip(true);
378
379    builder = match mode {
380        TransportMode::Http3PriorKnowledge => builder.http3_prior_knowledge(),
381        TransportMode::Http2PriorKnowledge => builder.http2_prior_knowledge(),
382    };
383
384    let mut headers = HeaderMap::new();
385    headers.insert(
386        ACCEPT_ENCODING,
387        HeaderValue::from_static("zstd, br, gzip, deflate"),
388    );
389    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
390
391    builder
392        .default_headers(headers)
393        .build()
394        .map_err(|error| GrrError::Config(format!("Failed to build HTTP client: {error}")))
395}