oxicode_agent/agent_loop/
retry.rs1use crate::AgentEvent;
2use 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
11struct 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
29pub(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
56pub 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 #[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
86pub(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 if messages
127 .last()
128 .is_some_and(|m| matches!(m, Message::Assistant(_)))
129 {
130 messages.pop();
131 }
132
133 loop_ref.reset_auto_retry_cancel();
135
136 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 }
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
165pub fn cancel_auto_retry(loop_ref: &super::AgentLoop) {
170 loop_ref.fire_auto_retry_cancel();
171}
172
173pub fn auto_retry_attempt_method(loop_ref: &super::AgentLoop) -> usize {
175 loop_ref.auto_retry_attempt.load(Ordering::Relaxed)
176}