Handle context length errors consistently across response transports (#60482)

## Why

HTTP 400 responses with `context_length_exceeded` need to reach context-window recovery so manual compaction can retry with less history. Streaming context-window failures should end the stream immediately.

## What changed

- Map `context_length_exceeded` through the shared terminal provider error parser for HTTP 400 responses and events without an HTTP status.
- Return SSE context-window failures immediately.
- Preserve retryable handling for WebSocket errors carrying HTTP 503, even when their code is `context_length_exceeded`.

## Testing

Add coverage for WebSocket status handling and immediate SSE termination with retry headers. Update the manual compaction test to verify recovery from an HTTP 400 context-window error by dropping one history item and retrying.

GitOrigin-RevId: 56d8c831c2fad2ca1318318a3b4258fcc516d689
This commit is contained in:
Charlie Marsh
2026-11-10 00:55:34 +00:00
committed by copyberry
parent d9446b4b01
commit 1885417cd4
6 changed files with 46 additions and 10 deletions
@@ -1326,6 +1326,32 @@ mod tests {
assert!(body.contains("Model does not support image inputs"));
}
#[test]
fn wrapped_context_length_error_respects_http_status() {
for status in [400, 503] {
let payload = json!({
"type": "error",
"status": status,
"error": {"code": "context_length_exceeded"}
})
.to_string();
let event = parse_wrapped_websocket_error_event(&payload).unwrap();
let error = map_wrapped_websocket_error_event(event, payload).unwrap();
let error = crate::api_bridge::map_api_error(error);
assert_eq!(
matches!(
error.details(),
codex_protocol::error::CodexErrorDetails::ContextWindowExceeded
),
status == 400
);
assert_eq!(
error.retry_delay(/*retry_count*/ 1).is_some(),
status == 503
);
}
}
#[test]
fn parse_wrapped_websocket_error_event_with_connection_limit_maps_retryable() {
let payload = json!({
+3
View File
@@ -96,6 +96,9 @@ pub(crate) fn parse_terminal_provider_error(
.and_then(Value::as_str)
.filter(|message| !message.trim().is_empty());
match (code, status) {
("context_length_exceeded", None | Some(StatusCode::BAD_REQUEST)) => {
Some(ApiError::ContextWindowExceeded)
}
// A non-streaming 400 already follows the ordinary invalid-request path.
("invalid_access_program" | "unsupported_access_program", None) => {
Some(ApiError::InvalidRequest {
+1
View File
@@ -680,6 +680,7 @@ async fn process_sse_with_treatment(
if matches!(
error,
ApiError::FlexUnavailable
| ApiError::ContextWindowExceeded
| ApiError::InvalidRequest { .. }
| ApiError::SessionBudgetExceeded
| ApiError::AccessProgramNotEnabled { .. }
@@ -45,7 +45,6 @@ pub(super) fn parse_failed_response(response: Option<Value>) -> ApiError {
};
match error.code.as_deref() {
Some("context_length_exceeded") => ApiError::ContextWindowExceeded,
Some(
"insufficient_quota"
| "credit_balance_exhausted"
@@ -115,6 +115,10 @@ async fn terminal_failure_with_retry_header_ends_stream_immediately() {
for (code, expected) in [
("flex_unavailable", CodexErrorInfo::FlexUnavailable),
("budget_reached", CodexErrorInfo::SessionBudgetExceeded),
(
"context_length_exceeded",
CodexErrorInfo::ContextWindowExceeded,
),
("invalid_access_program", CodexErrorInfo::Other),
("unsupported_access_program", CodexErrorInfo::Other),
("content_policy_violation", CodexErrorInfo::InvalidPrompt),
+12 -9
View File
@@ -4464,22 +4464,25 @@ async fn manual_compact_retries_after_context_window_error() {
ev_assistant_message("m1", FIRST_REPLY),
ev_completed("r1"),
]);
let compact_failed = sse_failed(
"resp-fail",
"context_length_exceeded",
CONTEXT_LIMIT_MESSAGE,
);
let compact_failed = wiremock::ResponseTemplate::new(/*s*/ 400).set_body_json(json!({
"error": {
"type": "invalid_request_error",
"code": "context_length_exceeded",
"message": CONTEXT_LIMIT_MESSAGE,
"param": "input",
}
}));
let compact_succeeds = sse(vec![
ev_assistant_message("m2", SUMMARY_TEXT),
ev_completed("r2"),
]);
let request_log = mount_sse_sequence(
let request_log = mount_response_sequence(
&server,
vec![
user_turn.clone(),
compact_failed.clone(),
compact_succeeds.clone(),
sse_response(user_turn),
compact_failed,
sse_response(compact_succeeds),
],
)
.await;