Treat workspace access denials as terminal errors (#60480)

## Why

A `403` with `codex_workspace_access_denied` indicates that the account cannot use Codex in the selected workspace. Retrying or falling back to HTTP does not resolve that denial.

## What changed

Map `codex_workspace_access_denied` and `invalid_workspace_selected` responses to a shared workspace access denial error, and rename the error variant to reflect its broader scope. Keep workspace denials excluded from compaction model fallback.

## Testing

Extend API error mapping tests to cover the new code on `403` and preserve ordinary handling for `503`. Add WebSocket coverage verifying that an explicit workspace access denial does not trigger HTTP fallback.

GitOrigin-RevId: e601a22a0fa783a4ce5401a98ff9c8d3d9d1b074
This commit is contained in:
Charlie Marsh
2026-11-10 00:54:30 +00:00
committed by copyberry
parent 65f2709052
commit 4b345a8de9
5 changed files with 15 additions and 13 deletions
+4 -3
View File
@@ -290,9 +290,10 @@ fn map_api_error_details(err: ApiError) -> CodexErr {
});
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()
}
(
http::StatusCode::FORBIDDEN,
Some("invalid_workspace_selected" | "codex_workspace_access_denied"),
) => CodexErrorDetails::WorkspaceAccessDenied(response).into(),
(http::StatusCode::FORBIDDEN, Some("access_program_not_enabled")) => {
CodexErrorDetails::AccessProgramNotEnabled(ProviderRejection::Http(
response,
+4 -4
View File
@@ -814,12 +814,15 @@ 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 access_denied = r#"{"detail":{"code":"codex_workspace_access_denied"}}"#;
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, access_denied, false, true),
(StatusCode::SERVICE_UNAVAILABLE, access_denied, false, false),
(StatusCode::FORBIDDEN, provider, false, true),
(StatusCode::FORBIDDEN, "", true, true),
(StatusCode::FORBIDDEN, "", false, false),
@@ -845,10 +848,7 @@ fn only_an_explicit_workspace_403_stops_retries() {
retry_after: None,
}));
assert_eq!(
matches!(
err.details(),
CodexErrorDetails::InvalidWorkspaceSelected(_)
),
matches!(err.details(), CodexErrorDetails::WorkspaceAccessDenied(_)),
terminal,
"{err}"
);
+1 -1
View File
@@ -17,7 +17,7 @@ pub(crate) fn should_retry_with_current_model(
CodexErrorDetails::TurnAborted
| CodexErrorDetails::Interrupted
| CodexErrorDetails::SessionBudgetExceeded
| CodexErrorDetails::InvalidWorkspaceSelected(_)
| CodexErrorDetails::WorkspaceAccessDenied(_)
| CodexErrorDetails::ContentPolicyViolation { .. }
| CodexErrorDetails::ResponseIncomplete(_)
) || (previous.model_info().slug == current.model_info().slug
@@ -129,6 +129,7 @@ async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result
#[test_case::test_case(true, Some("invalid_workspace_selected"), 3, 0; "workspace denial on websocket")]
#[test_case::test_case(false, Some("invalid_workspace_selected"), 0, 2; "workspace denial on http")]
#[test_case::test_case(true, Some("codex_workspace_access_denied"), 3, 0; "workspace permission denied on websocket")]
#[test_case::test_case(true, Some("access_program_not_enabled"), 3, 0; "access program denial on websocket")]
#[test_case::test_case(false, Some("access_program_not_enabled"), 0, 2; "access program denial on http")]
#[test_case::test_case(true, None, 3, 4; "bare websocket and http denials")]
+5 -5
View File
@@ -148,9 +148,9 @@ pub enum CodexErrorDetails {
/// surface a polite FunctionCallOutput back to the model instead of crashing the CLI.
#[error("interrupted (Ctrl-C). Something went wrong? Hit `/feedback` to report the issue.")]
Interrupted,
/// The server rejected the selected workspace.
/// The account cannot use Codex in the selected workspace.
#[error("{0}")]
InvalidWorkspaceSelected(UnexpectedResponseError),
WorkspaceAccessDenied(UnexpectedResponseError),
/// The provider denied the access program required for this request.
#[error("{0}")]
AccessProgramNotEnabled(ProviderRejection),
@@ -479,7 +479,7 @@ impl CodexErr {
| CodexErrorDetails::FlexUnavailable
| CodexErrorDetails::CyberPolicy { .. }
| CodexErrorDetails::BioPolicy { .. }
| CodexErrorDetails::InvalidWorkspaceSelected(_)
| CodexErrorDetails::WorkspaceAccessDenied(_)
| CodexErrorDetails::AccessProgramNotEnabled(_)
| CodexErrorDetails::MisalignmentPolicyViolation { .. } => None,
CodexErrorDetails::ServerOverloaded
@@ -556,7 +556,7 @@ impl CodexErr {
http_status_code: self.http_status_code_value(),
},
CodexErrorDetails::ConnectionFailed(_)
| CodexErrorDetails::InvalidWorkspaceSelected(_)
| CodexErrorDetails::WorkspaceAccessDenied(_)
| CodexErrorDetails::AccessProgramNotEnabled(_)
| CodexErrorDetails::UnexpectedStatus(_) => CodexErrorInfo::HttpConnectionFailed {
http_status_code: self.http_status_code_value(),
@@ -604,7 +604,7 @@ impl CodexErr {
CodexErrorDetails::ModelNotFound { status, .. }
| CodexErrorDetails::ContentPolicyViolation { status, .. } => *status,
CodexErrorDetails::RetryLimit(err) => Some(err.status),
CodexErrorDetails::InvalidWorkspaceSelected(err)
CodexErrorDetails::WorkspaceAccessDenied(err)
| CodexErrorDetails::AccessProgramNotEnabled(ProviderRejection::Http(err))
| CodexErrorDetails::UnexpectedStatus(err) => Some(err.status),
CodexErrorDetails::ConnectionFailed(err) => err.source.status(),