Skip to main content

oxicode_agent/
stream_retry.rs

1/// Shared streaming retry logic used by both [`Agent`](crate::Agent) and
2/// [`AgentLoop`](crate::AgentLoop).
3///
4/// The core retry loop (exponential back-off, rate-limit detection) is
5/// identical between the two agent implementations. This module factors
6/// that logic into a single place so it can be tested once and reused.
7use crate::error::AgentError;
8use oxicode_ai::circuit_breaker::CircuitBreaker;
9use oxicode_ai::{Context, Model, ProviderEvent, StreamOptions};
10use std::time::Duration;
11
12/// Maximum retry attempts for provider stream requests.
13pub const MAX_RETRIES: usize = 3;
14
15/// Base delay in seconds for exponential backoff.
16pub const BACKOFF_BASE_SECS: u64 = 2;
17
18/// Callback invoked each time a retry is about to happen.
19///
20/// The implementer can use this to emit events or log the retry.
21pub trait RetryCallback: Send + Sync {
22    /// Called before sleeping for `delay_secs`.
23    fn on_retry(&self, attempt: usize, max_retries: usize, delay_secs: u64, reason: String);
24}
25
26/// Attempt to open a streaming connection to the provider with retry and
27/// exponential back-off.
28///
29/// This is the non-breaking entry point — it delegates to
30/// [`stream_with_retry_core_with_breaker`] with `breaker = None` (no circuit
31/// breaking). Consumers that want circuit breaking call the `_with_breaker`
32/// variant directly.
33///
34/// * `provider`   – the LLM provider to call.
35/// * `model`      – resolved model descriptor.
36/// * `context`    – conversation context (system prompt + messages + tools).
37/// * `options`    – stream options (temperature, max_tokens …).
38/// * `retry_cb`   – callback fired on each retry attempt.
39/// * `max_delay`  – optional cap on the back-off delay (seconds).
40pub 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
54/// Attempt to open a streaming connection to the provider with retry,
55/// exponential back-off, and an optional circuit breaker.
56///
57/// Same contract as [`stream_with_retry_core`] plus:
58///
59/// * `breaker` – optional circuit breaker. Consulted before each provider
60///   attempt (`check()`); an open circuit short-circuits the retry loop and
61///   returns [`AgentError::Stream`] with a `breaker open:` prefix (NOT
62///   retryable — that is the breaker's whole purpose: stop hammering a
63///   failing upstream). On every successful call the breaker records
64///   success; on every error it records failure. `None` = no circuit
65///   breaking (identical to [`stream_with_retry_core`]).
66///
67/// This function is additive (`stream_with_retry_core` keeps its signature
68/// and delegates here with `None`) so consumers on the old surface are
69/// unaffected.
70pub 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        // Consumer-supplied circuit breaker. Runs BEFORE the provider call
83        // so an open circuit short-circuits the retry loop (we don't burn
84        // retries against a known-open upstream). Do NOT record a failure
85        // here — the upstream didn't fail, we declined to call it.
86        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                // A `MissingApiKey` is a *configuration* error, not a
113                // transient upstream failure — fast-fail before any retry.
114                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    /// Minimal provider that either succeeds immediately or fails with a
152    /// rate-limit error (retryable) so the retry loop is exercised.
153    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        // A breaker that is already open must prevent ANY provider call —
202        // the whole point of circuit breaking.
203        let breaker = Arc::new(DefaultCircuitBreaker::new(1, Duration::from_secs(60)));
204        breaker.record_failure(); // trip it open
205        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        // First call fails with 429 (retryable) -> breaker records a failure.
279        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        // stream_with_retry_core (the old entry) must behave exactly as
300        // before: no breaker consulted, provider called.
301        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}