Skip to content

Commit 7b932b4

Browse files
HM-SilveyClaude Fable 5
andcommitted
fix(codex): retry empty buffered completions
The buffered transport path (CCP_CODEX_TRANSPORT=http, and non-stream requests generally) translated an upstream body that ended in a successful terminal event with no semantic output into an empty 200 end_turn. Detect that shape via the reducer (text, thinking, tool, and web-search events all count as semantic), retry with previous_response_id dropped so the resend carries full context, and surface an explicit 503 after the bounded retries exhaust — mirroring the live-stream semantics of #70. response.incomplete stays non-retryable. Includes a zero-delay retry hook (same *_for_tests pattern as the websocket pool/continuation helpers) so the exhaustion smoke test runs instantly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 52c5501 commit 7b932b4

3 files changed

Lines changed: 348 additions & 7 deletions

File tree

src/providers/codex/mod.rs

Lines changed: 160 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ use self::translate::reducer::finish_metadata_from_upstream;
4141
use self::translate::request::{TranslateOptions, has_hosted_web_search, translate_request};
4242

4343
const MAX_RETRYABLE_LIVE_STREAM_RETRIES: u32 = 10;
44+
const MAX_EMPTY_COMPLETION_RETRIES: u32 = 10;
45+
const EMPTY_CODEX_COMPLETION_DETAIL: &str = "empty_codex_completion";
4446
use self::translate::stream::translate_stream_bytes_with_traffic;
4547

4648
// ---------------------------------------------------------------------------
@@ -155,15 +157,37 @@ impl Provider for CodexProvider {
155157
.await;
156158
}
157159

