Skip to main content

oxicode_agent/agent_loop/
retry.rs

1use crate::AgentEvent;
2/// Retry logic for agent loop
3use crate::stream_retry::{self, RetryCallback};
4use anyhow::Result;
5use oxicode_ai::{Context, Message, Model, ProviderEvent, StopReason, StreamOptions};
6use regex::Regex;
7use std::sync::atomic::Ordering;
8
9pub use crate::stream_retry::{BACKOFF_BASE_SECS, MAX_RETRIES};
10
11/// [`RetryCallback`] that emits [`AgentEvent::Retry`] through the AgentLoop emit function.
12struct EmitRetryCallback<'a> {
13    emit: &'a super::EmitFn,
14    session_id: Option<String>,
15}
16
17impl RetryCallback for EmitRetryCallback<'_> {
18    fn on_retry(&self, attempt: usize, max_retries: usize, delay_secs: u64, reason: String) {
19        (self.emit)(AgentEvent::Retry {
20            attempt,
21            max_retries,
22            retry_after_secs: delay_secs,
23            reason,
24            session_id: self.session_id.clone(),
25        });
26    }
27}
28
29/// Stream with automatic retry on transient provider errors.
30///
31/// Wraps [`stream_retry::stream_with_retry_core`] with per-session event emission.
32pub(crate) async fn stream_with_retry(
33    loop_ref: &super::AgentLoop,
34    model: &Model,
35    context: &Context,
36    options: Option<StreamOptions>,
37    emit: &super::EmitFn,
38) -> Result<futures::stream::BoxStream<'static, ProviderEvent>> {
39    let cb = EmitRetryCallback {
40        emit,
41        session_id: loop_ref.session_id.clone(),
42    };
43
44    let provider = loop_ref.provider.as_ref();
45    let max_delay = loop_ref.config.max_retry_delay_ms;
46    let breaker = loop_ref.config.circuit_breaker.as_deref();
47
48    let result = stream_retry::stream_with_retry_core_with_breaker(
49        provider, model, context, options, &cb, max_delay, breaker,
50    )
51    .await;
52
53    result.map_err(Into::into)
54}
55
56/// Detect whether an assistant message contains a retryable error.
57pub fn is_retryable_error(message: &oxicode_ai::AssistantMessage) -> bool {
58    if message.stop_reason != StopReason::Error {
59        return false;
60    }
61    let err = match message.error_message.as_deref() {
62        Some(e) if !e.is_empty() => e,
63        _ => return false,
64    };
65
66    static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
67    let re = RE.get_or_init(|| {
68        // SAFETY: the auto-retry regex is a compile-time literal that is
69        // verified valid by `regex::Regex::new`; a panic here is a programming
70        // error in the literal itself, not a runtime condition.
71        #[allow(clippy::expect_used)]
72        Regex::new(
73            r"(?i)overloaded|provider.?returned.?error|rate.?limit|too many requests\
74             |429|500|502|503|504|service.?unavailable|server.?error|internal.?error\
75             |network.?error|connection.?error|connection.?refused|connection.?lost\
76             |other side closed|fetch failed|upstream.?connect|reset before headers\
77             |socket hang up|ended without|http2 request did not get a response\
78             |timed? out|timeout|terminated|retry delay",
79        )
80        .expect("auto-retry regex should compile")
81    });
82
83    re.is_match(err)
84}
85
86/// Attempt an auto-retry for a retryable assistant error.
87///
88/// Uses [`tokio::sync::Notify`] to allow immediate cancellation of the retry
89/// delay sleep, instead of waiting for the full delay to elapse.
90pub(crate) async fn handle_retryable_error(
91    loop_ref: &super::AgentLoop,
92    message: &oxicode_ai::AssistantMessage,
93    messages: &mut Vec<Message>,
94    emit: &super::EmitFn,
95) -> bool {
96    if !loop_ref.auto_retry_enabled() {
97        return false;
98    }
99
100    let attempt = loop_ref.auto_retry_attempt.fetch_add(1, Ordering::Relaxed) + 1;
101    let max_attempts = loop_ref.config.auto_retry_max_attempts;
102
103    if attempt > max_attempts {
104        emit(AgentEvent::AutoRetryEnd {
105            success: false,
106            attempt: attempt - 1,
107            final_error: message.error_message.clone(),
108        });
109        loop_ref.auto_retry_attempt.store(0, Ordering::Relaxed);
110        return false;
111    }
112
113    let delay_ms = loop_ref.config.auto_retry_base_delay_ms * 2u64.pow((attempt - 1) as u32);
114
115    emit(AgentEvent::AutoRetryStart {
116        attempt,
117        max_attempts,
118        delay_ms,
119        error_message: message
120            .error_message
121            .clone()
122            .unwrap_or_else(|| "Unknown error".into()),
123    });
124
125    // Remove the error assistant message so we can retry with a clean context.
126    if messages
127        .last()
128        .is_some_and(|m| matches!(m, Message::Assistant(_)))
129    {
130        messages.pop();
131    }
132
133    // Reset cancel flag before entering the wait.
134    loop_ref.reset_auto_retry_cancel();
135
136    // Wait with immediate wake-up via Notify.
137    // If cancel_auto_retry() is called during the sleep, the Notify fires
138    // and we wake up immediately instead of waiting for the full delay.
139    tokio::select! {
140        biased;
141        _ = loop_ref.auto_retry_notify.notified() => {
142            tracing::info!(attempt, "Auto-retry wait interrupted by cancellation");
143        }
144        _ = loop_ref.external_auto_retry_notified() => {
145            tracing::info!(attempt, "Auto-retry wait interrupted by external cancellation");
146        }
147        _ = tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)) => {
148            // Normal delay elapsed — check cancel flag below.
149        }
150    }
151
152    if loop_ref.auto_retry_cancelled() {
153        emit(AgentEvent::AutoRetryEnd {
154            success: false,
155            attempt,
156            final_error: Some("Retry cancelled".into()),
157        });
158        loop_ref.auto_retry_attempt.store(0, Ordering::Relaxed);
159        return false;
160    }
161
162    true
163}
164
165/// Cancel any in-progress auto-retry wait.
166///
167/// Sets the cancel flag and fires the [`tokio::sync::Notify`] to immediately
168/// wake up the retry delay sleep.
169pub fn cancel_auto_retry(loop_ref: &super::AgentLoop) {
170    loop_ref.fire_auto_retry_cancel();
171}
172
173/// Returns the current auto-retry attempt number (0 = no retry in progress).
174pub fn auto_retry_attempt_method(loop_ref: &super::AgentLoop) -> usize {
175    loop_ref.auto_retry_attempt.load(Ordering::Relaxed)
176}