Stop retrying explicit invalid-workspace errors (#60475)

## Why

HTTP 403 responses with `invalid_workspace_selected` were treated as unexpected status errors, allowing retries and compaction fallback to another model after the server rejected the selected workspace.

## What changed

Recognize the code in `detail.code` or `error.code`, falling back to the `x-error-json` header when the body has no code. Classify these 403 responses as `InvalidWorkspaceSelected` and disable retries and compaction model fallback while preserving the response details and HTTP status. Other 403 responses retain their existing retry behavior.

## Testing

Add coverage for body and header error codes, code precedence, HTTP and WebSocket retry behavior, and local and remote compaction during a model downshift.

GitOrigin-RevId: f44a3bee5ef06e601909839868ab99cea6dd7d03
This commit is contained in:
Charlie Marsh
2026-11-10 00:07:57 +00:00
committed by copyberry
parent d3147716e0
commit bb360e5db8
6 changed files with 179 additions and 8 deletions
+14 -2
View File
@@ -261,7 +261,7 @@ fn map_api_error_details(err: ApiError) -> CodexErr {
request_id: extract_request_tracking_id(headers.as_ref()),
})
} else {
CodexErr::UnexpectedStatus(UnexpectedResponseError {
let response = UnexpectedResponseError {
status,
user_message: api_error_user_message(status, &body_text),
body: body_text,
@@ -273,7 +273,19 @@ fn map_api_error_details(err: ApiError) -> CodexErr {
X_OPENAI_AUTHORIZATION_ERROR_HEADER,
),
identity_error_code: extract_x_error_json_code(headers.as_ref()),
})
};
let body_code = parsed_body.as_ref().and_then(|body| {
["/detail/code", "/error/code"]
.into_iter()
.find_map(|path| body.pointer(path)?.as_str())
});
let code = body_code.or(response.identity_error_code.as_deref());
match (status, code) {
(http::StatusCode::FORBIDDEN, Some("invalid_workspace_selected")) => {
CodexErrorDetails::InvalidWorkspaceSelected(response).into()
}
_ => CodexErr::UnexpectedStatus(response),
}
}
}
TransportError::RetryLimit => CodexErr::RetryLimit(RetryLimitReachedError {
@@ -804,6 +804,59 @@ fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() {
}
}
#[test]
fn only_an_explicit_workspace_403_stops_retries() {
use http::StatusCode;
let provider = r#"{"error":{"code":"invalid_workspace_selected"}}"#;
let detail = r#"{"detail":{"code":"invalid_workspace_selected"}}"#;
let other = r#"{"detail":{"code":"route_rate_limited"}}"#;
let workspace_header =
http::HeaderValue::from_str(&base64::engine::general_purpose::STANDARD.encode(provider))
.unwrap();
for (status, body, with_header, terminal) in [
(StatusCode::FORBIDDEN, detail, false, true),
(StatusCode::FORBIDDEN, provider, false, true),
(StatusCode::FORBIDDEN, "", true, true),
(StatusCode::FORBIDDEN, "", false, false),
(
StatusCode::FORBIDDEN,
"invalid_workspace_selected",
false,
false,
),
(StatusCode::FORBIDDEN, other, true, false),
(StatusCode::UNAUTHORIZED, detail, true, false),
] {
let mut headers = HeaderMap::new();
headers.insert(REQUEST_ID_HEADER, http::HeaderValue::from_static("req-403"));
if with_header {
headers.insert(X_ERROR_JSON_HEADER, workspace_header.clone());
}
let err = map_api_error(ApiError::Transport(TransportError::Http {
status,
headers: Some(headers),
body: Some(body.to_owned()),
url: None,
retry_after: None,
}));
assert_eq!(
matches!(
err.details(),
CodexErrorDetails::InvalidWorkspaceSelected(_)
),
terminal,
"{err}"
);
assert_eq!(
err.retry_delay(/*retry_count*/ 1).is_none(),
terminal,
"{err}"
);
assert_eq!(err.http_status_code_value(), Some(status.as_u16()));
assert!(err.to_string().contains("req-403"));
}
}
#[test]
fn map_api_error_extracts_identity_auth_details_from_headers() {
let mut headers = HeaderMap::new();