ugi 0.2.1

Runtime-agnostic Rust request client with HTTP/1.1, HTTP/2, HTTP/3, H2C, WebSocket, SSE, and gRPC support
Documentation
/// A thread-safe cache of Alt-Svc entries parsed from HTTP responses.
///
/// When a server returns an `Alt-Svc` header advertising HTTP/3 support, the
/// advertised authority and its `ma` (max-age) expiry are stored here, keyed
/// by the origin host and port.  Subsequent requests to the same host can then
/// opportunistically upgrade to HTTP/3 without waiting for another roundtrip.
///
/// The cache deliberately trades exactness for simplicity:
/// - Only `h3` and `h3-*` alternative protocols are recorded.
/// - Entries expire after the `ma` seconds advertised by the server (default 86 400 s).
/// - `clear=1` removes all entries for the origin.
/// - The cache is shared across all requests on the same `Client` via an `Arc<Mutex<_>>`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

/// A single cached Alt-Svc entry for one origin.
#[derive(Clone, Debug)]
struct Entry {
    /// The alternative authority advertised by the server (e.g. `"example.com:443"`).
    /// An empty string means the authority is the same as the origin.
    /// Retained for future use: the H3 connect path will need it to dial the
    /// correct alt-endpoint rather than always reusing the origin address.
    #[allow(dead_code)]
    alt_authority: String,
    /// When this entry stops being valid.
    expires: Instant,
}

/// Thread-safe Alt-Svc cache, typically wrapped in `Arc`.
#[derive(Default, Clone, Debug)]
pub(crate) struct AltSvcCache {
    inner: Arc<Mutex<HashMap<(String, u16), Entry>>>,
}

impl AltSvcCache {
    /// Parse the value of an `Alt-Svc` response header and record any HTTP/3
    /// entries for `(host, port)`.
    ///
    /// The header value may contain multiple comma-separated alternatives and
    /// optional parameters (`;` separated).  We only store H3 alternatives.
    pub(crate) fn record(&self, host: &str, port: u16, header_value: &str) {
        let trimmed = header_value.trim();

        // `Alt-Svc: clear` removes all stored alternatives for this origin.
        if trimmed.eq_ignore_ascii_case("clear") {
            self.inner
                .lock()
                .unwrap_or_else(|err| err.into_inner())
                .remove(&(host.to_owned(), port));
            return;
        }

        let mut best: Option<Entry> = None;

        // Each comma-separated token is one alternative.
        for token in trimmed.split(',') {
            let token = token.trim();
            if token.is_empty() {
                continue;
            }

            // Split protocol-id="alt-authority" from parameters.
            let mut parts = token.splitn(2, ';');
            let proto_authority = parts.next().unwrap_or("").trim();
            let params = parts.next().unwrap_or("").trim();

            // We need `proto-id="alt-authority"`.
            let (proto_id, alt_authority) = match proto_authority.split_once('=') {
                Some((p, a)) => (p.trim(), unquote(a.trim())),
                None => continue,
            };

            // Only care about h3 and draft versions (h3-29, h3-Q046, etc.)
            if !proto_id.eq_ignore_ascii_case("h3")
                && !proto_id.to_ascii_lowercase().starts_with("h3-")
            {
                continue;
            }

            // Parse max-age from parameters; default per RFC is 86 400 s.
            let ma_secs = parse_max_age(params).unwrap_or(86_400);
            let expires = Instant::now() + Duration::from_secs(ma_secs);

            // Prefer entries with longer lifetime when multiple h3 alternatives exist.
            let candidate = Entry {
                alt_authority: alt_authority.to_owned(),
                expires,
            };
            if best
                .as_ref()
                .map(|b| candidate.expires > b.expires)
                .unwrap_or(true)
            {
                best = Some(candidate);
            }
        }

        if let Some(entry) = best {
            self.inner
                .lock()
                .unwrap_or_else(|err| err.into_inner())
                .insert((host.to_owned(), port), entry);
        }
    }

