1use crate::error::AgentError;
8use oxicode_ai::circuit_breaker::CircuitBreaker;
9use oxicode_ai::{Context, Model, ProviderEvent, StreamOptions};
10use std::time::Duration;
11
12pub const MAX_RETRIES: usize = 3;
14
15pub const BACKOFF_BASE_SECS: u64 = 2;
17
18pub trait RetryCallback: Send + Sync {
22 fn on_retry(&self, attempt: usize, max_retries: usize, delay_secs: u64, reason: String);
24}
25
26pub async fn stream_with_retry_core(
41 provider: &dyn oxicode_ai::Provider,
42 model: &Model,
43 context: &Context,
44 options: Option<StreamOptions>,
45 retry_cb: &dyn RetryCallback,
46 max_delay: Option<u64>,
47) -> Result<futures::stream::BoxStream<'static, ProviderEvent>, AgentError> {
48 stream_with_retry_core_with_breaker(
49 provider, model, context, options, retry_cb, max_delay, None,
50 )
51 .await
52}
53
54pub async fn stream_with_retry_core_with_breaker(
71 provider: &dyn oxicode_ai::Provider,
72 model: &Model,
73 context: &Context,
74 options: Option<StreamOptions>,
75 retry_cb: &dyn RetryCallback,
76 max_delay: Option<u64>,
77 breaker: Option<&dyn CircuitBreaker>,
78) -> Result<futures::stream::BoxStream<'static, ProviderEvent>, AgentError> {
79 let mut last_err: Option<String> = None;
80
81 for attempt in 0..=MAX_RETRIES {
82 if let Some(b) = breaker
87 && let Err(e) = b.check()
88 {
89 return Err(AgentError::Stream(format!(
90 "breaker open: {e} (provider call refused by circuit breaker)"
91 )));
92 }
93
94 match provider.stream(model, context, options.clone()).await {
95 Ok(stream) => {
96 if let Some(b) = breaker {
97 b.record_success();
98 }
99 return Ok(stream as futures::stream::BoxStream<'static, ProviderEvent>);
100 }
101 Err(e) => {
102 if let Some(b) = breaker {
103 b.record_failure();
104 }
105 let msg = e.to_string();
106 let is_rate_limit = e.http_status() == Some(429);
107 let is_server_error = e.http_status().is_some_and(|code| code >= 500);
108 let is_retryable = is_rate_limit
109 || is_server_error
110 || matches!(e, oxicode_ai::ProviderError::RequestFailed(_));
111
112 if matches!(e, oxicode_ai::ProviderError::MissingApiKey) {
115 return Err(AgentError::Stream(format!(
116 "{msg} — set the corresponding *_API_KEY env var or run `oxicode setup`"
117 )));
118 }
119
120 if !is_retryable && attempt == 0 {
121 return Err(AgentError::Stream(msg));
122 }
123
124 last_err = Some(msg.clone());
125
126 if attempt < MAX_RETRIES {
127 let mut delay = BACKOFF_BASE_SECS.pow(attempt as u32 + 1);
128 if let Some(cap) = max_delay {
129 delay = delay.min(cap);
130 }
131 retry_cb.on_retry(attempt + 1, MAX_RETRIES, delay, msg);
132 tokio::time::sleep(Duration::from_secs(delay)).await;
133 }
134 }
135 }
136 }
137
138 Err(AgentError::RetriesExhausted {
139 attempts: MAX_RETRIES,
140 last_error: last_err.unwrap_or_default(),
141 })
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use oxicode_ai::circuit_breaker::DefaultCircuitBreaker;
148 use std::sync::Arc;
149 use std::sync::atomic::{AtomicUsize, Ordering};
150
151 struct StubProvider {
154 fail_with_429: bool,
155 calls: AtomicUsize,
156 }
157
158 impl oxicode_ai::Provider for StubProvider {
159 fn stream<'a>(
160 &'a self,
161 _model: &'a oxicode_ai::Model,
162 _context: &'a oxicode_ai::Context,
163 _options: Option<oxicode_ai::StreamOptions>,
164 ) -> std::pin::Pin<
165 Box<dyn std::future::Future<Output = oxicode_ai::StreamResult> + Send + 'a>,
166 > {
167 self.calls.fetch_add(1, Ordering::SeqCst);
168 if self.fail_with_429 {
169 Box::pin(async {
170 Err(oxicode_ai::ProviderError::RateLimited { retry_after: None })
171 })
172 } else {
173 Box::pin(async {
174 Ok(Box::pin(futures::stream::empty())
175 as futures::stream::BoxStream<
176 'static,
177 oxicode_ai::ProviderEvent,
178 >)
179 })
180 }
181 }
182 }
183
184 struct NoopCallback;
185 impl RetryCallback for NoopCallback {
186 fn on_retry(&self, _: usize, _: usize, _: u64, _: String) {}
187 }
188
189 fn model() -> oxicode_ai::Model {
190 oxicode_ai::Model::new(
191 "test-model",
192 "test-model",
193 oxicode_ai::Api::AnthropicMessages,
194 "test",
195 "http://localhost:1",
196 )
197 }
198
199 #[tokio::test]
200 async fn open_breaker_short_circuits_without_calling_provider() {
201 let breaker = Arc::new(DefaultCircuitBreaker::new(1, Duration::from_secs(60)));
204 breaker.record_failure(); let provider = StubProvider {
206 fail_with_429: false,
207 calls: AtomicUsize::new(0),
208 };
209 let cb = NoopCallback;
210 let ctx = oxicode_ai::Context::new();
211
212 let err = match stream_with_retry_core_with_breaker(
213 &provider,
214 &model(),
215 &ctx,
216 None,
217 &cb,
218 None,
219 Some(breaker.as_ref()),
220 )
221 .await
222 {
223 Ok(_) => panic!("open breaker must refuse the call"),
224 Err(e) => e,
225 };
226
227 assert!(
228 err.to_string().contains("breaker open"),
229 "expected breaker-open message, got: {err}"
230 );
231 assert_eq!(
232 provider.calls.load(Ordering::SeqCst),
233 0,
234 "provider must never be called when the circuit is open"
235 );
236 }
237
238 #[tokio::test]
239 async fn success_records_success_on_breaker() {
240 let breaker = Arc::new(DefaultCircuitBreaker::new(2, Duration::from_secs(60)));
241 let provider = StubProvider {
242 fail_with_429: false,
243 calls: AtomicUsize::new(0),
244 };
245 let cb = NoopCallback;
246 let ctx = oxicode_ai::Context::new();
247
248 let _ = stream_with_retry_core_with_breaker(
249 &provider,
250 &model(),
251 &ctx,
252 None,
253 &cb,
254 None,
255 Some(breaker.as_ref()),
256 )
257 .await
258 .expect("successful stream");
259
260 assert_eq!(
261 breaker.failure_count(),
262 0,
263 "success must reset the breaker's failure count"
264 );
265 assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
266 }
267
268 #[tokio::test]
269 async fn failure_records_failure_on_breaker() {
270 let breaker = Arc::new(DefaultCircuitBreaker::new(5, Duration::from_secs(60)));
271 let provider = StubProvider {
272 fail_with_429: true,
273 calls: AtomicUsize::new(0),
274 };
275 let cb = NoopCallback;
276 let ctx = oxicode_ai::Context::new();
277
278 let _ = stream_with_retry_core_with_breaker(
280 &provider,
281 &model(),
282 &ctx,
283 None,
284 &cb,
285 None,
286 Some(breaker.as_ref()),
287 )
288 .await;
289
290 assert_eq!(
291 breaker.failure_count(),
292 1,
293 "failed provider call must be recorded on the breaker"
294 );
295 }
296
297 #[tokio::test]
298 async fn no_breaker_preserves_legacy_behavior() {
299 let provider = StubProvider {
302 fail_with_429: false,
303 calls: AtomicUsize::new(0),
304 };
305 let cb = NoopCallback;
306 let ctx = oxicode_ai::Context::new();
307
308 let _ = stream_with_retry_core(&provider, &model(), &ctx, None, &cb, None)
309 .await
310 .expect("legacy entry point still works");
311 assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
312 }
313}