@@ -19,6 +19,7 @@ use crate::trace::{
1919use crate :: types:: ProxyConfig ;
2020use crate :: types:: * ;
2121use anyhow:: Result ;
22+ use bitfun_core_types:: errors:: { AiProviderError , ErrorCategory } ;
2223use format:: ApiFormat ;
2324use log:: warn;
2425use reqwest:: Client ;
@@ -223,30 +224,36 @@ impl AIClient {
223224 extra_body : Option < serde_json:: Value > ,
224225 trace : Option < ModelExchangeTraceConfig > ,
225226 ) -> Result < StreamResponse > {
226- let max_tries = SEND_MESSAGE_STREAM_ATTEMPTS ;
227- match ApiFormat :: parse ( & self . config . format ) ? {
228- ApiFormat :: OpenAIChat => {
229- openai:: chat:: send_stream ( self , messages, tools, extra_body, max_tries, trace) . await
230- }
231- ApiFormat :: OpenAIResponses => {
232- openai:: responses:: send_stream ( self , messages, tools, extra_body, max_tries, trace)
233- . await
234- }
235- ApiFormat :: Anthropic => {
236- anthropic:: request:: send_stream ( self , messages, tools, extra_body, max_tries, trace)
237- . await
238- }
239- ApiFormat :: Gemini => {
240- gemini:: request:: send_stream ( self , messages, tools, extra_body, max_tries, trace)
241- . await
242- }
243- ApiFormat :: GeminiCodeAssist => {
244- gemini:: code_assist:: send_stream (
245- self , messages, tools, extra_body, max_tries, trace,
246- )
247- . await
248- }
249- }
227+ self . send_message_stream_with_extra_body_and_max_attempts (
228+ messages,
229+ tools,
230+ extra_body,
231+ SEND_MESSAGE_STREAM_ATTEMPTS ,
232+ trace,
233+ )
234+ . await
235+ }
236+
237+ /// Open one model stream without an adapter-owned retry loop.
238+ ///
239+ /// Runtime owners with a broader attempt lifecycle use this entry point so
240+ /// connection, HTTP, TTFT, parsing, and in-stream failures all consume one
241+ /// shared retry budget instead of multiplying nested retry loops.
242+ pub async fn send_message_stream_once (
243+ & self ,
244+ messages : Vec < Message > ,
245+ tools : Option < Vec < ToolDefinition > > ,
246+ trace : Option < ModelExchangeTraceConfig > ,
247+ ) -> Result < StreamResponse > {
248+ let custom_body = self . config . custom_request_body . clone ( ) ;
249+ self . send_message_stream_with_extra_body_and_max_attempts (
250+ messages,
251+ tools,
252+ custom_body,
253+ 1 ,
254+ trace,
255+ )
256+ . await
250257 }
251258
252259 pub async fn send_message (
@@ -306,15 +313,33 @@ impl AIClient {
306313 max_attempts : usize ,
307314 ) -> Result < GeminiResponse > {
308315 for attempt in 0 ..max_attempts {
309- let stream_response = self
316+ let stream_response = match self
310317 . send_message_stream_with_extra_body_and_max_attempts (
311318 messages. clone ( ) ,
312319 tools. clone ( ) ,
313320 extra_body. clone ( ) ,
314- max_attempts ,
321+ 1 ,
315322 trace. clone ( ) ,
316323 )
317- . await ?;
324+ . await
325+ {
326+ Ok ( response) => response,
327+ Err ( error) => {
328+ if attempt == max_attempts - 1 {
329+ return Err ( error) ;
330+ }
331+ let delay_ms = send_message_retry_delay_ms_for_error ( attempt, & error) ;
332+ warn ! (
333+ "Retrying AI stream request after error: attempt={}/{}, delay_ms={}, error={}" ,
334+ attempt + 1 ,
335+ max_attempts,
336+ delay_ms,
337+ error
338+ ) ;
339+ tokio:: time:: sleep ( Duration :: from_millis ( delay_ms) ) . await ;
340+ continue ;
341+ }
342+ } ;
318343 let trace_handle = stream_response. trace_handle . clone ( ) ;
319344
320345 match response_aggregator:: aggregate_stream_response ( stream_response) . await {
@@ -333,7 +358,7 @@ impl AIClient {
333358 if attempt == max_attempts - 1 {
334359 return Err ( error) ;
335360 }
336- let delay_ms = send_message_retry_delay_ms ( attempt, & error. to_string ( ) ) ;
361+ let delay_ms = send_message_retry_delay_ms_for_error ( attempt, & error) ;
337362 warn ! (
338363 "Retrying aggregated AI stream after error: attempt={}/{}, delay_ms={}, error={}" ,
339364 attempt + 1 ,
@@ -419,22 +444,52 @@ impl AIClient {
419444 }
420445}
421446
447+ #[ cfg( test) ]
422448fn send_message_retry_delay_ms ( attempt_index : usize , error_message : & str ) -> u64 {
449+ send_message_retry_delay_ms_with_provider ( attempt_index, error_message, None )
450+ }
451+
452+ fn send_message_retry_delay_ms_for_error ( attempt_index : usize , error : & anyhow:: Error ) -> u64 {
453+ send_message_retry_delay_ms_with_provider (
454+ attempt_index,
455+ & error. to_string ( ) ,
456+ error. downcast_ref :: < AiProviderError > ( ) ,
457+ )
458+ }
459+
460+ fn send_message_retry_delay_ms_with_provider (
461+ attempt_index : usize ,
462+ error_message : & str ,
463+ provider_error : Option < & AiProviderError > ,
464+ ) -> u64 {
423465 let shift = u32:: try_from ( attempt_index)
424466 . unwrap_or ( u32:: MAX )
425467 . min ( SEND_MESSAGE_MAX_RETRY_EXPONENT_SHIFT ) ;
426468 let msg = error_message. to_lowercase ( ) ;
427- let is_rate_limit =
428- msg. contains ( "429" ) || msg. contains ( "rate limit" ) || msg. contains ( "too many requests" ) ;
469+ let is_rate_limit = provider_error
470+ . is_some_and ( |error| error. category == ErrorCategory :: RateLimit )
471+ || msg. contains ( "429" )
472+ || msg. contains ( "rate limit" )
473+ || msg. contains ( "too many requests" ) ;
429474
430- if is_rate_limit {
475+ let fallback = if is_rate_limit {
431476 SEND_MESSAGE_RATE_LIMIT_RETRY_BASE_DELAY_MS
432477 . saturating_mul ( 1u64 << shift)
433478 . min ( SEND_MESSAGE_MAX_RATE_LIMIT_DELAY_MS )
434479 } else {
435480 SEND_MESSAGE_RETRY_BASE_DELAY_MS
436481 . saturating_mul ( 1u64 << shift)
437482 . min ( SEND_MESSAGE_MAX_EXPONENTIAL_DELAY_MS )
483+ } ;
484+
485+ match provider_error. and_then ( |error| error. retry_after_ms ) {
486+ Some ( retry_after_ms) if is_rate_limit => retry_after_ms
487+ . max ( fallback)
488+ . min ( SEND_MESSAGE_MAX_RATE_LIMIT_DELAY_MS ) ,
489+ Some ( retry_after_ms) if retry_after_ms > 0 => {
490+ retry_after_ms. min ( SEND_MESSAGE_MAX_RATE_LIMIT_DELAY_MS )
491+ }
492+ Some ( _) | None => fallback,
438493 }
439494}
440495
0 commit comments