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, SystemTime, UNIX_EPOCH};
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};
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. Also the wait a
38/// 429 with no usable `Retry-After` takes, which is how that case stays
39/// inside [`MAX_RATE_LIMIT_WAIT`] and therefore retryable at all.
40const RETRY_BACKOFF_MS: u64 = 100;
41/// Never sleep longer than this honoring a 429 Retry-After mid-command.
42const MAX_RATE_LIMIT_WAIT: Duration = Duration::from_secs(30);
43
44/// Month abbreviations of an `IMF-fixdate` (RFC 9110 §5.6.7), position + 1
45/// giving the month number.
46const MONTHS: [&str; 12] = [
47    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
48];
49
50/// Backoff before retry `attempt + 1`, doubling from [`RETRY_BACKOFF_MS`].
51fn retry_backoff(attempt: u32) -> Duration {
52    Duration::from_millis(RETRY_BACKOFF_MS * 2_u64.pow(attempt))
53}
54
55/// Seconds a 429 asks us to wait, per RFC 9110 §10.2.3: a bare
56/// `delta-seconds` or an HTTP-date. `None` when the header is absent or
57/// unusable — the caller then falls back to [`retry_backoff`]. A date in the
58/// past means "retry now", i.e. zero.
59fn parse_retry_after(value: Option<&str>, now: SystemTime) -> Option<Duration> {
60    let raw = value?.trim();
61    if let Ok(seconds) = raw.parse::<u64>() {
62        return Some(Duration::from_secs(seconds));
63    }
64    let at = http_date_unix_secs(raw)?;
65    let now = now.duration_since(UNIX_EPOCH).ok()?.as_secs() as i64;
66    Some(Duration::from_secs(at.saturating_sub(now).max(0) as u64))
67}
68
69/// Unix seconds for an `IMF-fixdate` (`Sun, 06 Nov 1994 08:49:37 GMT`), the
70/// only date format a `Retry-After` may carry today. Hand-rolled because the
71/// alternative is a calendar crate for one header — the weekday is
72/// redundant with the date and is not verified.
73fn http_date_unix_secs(raw: &str) -> Option<i64> {
74    let (_weekday, rest) = raw.split_once(", ")?;
75    let mut fields = rest.split(' ');
76    let day: i64 = fields.next()?.parse().ok()?;
77    let month_name = fields.next()?;
78    let month = MONTHS.iter().position(|m| *m == month_name)? as i64 + 1;
79    let year: i64 = fields.next()?.parse().ok()?;
80    let mut clock = fields.next()?.split(':');
81    let hour: i64 = clock.next()?.parse().ok()?;
82    let minute: i64 = clock.next()?.parse().ok()?;
83    let second: i64 = clock.next()?.parse().ok()?;
84    if fields.next()? != "GMT" {
85        return None;
86    }
87    // 60 for a leap second, which RFC 9110 permits in this format.
88    if !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 {
89        return None;
90    }
91    Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
92}
93
94/// Days since 1970-01-01 for a proleptic Gregorian date — Howard Hinnant's
95/// `days_from_civil`, integer-only arithmetic so no calendar crate is needed.
96fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
97    let year = if month <= 2 { year - 1 } else { year };
98    let era = if year >= 0 { year } else { year - 399 } / 400;
99    let year_of_era = year - era * 400;
100    let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
101    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
102    era * 146_097 + day_of_era - 719_468
103}
104
105pub(crate) fn parse_url(raw: &str, kind: &str) -> Result<Url> {
106    Url::parse(raw).map_err(|error| GrrError::Config(format!("Invalid {kind} URL: {error}")))
107}
108
109#[derive(Debug, PartialEq, Eq, Clone, Copy)]
110pub enum TransportMode {
111    Http3PriorKnowledge,
112    Http2PriorKnowledge,
113}
114
115/// The transport a client is built for: HTTP/3 where it is requested, HTTP/2
116/// prior knowledge on the fallback path [`HttpCore::connect`] takes when the
117/// HTTP/3 probe fails. Both call sites go through here so the mode is chosen
118/// in exactly one place.
119pub fn resolve_transport_mode(use_http3: bool) -> TransportMode {
120    if use_http3 {
121        TransportMode::Http3PriorKnowledge
122    } else {
123        TransportMode::Http2PriorKnowledge
124    }
125}
126
127#[derive(Debug, Clone)]
128pub struct TransportInfo {
129    pub negotiated_version: String,
130    pub http3_requested: bool,
131    pub http3_effective: bool,
132    pub fell_back: bool,
133}
134
135impl Default for TransportInfo {
136    fn default() -> Self {
137        Self {
138            negotiated_version: "not-probed".into(),
139            http3_requested: true,
140            http3_effective: false,
141            fell_back: false,
142        }
143    }
144}
145
146/// The authenticated HTTP engine. [`HttpCore::connect`] negotiates the
147/// transport version against a service base URL and records it in
148/// [`TransportInfo`]; `grr transport` reports that result.
149#[derive(Clone)]
150pub struct HttpCore {
151    auth: Arc<GoogleAuth>,
152    http_client: ReqwestClient,
153    semaphore: Arc<Semaphore>,
154    transport_info: TransportInfo,
155}
156
157impl HttpCore {
158    /// Build the core, probing the negotiated HTTP version against the
159    /// service base URL (any authenticated probe path works; a dead
160    /// stored token must not brick construction — `grr auth login`
161    /// recovers without one).
162    pub async fn connect(auth: GoogleAuth, base_url: &Url, probe_path: &str) -> Result<Self> {
163        let mut info = TransportInfo {
164            http3_requested: true,
165            http3_effective: true,
166            ..TransportInfo::default()
167        };
168        let mut http_client = build_http_client()?;
169
170        match probe(
171            &http_client,
172            base_url,
173            &auth,
174            reqwest::Version::HTTP_3,
175            probe_path,
176        )
177        .await
178        {
179            Ok(version) => info.negotiated_version = version,
180            Err(GrrError::Auth(error)) => return Err(GrrError::Auth(error)),
181            Err(error) => {
182                warn!("HTTP/3 probe failed ({error}); rebuilding with HTTP/2");
183                info.http3_effective = false;
184                info.fell_back = true;
185                http_client = build_http_client_with_mode(resolve_transport_mode(false))?;
186                match probe(
187                    &http_client,
188                    base_url,
189                    &auth,
190                    reqwest::Version::HTTP_2,
191                    probe_path,
192                )
193                .await
194                {
195                    Ok(version) => info.negotiated_version = version,
196                    Err(error) => {
197                        warn!("HTTP/2 probe failed ({error}); continuing unprobed");
198                        info.negotiated_version = "not-probed".into();
199                    }
200                }
201            }
202        }
203
204        info!(
205            "HttpCore connected (probe {}: {}): http3_effective={}, max_inflight_requests={}",
206            probe_path, info.negotiated_version, info.http3_effective, MAX_INFLIGHT_REQUESTS
207        );
208
209        Ok(Self {
210            auth: Arc::new(auth),
211            http_client,
212            semaphore: Arc::new(Semaphore::new(MAX_INFLIGHT_REQUESTS)),
213            transport_info: info,
214        })
215    }
216
217    /// Start a GET request against an absolute URL.
218    pub fn get(&self, url: Url) -> reqwest::RequestBuilder {
219        self.http_client.get(url)
220    }
221
222    /// Start a POST request against an absolute URL.
223    pub fn post(&self, url: Url) -> reqwest::RequestBuilder {
224        self.http_client.post(url)
225    }
226
227    /// Start a PUT request against an absolute URL.
228    pub fn put(&self, url: Url) -> reqwest::RequestBuilder {
229        self.http_client.put(url)
230    }
231
232    /// Start a PATCH request against an absolute URL.
233    pub fn patch(&self, url: Url) -> reqwest::RequestBuilder {
234        self.http_client.patch(url)
235    }
236
237    /// Start a DELETE request against an absolute URL.
238    pub fn delete(&self, url: Url) -> reqwest::RequestBuilder {
239        self.http_client.delete(url)
240    }
241
242    /// The underlying reqwest client (multipart uploads, raw control).
243    pub fn http_client(&self) -> &ReqwestClient {
244        &self.http_client
245    }
246
247    /// Shared Google OAuth handle (login, device flow, token backend).
248    pub fn auth(&self) -> &GoogleAuth {
249        &self.auth
250    }
251
252    /// Transport negotiation details observed during connection.
253    pub fn transport_info(&self) -> &TransportInfo {
254        &self.transport_info
255    }
256
257    /// Execute a request: bearer auth, HTTP/3 version pinning, in-flight
258    /// valve, and the retry policy the engine owns — a 429 honors
259    /// `Retry-After` up to [`MAX_RATE_LIMIT_WAIT`] (a longer server wait is
260    /// surfaced instead of slept through, and a missing or unusable header
261    /// falls back to [`retry_backoff`]), a 401 fails fast with a
262    /// `grr auth login` hint because the token was fetched once above the
263    /// loop and a retry would re-send the same dead credential, and
264    /// connect/timeout/5xx failures back off exponentially.
265    ///
266    /// Every other status comes back as `Ok`, including a 5xx that survived
267    /// the retries: rendering a server error is the caller's business
268    /// (`api.rs` names the missing scope on a 403 and quotes Google's own
269    /// body for everything else), and the engine cannot know the verb and
270    /// URL the caller wants in that message.
271    pub async fn execute(&self, mut request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
272        let _permit = self
273            .semaphore
274            .acquire()
275            .await
276            .map_err(|_| GrrError::Internal("Semaphore closed".into()))?;
277
278        let token = self.auth.get_access_token().await?;
279        request = request.header(AUTHORIZATION, format!("Bearer {}", token));
280        request = apply_transport_version(request, self.transport_info.http3_effective);
281
282        let mut pending = Some(request);
283        let mut last_error = None;
284
285        for attempt in 0..=RETRY_ATTEMPTS {
286            // Streaming bodies are non-replayable: try_clone() yields None and
287            // only one send attempt is possible, so the builder is consumed.
288            let to_send = match pending.take() {
289                None => break,
290                Some(builder) => match builder.try_clone() {
291                    Some(replayable) => {
292                        pending = Some(builder);
293                        replayable
294                    }
295                    None => builder,
296                },
297            };
298
299            match to_send.send().await {
300                Ok(resp) => {
301                    let status = resp.status();
302
303                    match status.as_u16() {
304                        401 => {
305                            // The bearer token above was fetched once before
306                            // this loop, so retrying re-sends the same stale
307                            // credential and can never succeed — and clearing
308                            // storage would drop callers into the implicit
309                            // OAuth flow mid-request. Fail fast instead.
310                            return Err(GrrError::Auth(
311                                "request rejected as unauthorized (401); \
312                                 your access token is expired or invalid — \
313                                 rerun `grr auth login`"
314                                    .into(),
315                            ));
316                        }
317                        429 => {
318                            // Rate limited. Honor the server's Retry-After and
319                            // keep going when the wait is short; surface the
320                            // wait immediately when it is not. No usable
321                            // header means the transient backoff, which is
322                            // under the cap by construction — a fixed default
323                            // above it made every header-less 429 terminal.
324                            let server_wait = parse_retry_after(
325                                resp.headers()
326                                    .get("retry-after")
327                                    .and_then(|h| h.to_str().ok()),
328                                SystemTime::now(),
329                            );
330                            let wait = server_wait.unwrap_or_else(|| retry_backoff(attempt));
331                            if attempt < RETRY_ATTEMPTS && wait <= MAX_RATE_LIMIT_WAIT {
332                                warn!(
333                                    "rate limited; honoring a wait of {wait:?} \
334                                     (attempt {}/{})",
335                                    attempt + 1,
336                                    RETRY_ATTEMPTS
337                                );
338                                tokio::time::sleep(wait).await;
339                                continue;
340                            }
341                            // `retry_after_secs` reports what the server asked
342                            // for; a header-less 429 has nothing to report.
343                            return Err(GrrError::RateLimited {
344                                retry_after_secs: wait.as_secs(),
345                            });
346                        }
347                        // A 5xx is worth another attempt; the caller still
348                        // needs the response when the attempts run out.
349                        500..=599 if attempt < RETRY_ATTEMPTS => {}
350                        _ => return Ok(resp),
351                    }
352                }
353                Err(e) => {
354                    // Only transport-level failures are transient. `e` is
355                    // moved into the error, so the retryable test comes
356                    // first.
357                    let retryable = e.is_timeout() || e.is_connect() || e.is_request();
358                    if !retryable || attempt == RETRY_ATTEMPTS {
359                        return Err(GrrError::Http(e));
360                    }
361                    last_error = Some(GrrError::Http(e));
362                }
363            }
364
365            let backoff = retry_backoff(attempt);
366            debug!(
367                "Request failed, retrying in {:?} (attempt {}/{})",
368                backoff,
369                attempt + 1,
370                RETRY_ATTEMPTS
371            );
372            tokio::time::sleep(backoff).await;
373        }
374
375        Err(last_error.unwrap_or_else(|| GrrError::Internal("Max retries exceeded".into())))
376    }
377}
378
379/// Apply the transport-level HTTP version override for data-plane requests.
380///
381/// reqwest only routes an individual request over HTTP/3 when the request
382/// itself carries `version(HTTP_3)`; client-level `http3_prior_knowledge()`
383/// merely builds the QUIC connector. When h3 is effective, every API request
384/// must be explicitly versioned or it silently rides TCP.
385pub fn apply_transport_version(
386    builder: reqwest::RequestBuilder,
387    http3_effective: bool,
388) -> reqwest::RequestBuilder {
389    if http3_effective {
390        builder.version(reqwest::Version::HTTP_3)
391    } else {
392        builder
393    }
394}
395
396/// Probe the negotiated HTTP version with a real authenticated request.
397///
398/// Any HTTP response — even a 404/401 — proves the transport negotiated
399/// (some services, e.g. Forms, have no cheap "list" endpoint to hit).
400/// Only transport-level failures (connect/timeout) fail the probe.
401async fn probe(
402    client: &ReqwestClient,
403    base: &Url,
404    auth: &GoogleAuth,
405    version: reqwest::Version,
406    probe_path: &str,
407) -> std::result::Result<String, GrrError> {
408    let token = auth.get_access_token().await?;
409    let url = base.join(probe_path)?;
410    let resp = client
411        .get(url)
412        .bearer_auth(&token)
413        .version(version)
414        .send()
415        .await?;
416    Ok(format!("{:?}", resp.version()))
417}
418
419/// Build HTTP client with auto-tuned transport settings.
420///
421/// Zero-config: every value is a constant tuned for Google frontends and
422/// bounded async I/O; the tokio runtime already sizes OS threads to cores.
423pub fn build_http_client() -> Result<ReqwestClient> {
424    build_http_client_with_mode(resolve_transport_mode(true))
425}
426
427fn build_http_client_with_mode(mode: TransportMode) -> Result<ReqwestClient> {
428    let mut builder = ReqwestClient::builder()
429        .timeout(REQUEST_TIMEOUT)
430        .connect_timeout(CONNECT_TIMEOUT)
431        .pool_max_idle_per_host(POOL_IDLE_PER_HOST)
432        .pool_idle_timeout(POOL_IDLE_TIMEOUT)
433        .tcp_keepalive(TCP_KEEPALIVE)
434        .http2_keep_alive_interval(H2_KEEPALIVE_INTERVAL)
435        .http2_adaptive_window(true)
436        .brotli(true)
437        .zstd(true)
438        .gzip(true);
439
440    builder = match mode {
441        TransportMode::Http3PriorKnowledge => builder.http3_prior_knowledge(),
442        TransportMode::Http2PriorKnowledge => builder.http2_prior_knowledge(),
443    };
444
445    let mut headers = HeaderMap::new();
446    headers.insert(
447        ACCEPT_ENCODING,
448        HeaderValue::from_static("zstd, br, gzip, deflate"),
449    );
450    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
451
452    builder
453        .default_headers(headers)
454        .build()
455        .map_err(|error| GrrError::Config(format!("Failed to build HTTP client: {error}")))
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn retry_after_delta_seconds_is_read_directly() {
464        let now = UNIX_EPOCH;
465        assert_eq!(
466            parse_retry_after(Some("5"), now),
467            Some(Duration::from_secs(5))
468        );
469        assert_eq!(parse_retry_after(None, now), None);
470        assert_eq!(parse_retry_after(Some("soon"), now), None);
471    }
472
473    #[test]
474    fn retry_after_http_date_is_read_as_an_instant() {
475        // RFC 9110 §10.2.3's own example: 1994-11-06T08:49:37Z.
476        let header = Some("Sun, 06 Nov 1994 08:49:37 GMT");
477        assert_eq!(
478            parse_retry_after(header, UNIX_EPOCH),
479            Some(Duration::from_secs(784_111_777))
480        );
481        // Seen two seconds earlier, the same date is a two-second wait.
482        let now = UNIX_EPOCH + Duration::from_secs(784_111_775);
483        assert_eq!(parse_retry_after(header, now), Some(Duration::from_secs(2)));
484    }
485
486    #[test]
487    fn an_expired_retry_after_date_means_retry_now() {
488        let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
489        assert_eq!(
490            parse_retry_after(Some("Thu, 01 Jan 1970 00:00:00 GMT"), now),
491            Some(Duration::ZERO)
492        );
493    }
494
495    #[test]
496    fn retry_after_dates_handle_a_leap_day() {
497        assert_eq!(
498            parse_retry_after(Some("Tue, 29 Feb 2000 00:00:00 GMT"), UNIX_EPOCH),
499            Some(Duration::from_secs(951_782_400))
500        );
501    }
502
503    #[test]
504    fn a_malformed_retry_after_date_is_unusable_rather_than_wrong() {
505        let now = UNIX_EPOCH;
506        assert_eq!(
507            parse_retry_after(Some("Sun, 06 Nov 1994 08:49:37 UTC"), now),
508            None
509        );
510        assert_eq!(
511            parse_retry_after(Some("Sun, 06 Foo 1994 08:49:37 GMT"), now),
512            None
513        );
514        assert_eq!(parse_retry_after(Some("Sun, 06 Nov 1994"), now), None);
515    }
516
517    #[test]
518    fn the_headerless_rate_limit_fallback_is_always_retryable() {
519        // The whole point of the backoff fallback: it can never exceed the
520        // cap, because a wait past the cap is surfaced instead of slept.
521        for attempt in 0..=RETRY_ATTEMPTS {
522            assert!(retry_backoff(attempt) <= MAX_RATE_LIMIT_WAIT);
523        }
524        assert_eq!(retry_backoff(0), Duration::from_millis(100));
525        assert_eq!(retry_backoff(3), Duration::from_millis(800));
526    }
527}