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}