Skip to content

Commit 3911dc7

Browse files
authored
Merge pull request #2211 from bobleer/bob/unify-live-agent-error-retries
fix(agent): unify live model retry budget
2 parents fb16e44 + 201ad29 commit 3911dc7

7 files changed

Lines changed: 466 additions & 358 deletions

File tree

src/apps/cli/src/dispatch/runner.rs

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -220,27 +220,55 @@ pub(crate) fn process_alive(pid: u32) -> bool {
220220
return false;
221221
};
222222
// SAFETY: signal 0 performs liveness/permission checking only.
223-
if unsafe { libc::kill(pid, 0) } == 0 {
224-
#[cfg(target_os = "linux")]
225-
{
226-
// A zombie still answers to kill(0), but it has already exited and
227-
// must not be treated as an authenticated leader for escalation.
228-
if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) {
229-
if stat
230-
.rsplit_once(") ")
231-
.and_then(|(_, fields)| fields.split_whitespace().next())
232-
== Some("Z")
233-
{
234-
return false;
235-
}
223+
if unsafe { libc::kill(pid, 0) } != 0
224+
&& !matches!(
225+
std::io::Error::last_os_error().raw_os_error(),
226+
Some(libc::EPERM)
227+
)
228+
{
229+
return false;
230+
}
231+
232+
#[cfg(target_os = "linux")]
233+
{
234+
// A zombie still answers to kill(0), but it has already exited and
235+
// must not be treated as an authenticated leader for escalation.
236+
if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) {
237+
if stat
238+
.rsplit_once(") ")
239+
.and_then(|(_, fields)| fields.split_whitespace().next())
240+
== Some("Z")
241+
{
242+
return false;
236243
}
237244
}
238-
return true;
239245
}
240-
matches!(
241-
std::io::Error::last_os_error().raw_os_error(),
242-
Some(libc::EPERM)
243-
)
246+
247+
#[cfg(target_os = "macos")]
248+
{
249+
// macOS also reports zombies as present to kill(0). Query the process
250+
// state before using a leader PID to authenticate SIGKILL escalation;
251+
// a failed/empty query means the process disappeared during the check.
252+
let output = Command::new("ps")
253+
.args(["-p", &pid.to_string(), "-o", "stat="])
254+
.output();
255+
let Ok(output) = output else {
256+
return false;
257+
};
258+
if !output.status.success() {
259+
return false;
260+
}
261+
return String::from_utf8_lossy(&output.stdout)
262+
.trim_start()
263+
.chars()
264+
.next()
265+
.is_some_and(|state| state != 'Z');
266+
}
267+
268+
#[cfg(not(target_os = "macos"))]
269+
{
270+
true
271+
}
244272
}
245273

246274
#[cfg(not(unix))]

src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,48 @@ fn stream_json_patch_success_emits_one_success_terminal() {
367367
);
368368
}
369369

370+
#[test]
371+
fn stream_json_malformed_sse_retries_then_completes() {
372+
let server = MockOpenAiServer::malformed_sse_then_immediate();
373+
let environment = CliTestEnvironment::new();
374+
environment.configure_mock_model(server.base_url());
375+
let mut command = environment.std_command();
376+
command.args([
377+
"exec",
378+
"exercise malformed provider stream retry",
379+
"--output-format",
380+
"stream-json",
381+
]);
382+
let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30));
383+
server.assert_chat_completion_requests(2);
384+
385+
let stdout = stdout(&output);
386+
assert!(output.status.success(), "{}\n{stdout}", stderr(&output));
387+
let events = jsonl_events(&stdout);
388+
assert!(
389+
events.iter().any(|value| {
390+
value["event"]["type"] == "TextChunk"
391+
&& value["event"]["text"]
392+
.as_str()
393+
.is_some_and(|text| text.contains(STREAM_COMPLETED_MARKER))
394+
}),
395+
"retried model stream did not complete: {stdout}"
396+
);
397+
assert_eq!(
398+
events
399+
.iter()
400+
.filter(|value| is_terminal_event(value))
401+
.count(),
402+
1,
403+
"retried stream must emit exactly one terminal envelope: {stdout}"
404+
);
405+
assert_eq!(
406+
events.last().expect("retried stream terminal event")["event"]["type"],
407+
"DialogTurnCompleted",
408+
"retried stream terminal must be last: {stdout}"
409+
);
410+
}
411+
370412
#[test]
371413
fn stream_json_provider_http_403_emits_one_error_terminal() {
372414
let server = MockOpenAiServer::http_403("provider authorization denied");
@@ -493,7 +535,7 @@ fn stream_json_disconnect_then_exhausted_retry_failure_emits_one_error_terminal(
493535
"stream-json",
494536
]);
495537
let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30));
496-
server.assert_chat_completion_requests(11);
538+
server.assert_chat_completion_requests(10);
497539

498540
let stdout = stdout(&output);
499541
assert!(!output.status.success(), "{stdout}");

src/apps/cli/tests/support/mod.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ enum MockModelResponse {
303303
Gated,
304304
Http403 { reason: String },
305305
DisconnectThenHttp403,
306+
MalformedSseThenImmediate,
306307
}
307308

308309
impl MockOpenAiServer {
@@ -324,6 +325,10 @@ impl MockOpenAiServer {
324325
Self::spawn(MockModelResponse::DisconnectThenHttp403)
325326
}
326327

328+
pub(crate) fn malformed_sse_then_immediate() -> Self {
329+
Self::spawn(MockModelResponse::MalformedSseThenImmediate)
330+
}
331+
327332
pub(crate) fn base_url(&self) -> &str {
328333
&self.base_url
329334
}
@@ -405,11 +410,14 @@ impl MockOpenAiServer {
405410
&disconnect_tx,
406411
);
407412
attempt += 1;
408-
if matches!(
409-
response,
410-
MockModelResponse::Http403 { .. }
411-
| MockModelResponse::DisconnectThenHttp403
412-
) {
413+
let accepts_more_requests =
414+
matches!(
415+
response,
416+
MockModelResponse::Http403 { .. }
417+
| MockModelResponse::DisconnectThenHttp403
418+
) || (matches!(response, MockModelResponse::MalformedSseThenImmediate)
419+
&& attempt < 2);
420+
if accepts_more_requests {
413421
continue;
414422
}
415423
break;
@@ -473,6 +481,13 @@ fn serve_model_response(
473481
)
474482
.expect("write mock response headers");
475483

484+
if matches!(response, MockModelResponse::MalformedSseThenImmediate) && attempt == 0 {
485+
write_chunk(stream, b"data: not-json\n\n").expect("write malformed SSE frame");
486+
let _ = stream.write_all(b"0\r\n\r\n");
487+
let _ = stream.flush();
488+
return;
489+
}
490+
476491
write_sse_chunk(
477492
stream,
478493
&json!({

src/crates/adapters/ai-adapters/src/client.rs

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::trace::{
1919
use crate::types::ProxyConfig;
2020
use crate::types::*;
2121
use anyhow::Result;
22+
use bitfun_core_types::errors::{AiProviderError, ErrorCategory};
2223
use format::ApiFormat;
2324
use log::warn;
2425
use 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)]
422448
fn 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

Comments
 (0)