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, Clone)]
110pub struct TransportInfo {
111    pub negotiated_version: String,
112    pub http3_requested: bool,
113    pub http3_effective: bool,
114    pub fell_back: bool,
115}
116
117impl Default for TransportInfo {
118    fn default() -> Self {
119        Self {
120            negotiated_version: "not-probed".into(),
121            http3_requested: true,
122            http3_effective: false,
123            fell_back: false,
124        }
125    }
126}
127
128/// The authenticated HTTP engine. [`HttpCore::connect`] negotiates the
129/// transport version against a service base URL and records it in
130/// [`TransportInfo`]; `grr transport` reports that result.
131#[derive(Clone)]
132pub struct HttpCore {
133    auth: Arc<GoogleAuth>,
134    http_client: ReqwestClient,
135    semaphore: Arc<Semaphore>,
136    transport_info: TransportInfo,
137}
138
139impl HttpCore {
140    /// Build the core, probing the negotiated HTTP version against the
141    /// service base URL (any authenticated probe path works; a dead
142    /// stored token must not brick construction — `grr auth login`
143    /// recovers without one).
144    pub async fn connect(auth: GoogleAuth, base_url: &Url, probe_path: &str) -> Result<Self> {
145        let mut info = TransportInfo {
146            http3_requested: true,
147            http3_effective: true,
148            ..TransportInfo::default()
149        };
150        let mut http_client = build_http_client()?;
151
152        match probe(
153            &http_client,
154            base_url,
155            &auth,
156            reqwest::Version::HTTP_3,
157            probe_path,
158        )
159        .await
160        {
161            Ok(version) => info.negotiated_version = version,
162            Err(GrrError::Auth(error)) => return Err(GrrError::Auth(error)),
163            Err(error) => {
164                warn!("HTTP/3 probe failed ({error}); rebuilding with HTTP/2");
165                info.http3_effective = false;
166                info.fell_back = true;
167                http_client = build_http_client_with_mode(false)?;
168                match probe(
169                    &http_client,
170                    base_url,
171                    &auth,
172                    reqwest::Version::HTTP_2,
173                    probe_path,
174                )
175                .await
176                {
177                    Ok(version) => info.negotiated_version = version,
178                    Err(error) => {
179                        warn!("HTTP/2 probe failed ({error}); continuing unprobed");
180                        info.negotiated_version = "not-probed".into();
181                    }
182                }
183            }
184        }
185
186        info!(
187            "HttpCore connected (probe {}: {}): http3_effective={}, max_inflight_requests={}",
188            probe_path, info.negotiated_version, info.http3_effective, MAX_INFLIGHT_REQUESTS
189        );
190
191        Ok(Self {
192            auth: Arc::new(auth),
193            http_client,
194            semaphore: Arc::new(Semaphore::new(MAX_INFLIGHT_REQUESTS)),
195            transport_info: info,
196        })
197    }
198
199    /// The underlying reqwest client (multipart uploads, raw control).
200    pub fn http_client(&self) -> &ReqwestClient {
201        &self.http_client
202    }
203
204    /// Transport negotiation details observed during connection.
205    pub fn transport_info(&self) -> &TransportInfo {
206        &self.transport_info
207    }
208
209    /// Execute a request: bearer auth, HTTP/3 version pinning, in-flight
210    /// valve, and the retry policy the engine owns — a 429 honors
211    /// `Retry-After` up to [`MAX_RATE_LIMIT_WAIT`] (a longer server wait is
212    /// surfaced instead of slept through, and a missing or unusable header
213    /// falls back to [`retry_backoff`]), a 401 fails fast with a
214    /// `grr auth login` hint because the token was fetched once above the
215    /// loop and a retry would re-send the same dead credential, and
216    /// connect/timeout/5xx failures back off exponentially.
217    ///
218    /// Every other status comes back as `Ok`, including a 5xx that survived
219    /// the retries: rendering a server error is the caller's business
220    /// (`api.rs` names the missing scope on a 403 and quotes Google's own
221    /// body for everything else), and the engine cannot know the verb and
222    /// URL the caller wants in that message.
223    pub async fn execute(&self, mut request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
224        let _permit = self
225            .semaphore
226            .acquire()
227            .await
228            .map_err(|_| GrrError::Internal("Semaphore closed".into()))?;
229
230        let token = self.auth.get_access_token().await?;
231        request = request.header(AUTHORIZATION, format!("Bearer {}", token));
232        request = apply_transport_version(request, self.transport_info.http3_effective);
233
234        let mut pending = Some(request);
235        let mut last_error = None;
236
237        for attempt in 0..=RETRY_ATTEMPTS {
238            // Streaming bodies are non-replayable: try_clone() yields None and
239            // only one send attempt is possible, so the builder is consumed.
240            let to_send = match pending.take() {
241                None => break,
242                Some(builder) => match builder.try_clone() {
243                    Some(replayable) => {
244                        pending = Some(builder);
245                        replayable
246                    }
247                    None => builder,
248                },
249            };
250
251            match to_send.send().await {
252                Ok(resp) => {
253                    let status = resp.status();
254
255                    match status.as_u16() {
256                        401 => {
257                            // The bearer token above was fetched once before
258                            // this loop, so retrying re-sends the same stale
259                            // credential and can never succeed — and clearing
260                            // storage would drop callers into the implicit
261                            // OAuth flow mid-request. Fail fast instead.
262                            return Err(GrrError::Auth(
263                                "request rejected as unauthorized (401); \
264                                 your access token is expired or invalid — \
265                                 rerun `grr auth login`"
266                                    .into(),
267                            ));
268                        }
269                        429 => {
270                            // Rate limited. Honor the server's Retry-After and
271                            // keep going when the wait is short; surface the
272                            // wait immediately when it is not. No usable
273                            // header means the transient backoff, which is
274                            // under the cap by construction — a fixed default
275                            // above it made every header-less 429 terminal.
276                            let server_wait = parse_retry_after(
277                                resp.headers()
278                                    .get("retry-after")
279                                    .and_then(|h| h.to_str().ok()),
280                                SystemTime::now(),
281                            );
282                            let wait = server_wait.unwrap_or_else(|| retry_backoff(attempt));
283                            if attempt < RETRY_ATTEMPTS && wait <= MAX_RATE_LIMIT_WAIT {
284                                warn!(
285                                    "rate limited; honoring a wait of {wait:?} \
286                                     (attempt {}/{})",
287                                    attempt + 1,
288                                    RETRY_ATTEMPTS
289                                );
290                                tokio::time::sleep(wait).await;
291                                continue;
292                            }
293                            // `retry_after_secs` reports what the server asked
294                            // for; a header-less 429 has nothing to report.
295                            return Err(GrrError::RateLimited {
296                                retry_after_secs: wait.as_secs(),
297                            });
298                        }
299                        // A 5xx is worth another attempt; the caller still
300                        // needs the response when the attempts run out.
301                        500..=599 if attempt < RETRY_ATTEMPTS => {}
302                        _ => return Ok(resp),
303                    }
304                }
305                Err(e) => {
306                    // Only transport-level failures are transient. `e` is
307                    // moved into the error, so the retryable test comes
308                    // first.
309                    let retryable = e.is_timeout() || e.is_connect() || e.is_request();
310                    if !retryable || attempt == RETRY_ATTEMPTS {
311                        return Err(GrrError::Http(e));
312                    }
313                    last_error = Some(GrrError::Http(e));
314                }
315            }
316
317            let backoff = retry_backoff(attempt);
318            debug!(
319                "Request failed, retrying in {:?} (attempt {}/{})",
320                backoff,
321                attempt + 1,
322                RETRY_ATTEMPTS
323            );
324            tokio::time::sleep(backoff).await;
325        }
326
327        Err(last_error.unwrap_or_else(|| GrrError::Internal("Max retries exceeded".into())))
328    }
329}
330
331/// Apply the transport-level HTTP version override for data-plane requests.
332///
333/// reqwest only routes an individual request over HTTP/3 when the request
334/// itself carries `version(HTTP_3)`; client-level `http3_prior_knowledge()`
335/// merely builds the QUIC connector. When h3 is effective, every API request
336/// must be explicitly versioned or it silently rides TCP.
337pub fn apply_transport_version(
338    builder: reqwest::RequestBuilder,
339    http3_effective: bool,
340) -> reqwest::RequestBuilder {
341    if http3_effective {
342        builder.version(reqwest::Version::HTTP_3)
343    } else {
344        builder
345    }
346}
347
348/// Probe the negotiated HTTP version with a real authenticated request.
349///
350/// Any HTTP response — even a 404/401 — proves the transport negotiated
351/// (some services, e.g. Forms, have no cheap "list" endpoint to hit).
352/// Only transport-level failures (connect/timeout) fail the probe.
353async fn probe(
354    client: &ReqwestClient,
355    base: &Url,
356    auth: &GoogleAuth,
357    version: reqwest::Version,
358    probe_path: &str,
359) -> std::result::Result<String, GrrError> {
360    let token = auth.get_access_token().await?;
361    let url = base.join(probe_path)?;
362    let resp = client
363        .get(url)
364        .bearer_auth(&token)
365        .version(version)
366        .send()
367        .await?;
368    Ok(format!("{:?}", resp.version()))
369}
370
371/// Build HTTP client with auto-tuned transport settings.
372///
373/// Zero-config: every value is a constant tuned for Google frontends and
374/// bounded async I/O; the tokio runtime already sizes OS threads to cores.
375pub fn build_http_client() -> Result<ReqwestClient> {
376    build_http_client_with_mode(true)
377}
378
379fn build_http_client_with_mode(use_http3: bool) -> Result<ReqwestClient> {
380    let mut builder = ReqwestClient::builder()
381        .timeout(REQUEST_TIMEOUT)
382        .connect_timeout(CONNECT_TIMEOUT)
383        .pool_max_idle_per_host(POOL_IDLE_PER_HOST)
384        .pool_idle_timeout(POOL_IDLE_TIMEOUT)
385        .tcp_keepalive(TCP_KEEPALIVE)
386        .http2_keep_alive_interval(H2_KEEPALIVE_INTERVAL)
387        .http2_adaptive_window(true)
388        .brotli(true)
389        .zstd(true)
390        .gzip(true);
391
392    builder = if use_http3 {
393        builder.http3_prior_knowledge()
394    } else {
395        builder.http2_prior_knowledge()
396    };
397
398    let mut headers = HeaderMap::new();
399    headers.insert(
400        ACCEPT_ENCODING,
401        HeaderValue::from_static("zstd, br, gzip, deflate"),
402    );
403    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
404
405    builder
406        .default_headers(headers)
407        .build()
408        .map_err(|error| GrrError::Config(format!("Failed to build HTTP client: {error}")))
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn retry_after_delta_seconds_is_read_directly() {
417        let now = UNIX_EPOCH;
418        assert_eq!(
419            parse_retry_after(Some("5"), now),
420            Some(Duration::from_secs(5))
421        );
422        assert_eq!(parse_retry_after(None, now), None);
423        assert_eq!(parse_retry_after(Some("soon"), now), None);
424    }
425
426    #[test]
427    fn retry_after_http_date_is_read_as_an_instant() {
428        // RFC 9110 §10.2.3's own example: 1994-11-06T08:49:37Z.
429        let header = Some("Sun, 06 Nov 1994 08:49:37 GMT");
430        assert_eq!(
431            parse_retry_after(header, UNIX_EPOCH),
432            Some(Duration::from_secs(784_111_777))
433        );
434        // Seen two seconds earlier, the same date is a two-second wait.
435        let now = UNIX_EPOCH + Duration::from_secs(784_111_775);
436        assert_eq!(parse_retry_after(header, now), Some(Duration::from_secs(2)));
437    }
438
439    #[test]
440    fn an_expired_retry_after_date_means_retry_now() {
441        let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
442        assert_eq!(
443            parse_retry_after(Some("Thu, 01 Jan 1970 00:00:00 GMT"), now),
444            Some(Duration::ZERO)
445        );
446    }
447
448    #[test]
449    fn retry_after_dates_handle_a_leap_day() {
450        assert_eq!(
451            parse_retry_after(Some("Tue, 29 Feb 2000 00:00:00 GMT"), UNIX_EPOCH),
452            Some(Duration::from_secs(951_782_400))
453        );
454    }
455
456    #[test]
457    fn a_malformed_retry_after_date_is_unusable_rather_than_wrong() {
458        let now = UNIX_EPOCH;
459        assert_eq!(
460            parse_retry_after(Some("Sun, 06 Nov 1994 08:49:37 UTC"), now),
461            None
462        );
463        assert_eq!(
464            parse_retry_after(Some("Sun, 06 Foo 1994 08:49:37 GMT"), now),
465            None
466        );
467        assert_eq!(parse_retry_after(Some("Sun, 06 Nov 1994"), now), None);
468    }
469
470    #[test]
471    fn the_headerless_rate_limit_fallback_is_always_retryable() {
472        // The whole point of the backoff fallback: it can never exceed the
473        // cap, because a wait past the cap is surfaced instead of slept.
474        for attempt in 0..=RETRY_ATTEMPTS {
475            assert!(retry_backoff(attempt) <= MAX_RATE_LIMIT_WAIT);
476        }
477        assert_eq!(retry_backoff(0), Duration::from_millis(100));
478        assert_eq!(retry_backoff(3), Duration::from_millis(800));
479    }
480}