158-
let upstream = match client
159-
.post_codex(&translated, &ctx, Some(&continuation))
160-
.await
161-
{
162-
Ok(r) => r,
163-
Err(e) => {
160+
let mut continuation = Some(continuation);
161+
let mut attempt = 0_u32;
162+
let upstream = loop {
163+
let response = match client
164+
.post_codex(&translated, &ctx, continuation.as_ref())
165+
.await
166+
{
167+
Ok(r) => r,
168+
Err(e) => {
169+
abort_continuation(ctx.session_id.as_deref(), turn_id);
170+
return map_codex_error_to_response(&e);
171+
}
172+
};
173+
if !is_empty_codex_success_completion(&response.body) {
174+
break response;
175+
}
176+
// A successful terminal event with no output would translate into
177+
// an empty end_turn; retry with full context instead.
178+
let error = empty_buffered_completion_error();
179+
drop_live_continuation_for_retry(&mut continuation);
180+
if attempt >= MAX_EMPTY_COMPLETION_RETRIES {
181+
abort_continuation(ctx.session_id.as_deref(), turn_id);
182+
return map_codex_error_to_response(&error);
183+
}
184+
let delay = compute_backoff_delay(attempt, None);
185+
if delay.exceeds_budget {
164186
abort_continuation(ctx.session_id.as_deref(), turn_id);
165-
return map_codex_error_to_response(&e);
187+
return map_codex_error_to_response(&error);
166188
}
189+
attempt += 1;
190+
sleep(delay.wait_ms).await;
167191
};
168192

169193
if want_stream {
@@ -662,6 +686,45 @@ where
662686
(headers, Body::from_stream(stream)).into_response()
663687
}
664688

689+
fn empty_buffered_completion_error() -> client::CodexError {
690+
client::CodexError {
691+
status: 503,
692+
message: "Codex completed without producing output".to_string(),
693+
detail: Some(EMPTY_CODEX_COMPLETION_DETAIL.to_string()),
694+
retry_after: None,
695+
origin: match config::codex_transport() {
696+
config::CodexTransport::Http => client::CodexErrorOrigin::BufferedHttp,
697+
_ => client::CodexErrorOrigin::BufferedWebSocket,
698+
},
699+
}
700+
}
701+
702+
/// True when the buffered upstream body ended in a successful terminal event
703+
/// without ever producing semantic output (text, thinking, tool, web search).
704+
fn is_empty_codex_success_completion(upstream_sse: &[u8]) -> bool {
705+
use self::translate::reducer::{ReducerEvent, TERM_COMPLETED, TERM_DONE};
706+
707+
let Ok(events) = self::translate::reducer::reduce_upstream_bytes(upstream_sse) else {
708+
return false;
709+
};
710+
let mut saw_success_terminal = false;
711+
for event in &events {
712+
match event {
713+
ReducerEvent::TextStart { .. }
714+
| ReducerEvent::ThinkingStart { .. }
715+
| ReducerEvent::ToolStart { .. }
716+
| ReducerEvent::WebSearch { .. } => return false,
717+
ReducerEvent::Finish { terminal_type, .. } => {
718+
if terminal_type == TERM_COMPLETED || terminal_type == TERM_DONE {
719+
saw_success_terminal = true;
720+
}
721+
}
722+
_ => {}
723+
}
724+
}
725+
saw_success_terminal
726+
}
727+
665728
fn is_codex_terminal_event(payload: &serde_json::Value) -> bool {
666729
matches!(
667730
payload.get("type").and_then(|v| v.as_str()),
@@ -769,6 +832,9 @@ fn map_codex_error_to_response(err: &client::CodexError) -> Response {
769832
if is_context_window_overflow(message) {
770833
return map_codex_failure_to_response(message);
771834
}
835+
if err.detail.as_deref() == Some(EMPTY_CODEX_COMPLETION_DETAIL) {
836+
return json_error(StatusCode::SERVICE_UNAVAILABLE, "api_error", &err.message);
837+
}
772838

773839
match err.status {
774840
401 | 403 => json_error(
@@ -940,6 +1006,93 @@ fn format_auth_saved_output(auth_path: &str, account_id: Option<&str>) -> String
9401006
mod tests {
9411007
use super::*;
9421008

1009+
fn upstream_sse(events: &[serde_json::Value]) -> Vec<u8> {
1010+
let mut bytes = Vec::new();
1011+
for event in events {
1012+
bytes.extend_from_slice(format!("data: {event}\n\n").as_bytes());
1013+
}
1014+
bytes
1015+
}
1016+
1017+
#[test]
1018+
fn terminal_only_completed_upstream_is_empty_completion() {
1019+
let body = upstream_sse(&[serde_json::json!({
1020+
"type": "response.completed",
1021+
"response": {"id": "resp_1", "status": "completed", "incomplete_details": null, "usage": {"input_tokens": 5, "output_tokens": 0}}
1022+
})]);
1023+
assert!(is_empty_codex_success_completion(&body));
1024+
}
1025+
1026+
#[test]
1027+
fn terminal_only_done_upstream_is_empty_completion() {
1028+
let body = upstream_sse(&[serde_json::json!({
1029+
"type": "response.done",
1030+
"response": {"id": "resp_1", "usage": {}}
1031+
})]);
1032+
assert!(is_empty_codex_success_completion(&body));
1033+
}
1034+
1035+
#[test]
1036+
fn upstream_with_text_is_not_empty_completion() {
1037+
let body = upstream_sse(&[
1038+
serde_json::json!({
1039+
"type": "response.output_item.added",
1040+
"output_index": 0,
1041+
"item": {"type": "message", "id": "msg_1"}
1042+
}),
1043+
serde_json::json!({
1044+
"type": "response.output_text.delta",
1045+
"output_index": 0,
1046+
"delta": "hello"
1047+
}),
1048+
serde_json::json!({
1049+
"type": "response.output_item.done",
1050+
"output_index": 0,
1051+
"item": {"type": "message"}
1052+
}),
1053+
serde_json::json!({
1054+
"type": "response.completed",
1055+
"response": {"id": "resp_1", "usage": {}}
1056+
}),
1057+
]);
1058+
assert!(!is_empty_codex_success_completion(&body));
1059+
}
1060+
1061+
#[test]
1062+
fn upstream_with_tool_call_is_not_empty_completion() {
1063+
let body = upstream_sse(&[
1064+
serde_json::json!({
1065+
"type": "response.output_item.added",
1066+
"output_index": 0,
1067+
"item": {"type": "function_call", "call_id": "call_1", "name": "Read", "arguments": ""}
1068+
}),
1069+
serde_json::json!({
1070+
"type": "response.output_item.done",
1071+
"output_index": 0,
1072+
"item": {"type": "function_call", "call_id": "call_1", "name": "Read", "arguments": "{}"}
1073+
}),
1074+
serde_json::json!({
1075+
"type": "response.completed",
1076+
"response": {"id": "resp_1", "usage": {}}
1077+
}),
1078+
]);
1079+
assert!(!is_empty_codex_success_completion(&body));
1080+
}
1081+
1082+
#[test]
1083+
fn terminal_only_incomplete_upstream_is_not_empty_completion() {
1084+
let body = upstream_sse(&[serde_json::json!({
1085+
"type": "response.incomplete",
1086+
"response": {"id": "resp_1", "incomplete_details": {"reason": "max_output_tokens"}, "usage": {}}
1087+
})]);
1088+
assert!(!is_empty_codex_success_completion(&body));
1089+
}
1090+
1091+
#[test]
1092+
fn upstream_without_terminal_event_is_not_empty_completion() {
1093+
assert!(!is_empty_codex_success_completion(&upstream_sse(&[])));
1094+
}
1095+
9431096
fn request_with_tools(tools: serde_json::Value) -> MessagesRequest {
9441097
serde_json::from_value(serde_json::json!({
9451098
"model": "gpt-5.6-luna",

src/retry.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::sync::atomic::{AtomicBool, Ordering};
12
use std::time::Duration;
23

34
pub const RETRY_INITIAL_DELAY_MS: u64 = 2000;
@@ -39,7 +40,18 @@ pub fn compute_backoff_delay(attempt: u32, retry_after: Option<&str>) -> Backoff
3940
}
4041
}
4142

43+
static ZERO_RETRY_DELAY_FOR_TESTS: AtomicBool = AtomicBool::new(false);
44+
45+
/// Make retry sleeps return immediately so exhaustion paths can be exercised
46+
/// in tests without waiting out the real backoff schedule.
47+
pub fn set_zero_retry_delay_for_tests(enabled: bool) {
48+
ZERO_RETRY_DELAY_FOR_TESTS.store(enabled, Ordering::SeqCst);
49+
}
50+
4251
pub async fn sleep(ms: u64) {
52+
if ZERO_RETRY_DELAY_FOR_TESTS.load(Ordering::SeqCst) {
53+
return;
54+
}
4355
tokio::time::sleep(Duration::from_millis(ms)).await;
4456
}
4557

0 commit comments

Comments
 (0)