Skip to main content

brain_db_sdk/http/
retry.rs

1//! Retry policy for the HTTP tier.
2//!
3//! Self-contained (it does not reach into the wire [`crate::retry`] module) so
4//! the `http` module stays a standalone client. The policy is applied only to
5//! idempotent verbs — `encode` (stable server-side request id), `whoami`, and
6//! `capabilities` (GET) — while `recall`/`forget`/`link`/`unlink`/`plan`/`reason`
7//! stay single-shot.
8//!
9//! Retryable failures are HTTP `503` responses and transport/timeout errors
10//! (which surface as [`BrainHttpError`] with `status == 0`). Nothing else — a
11//! `4xx` (401/400/404/409) is a client verdict and never retried. The backoff
12//! grows exponentially from `base_delay` (100ms, 200ms, …), capped at
13//! `max_delay`; a server `Retry-After` hint overrides the computed schedule.
14
15use std::time::Duration;
16
17use super::error::BrainHttpError;
18
19/// How many times to retry and how long to wait between tries.
20#[derive(Clone, Debug)]
21pub struct HttpRetryPolicy {
22    /// Total attempts including the first. `1` disables retry.
23    pub max_attempts: u32,
24    /// First backoff; doubles each subsequent attempt.
25    pub base_delay: Duration,
26    /// Upper bound on any single backoff (also caps a server `Retry-After`).
27    pub max_delay: Duration,
28}
29
30impl Default for HttpRetryPolicy {
31    /// 3 attempts, 100ms base backoff, 2s cap.
32    fn default() -> Self {
33        Self {
34            max_attempts: 3,
35            base_delay: Duration::from_millis(100),
36            max_delay: Duration::from_secs(2),
37        }
38    }
39}
40
41impl HttpRetryPolicy {
42    /// No retry: a single attempt.
43    #[must_use]
44    pub fn none() -> Self {
45        Self {
46            max_attempts: 1,
47            base_delay: Duration::ZERO,
48            max_delay: Duration::ZERO,
49        }
50    }
51
52    /// An explicit policy. `max_attempts` is floored at `1`.
53    #[must_use]
54    pub fn new(max_attempts: u32, base_delay: Duration, max_delay: Duration) -> Self {
55        Self {
56            max_attempts: max_attempts.max(1),
57            base_delay,
58            max_delay,
59        }
60    }
61
62    /// Whether a failure with this `status` is transient: transport/timeout
63    /// (`0`) or a server `503`. Every other status — including all `4xx` — is a
64    /// terminal verdict.
65    #[must_use]
66    pub(crate) fn is_retryable_status(status: u16) -> bool {
67        // 429 is a "slow down", not a refusal: an idempotent call is safe to
68        // repeat once the server's `Retry-After` has passed, and `backoff`
69        // already honours that hint. It was excluded here while the
70        // TypeScript client retried it, so the same rate-limited read was
71        // absorbed by one SDK and raised at the caller by this one. Every
72        // other 4xx stays terminal — retrying a 400 or a 409 cannot change
73        // the answer.
74        status == 0 || status == 429 || status == 503
75    }
76
77    /// Whether to retry after `attempt` (1-based) produced `err`.
78    #[must_use]
79    pub(crate) fn should_retry(&self, attempt: u32, err: &BrainHttpError) -> bool {
80        attempt < self.max_attempts && Self::is_retryable_status(err.status)
81    }
82
83    /// The backoff to wait *after* `attempt` (1-based) failed and *before* the
84    /// next try. A server-sent `retry_after` overrides the exponential schedule
85    /// (still capped at `max_delay`).
86    #[must_use]
87    pub(crate) fn backoff(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
88        if let Some(ra) = retry_after {
89            return ra.min(self.max_delay);
90        }
91        if self.base_delay.is_zero() {
92            return Duration::ZERO;
93        }
94        // base * 2^(attempt-1), saturating at max_delay.
95        let shift = attempt.saturating_sub(1).min(31);
96        let mult = 1u32 << shift;
97        self.base_delay
98            .checked_mul(mult)
99            .unwrap_or(self.max_delay)
100            .min(self.max_delay)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn byok_missing_credential_status_is_not_retryable() {
110        // The BYOK contract maps "this namespace has no provider key" to HTTP
111        // 409. That choice is load-bearing: a tenant without a key cannot
112        // acquire one by retrying, so if it were mapped to 503 every client
113        // would hammer the endpoint forever. The transient counterpart
114        // (credential broker unreachable) is 503 and SHOULD retry.
115        // A rate limit is a "slow down", not a refusal. The server sends
116        // Retry-After and `backoff` honours it; excluding 429 here made the
117        // same throttled read terminal in Rust and absorbed in TypeScript.
118        assert!(
119            HttpRetryPolicy::is_retryable_status(429),
120            "429 carries Retry-After and an idempotent call is safe to repeat"
121        );
122        assert!(
123            !HttpRetryPolicy::is_retryable_status(400),
124            "retrying a malformed request cannot change the answer"
125        );
126        assert!(
127            !HttpRetryPolicy::is_retryable_status(409),
128            "llm_credential.missing (409) is terminal — retrying cannot create a key"
129        );
130        assert!(
131            HttpRetryPolicy::is_retryable_status(503),
132            "llm_credential.unavailable (503) is transient — it must retry"
133        );
134    }
135
136    #[test]
137    fn transport_503_and_429_are_retryable_and_nothing_else_is() {
138        assert!(HttpRetryPolicy::is_retryable_status(0));
139        assert!(HttpRetryPolicy::is_retryable_status(503));
140        // 429 moved into this set: the server sends Retry-After, `backoff`
141        // honours it, and an idempotent call is safe to repeat once it has
142        // passed. It was terminal here while TypeScript retried it.
143        assert!(HttpRetryPolicy::is_retryable_status(429));
144        // 500/502/504 stay terminal on purpose: unlike 503 they carry no
145        // "try again" contract, and a blind repeat of a write-adjacent fault
146        // is how one failure becomes several.
147        for s in [400, 401, 404, 409, 500, 502, 504] {
148            assert!(
149                !HttpRetryPolicy::is_retryable_status(s),
150                "{s} must not retry"
151            );
152        }
153    }
154
155    #[test]
156    fn exponential_backoff_doubles_and_caps() {
157        let p = HttpRetryPolicy::new(4, Duration::from_millis(100), Duration::from_secs(2));
158        assert_eq!(p.backoff(1, None), Duration::from_millis(100));
159        assert_eq!(p.backoff(2, None), Duration::from_millis(200));
160        assert_eq!(p.backoff(3, None), Duration::from_millis(400));
161        // 100 * 2^30 would exceed the 2s cap.
162        assert_eq!(p.backoff(31, None), Duration::from_secs(2));
163    }
164
165    #[test]
166    fn retry_after_overrides_and_is_capped() {
167        let p = HttpRetryPolicy::default();
168        assert_eq!(
169            p.backoff(1, Some(Duration::from_millis(500))),
170            Duration::from_millis(500)
171        );
172        assert_eq!(
173            p.backoff(1, Some(Duration::from_secs(99))),
174            Duration::from_secs(2),
175            "server hint is capped at max_delay"
176        );
177    }
178
179    #[test]
180    fn should_retry_respects_attempts_and_status() {
181        let p = HttpRetryPolicy::default(); // 3 attempts
182        let transient = BrainHttpError {
183            status: 503,
184            code: "unavailable".into(),
185            message: String::new(),
186        };
187        let client_err = BrainHttpError {
188            status: 401,
189            code: "unauthorized".into(),
190            message: String::new(),
191        };
192        assert!(p.should_retry(1, &transient));
193        assert!(p.should_retry(2, &transient));
194        assert!(!p.should_retry(3, &transient), "exhausted at max_attempts");
195        assert!(!p.should_retry(1, &client_err), "4xx never retries");
196    }
197}