Skip to main content

gproxy_channel_api/
disposition.rs

1//! Upstream response classification (§6.4).
2
3use std::time::Duration;
4
5use http::{HeaderMap, StatusCode};
6
7/// 5-state classification driving failover + cooldown + credential health +
8/// billing (§6.4). Same shape as v1's `ResponseClassification`.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Disposition {
11    /// 2xx — return; mark credential healthy; bill (§17).
12    Success,
13    /// 401/402/403 — refresh once; still dead → mark dead, next credential.
14    AuthDead,
15    /// 429 — with `retry_after`: cool this credential + switch; else limited
16    /// same-credential retries.
17    RateLimited { retry_after: Option<Duration> },
18    /// 5xx / network — next credential.
19    Transient,
20    /// 4xx validation — return immediately, no retry.
21    Permanent,
22}
23
24impl Disposition {
25    pub fn is_success(&self) -> bool {
26        matches!(self, Self::Success)
27    }
28
29    /// Whether failover should advance to the next candidate.
30    pub fn should_failover(&self) -> bool {
31        matches!(
32            self,
33            Self::AuthDead | Self::RateLimited { .. } | Self::Transient
34        )
35    }
36
37    /// Generic HTTP-status → disposition mapping shared by all channels (the
38    /// `Channel::classify` default). Channels override only if they need
39    /// provider-specific signals (e.g. a 200 body carrying an error envelope).
40    pub fn from_http(status: StatusCode, headers: &HeaderMap) -> Self {
41        let code = status.as_u16();
42        match code {
43            200..=299 => Self::Success,
44            401..=403 => Self::AuthDead,
45            429 => Self::RateLimited {
46                retry_after: parse_retry_after(headers),
47            },
48            500..=599 => Self::Transient,
49            _ => Self::Permanent,
50        }
51    }
52}
53
54/// Parse a `Retry-After` header into a `Duration`. Accepts both forms (RFC 7231):
55/// a delay in seconds (`Retry-After: 120`) and an HTTP-date
56/// (`Retry-After: Wed, 21 Oct 2025 07:28:00 GMT`), the latter converted to a
57/// delay from the current time. A past date or unparseable value → `None`.
58fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
59    let val = headers
60        .get(http::header::RETRY_AFTER)?
61        .to_str()
62        .ok()?
63        .trim();
64
65    // delta-seconds form
66    if let Ok(secs) = val.parse::<u64>() {
67        return Some(Duration::from_secs(secs));
68    }
69
70    // HTTP-date form → delay from now (dual-target clock; no SystemTime::now,
71    // so this stays wasm-safe).
72    let target = httpdate::parse_http_date(val)
73        .ok()?
74        .duration_since(std::time::UNIX_EPOCH)
75        .ok()?
76        .as_secs() as i64;
77    let now = unix_now();
78    (target > now).then(|| Duration::from_secs((target - now) as u64))
79}
80
81#[cfg(not(target_arch = "wasm32"))]
82fn unix_now() -> i64 {
83    std::time::SystemTime::now()
84        .duration_since(std::time::UNIX_EPOCH)
85        .unwrap_or_default()
86        .as_secs() as i64
87}
88
89#[cfg(target_arch = "wasm32")]
90fn unix_now() -> i64 {
91    (js_sys::Date::now() / 1000.0) as i64
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn status_mapping() {
100        let h = HeaderMap::new();
101        assert_eq!(
102            Disposition::from_http(StatusCode::OK, &h),
103            Disposition::Success
104        );
105        assert_eq!(
106            Disposition::from_http(StatusCode::UNAUTHORIZED, &h),
107            Disposition::AuthDead
108        );
109        assert_eq!(
110            Disposition::from_http(StatusCode::BAD_GATEWAY, &h),
111            Disposition::Transient
112        );
113        assert_eq!(
114            Disposition::from_http(StatusCode::BAD_REQUEST, &h),
115            Disposition::Permanent
116        );
117        assert!(Disposition::from_http(StatusCode::OK, &h).is_success());
118    }
119
120    #[test]
121    fn retry_after_parsed() {
122        let mut h = HeaderMap::new();
123        h.insert(http::header::RETRY_AFTER, "12".parse().unwrap());
124        assert_eq!(
125            Disposition::from_http(StatusCode::TOO_MANY_REQUESTS, &h),
126            Disposition::RateLimited {
127                retry_after: Some(Duration::from_secs(12))
128            }
129        );
130    }
131
132    #[test]
133    fn retry_after_http_date_form() {
134        // A far-future HTTP-date yields a positive (large) delay; a past date → None.
135        let mut h = HeaderMap::new();
136        h.insert(
137            http::header::RETRY_AFTER,
138            "Wed, 21 Oct 2099 07:28:00 GMT".parse().unwrap(),
139        );
140        match Disposition::from_http(StatusCode::TOO_MANY_REQUESTS, &h) {
141            Disposition::RateLimited {
142                retry_after: Some(d),
143            } => assert!(d.as_secs() > 0, "future date → positive delay"),
144            other => panic!("expected RateLimited with delay, got {other:?}"),
145        }
146
147        let mut past = HeaderMap::new();
148        past.insert(
149            http::header::RETRY_AFTER,
150            "Wed, 21 Oct 1999 07:28:00 GMT".parse().unwrap(),
151        );
152        assert_eq!(
153            Disposition::from_http(StatusCode::TOO_MANY_REQUESTS, &past),
154            Disposition::RateLimited { retry_after: None },
155            "past date → no cooldown"
156        );
157    }
158}