    /// Returns `true` if there is a non-expired Alt-Svc H3 entry for `(host, port)`.
    pub(crate) fn has_h3(&self, host: &str, port: u16) -> bool {
        let mut map = self.inner.lock().unwrap_or_else(|err| err.into_inner());
        let key = (host.to_owned(), port);
        if let Some(entry) = map.get(&key) {
            if Instant::now() < entry.expires {
                return true;
            }
            // Expired — evict eagerly.
            map.remove(&key);
        }
        false
    }

    /// Remove all stored entries (called when the client is closed / reset).
    pub(crate) fn clear(&self) {
        self.inner
            .lock()
            .unwrap_or_else(|err| err.into_inner())
            .clear();
    }
}

// ── helpers ──────────────────────────────────────────────────────────────────

/// Strip surrounding double-quotes from a token, if present.
fn unquote(s: &str) -> &str {
    if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
        &s[1..s.len() - 1]
    } else {
        s
    }
}

/// Parse `ma=<seconds>` from a semicolon-separated parameter string.
fn parse_max_age(params: &str) -> Option<u64> {
    for param in params.split(';') {
        let param = param.trim();
        if let Some(rest) = param.strip_prefix("ma=") {
            return rest.trim().parse().ok();
        }
        // Handle the case where the first param does not have a leading `;`
        // because we already split on the first `;` in the caller.
    }
    None
}

// ── tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn records_h3_entry_and_detects_it() {
        let cache = AltSvcCache::default();
        cache.record("example.com", 443, r#"h3=":443"; ma=3600"#);
        assert!(cache.has_h3("example.com", 443));
    }

    #[test]
    fn ignores_non_h3_alternatives() {
        let cache = AltSvcCache::default();
        cache.record("example.com", 443, r#"h2=":443"; ma=3600"#);
        assert!(!cache.has_h3("example.com", 443));
    }

    #[test]
    fn clear_directive_removes_entry() {
        let cache = AltSvcCache::default();
        cache.record("example.com", 443, r#"h3=":443"; ma=3600"#);
        cache.record("example.com", 443, "clear");
        assert!(!cache.has_h3("example.com", 443));
    }

    #[test]
    fn expired_entry_is_not_returned() {
        let cache = AltSvcCache::default();
        // ma=0 means immediately expired
        cache.record("example.com", 443, r#"h3=":443"; ma=0"#);
        // Give Instant::now() a chance to advance past ma=0
        std::thread::sleep(std::time::Duration::from_millis(10));
        assert!(!cache.has_h3("example.com", 443));
    }

    #[test]
    fn draft_h3_version_is_accepted() {
        let cache = AltSvcCache::default();
        cache.record("example.com", 443, r#"h3-29=":443"; ma=86400"#);
        assert!(cache.has_h3("example.com", 443));
    }

    #[test]
    fn multiple_alternatives_picks_longest_lived() {
        let cache = AltSvcCache::default();
        // h3-29 with short ma, h3 with long ma
        cache.record(
            "example.com",
            443,
            r#"h3-29=":443"; ma=60, h3=":443"; ma=86400"#,
        );
        // Should still register (longer-lived wins) — just check presence
        assert!(cache.has_h3("example.com", 443));
    }

    #[test]
    fn different_ports_are_independent() {
        let cache = AltSvcCache::default();
        cache.record("example.com", 443, r#"h3=":443"; ma=3600"#);
        assert!(cache.has_h3("example.com", 443));
        assert!(!cache.has_h3("example.com", 8443));
    }

    #[test]
    fn unquote_strips_double_quotes() {
        assert_eq!(unquote(r#"":443""#), ":443");
        assert_eq!(unquote(":443"), ":443");
    }

    #[test]
    fn parse_max_age_extracts_value() {
        assert_eq!(parse_max_age("ma=3600"), Some(3600));
        assert_eq!(parse_max_age("ma=0"), Some(0));
        assert_eq!(parse_max_age(""), None);
    }
}