From 5b98eaafeb341225d3724a6112a93c993cc09613 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 9 Nov 2026 23:15:18 +0000 Subject: [PATCH] Treat access program denials as terminal errors (#60476) ## What changed Recognize `access_program_not_enabled` in HTTP 403 responses and response stream errors as a dedicated, non-retryable error. Preserve HTTP diagnostic details and provider stream messages, with a default message when none is supplied. Stop SSE streams immediately on this error and avoid triggering WebSocket fallback for explicit access program denials. Add a fixed diagnostic label for doctor output and telemetry. ## Testing Extend coverage for HTTP and WebSocket denials, immediate SSE termination despite retry headers, and doctor diagnostic redaction. GitOrigin-RevId: 3a41f7bb80ad5e13a87e831b458d17cdd19a4b62 --- codex-rs/cli/src/doctor.rs | 5 +++++ codex-rs/codex-api/src/api_bridge.rs | 11 +++++++++++ codex-rs/codex-api/src/error.rs | 11 +++++++++++ codex-rs/codex-api/src/sse/responses.rs | 1 + .../codex-api/src/sse/responses_error_tests.rs | 6 ++++++ codex-rs/core/tests/suite/websocket_fallback.rs | 4 +++- .../ext/guardian-v2/src/async_scorer/metrics.rs | 1 + .../src/async_scorer/sampler/execution.rs | 1 + codex-rs/protocol/src/error.rs | 15 +++++++++++++++ codex-rs/response-debug-context/src/lib.rs | 1 + 10 files changed, 55 insertions(+), 1 deletion(-) diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 87792d5f5d..d84a542475 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2534,6 +2534,9 @@ fn websocket_error_detail(err: &ApiError) -> String { "handshake error: model not found: [redacted]".to_string() } ApiError::InvalidPrompt { .. } => "handshake error: invalid prompt: [redacted]".to_string(), + ApiError::AccessProgramNotEnabled { .. } => { + "handshake error: access program not enabled".to_string() + } ApiError::CyberPolicy { .. } => "handshake error: cyber policy: [redacted]".to_string(), ApiError::BioPolicy { .. } => "handshake error: bio policy: [redacted]".to_string(), ApiError::MisalignmentPolicyViolation { .. } => { @@ -3376,6 +3379,7 @@ mod tests { status: Some(http::StatusCode::NOT_FOUND), }, ApiError::InvalidPrompt { message: message() }, + ApiError::AccessProgramNotEnabled { message: message() }, ApiError::CyberPolicy { message: message() }, ApiError::BioPolicy { message: message() }, ApiError::MisalignmentPolicyViolation { @@ -3401,6 +3405,7 @@ mod tests { "handshake error: invalid audio input: [redacted]", "handshake error: model not found: [redacted]", "handshake error: invalid prompt: [redacted]", + "handshake error: access program not enabled", "handshake error: cyber policy: [redacted]", "handshake error: bio policy: [redacted]", "handshake error: misalignment policy violation: [redacted]", diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index a75bcd2fad..eb089df5e4 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -11,6 +11,7 @@ use codex_protocol::auth::PlanType; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; use codex_protocol::error::ConnectionFailedError; +use codex_protocol::error::ProviderRejection; use codex_protocol::error::RetryLimitReachedError; use codex_protocol::error::UnexpectedResponseError; use codex_protocol::error::UsageLimitReachedError; @@ -38,6 +39,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { | ApiError::FlexUnavailable | ApiError::RateLimit(_) | ApiError::InvalidRequest { .. } + | ApiError::AccessProgramNotEnabled { .. } | ApiError::InvalidAudioInput { .. } | ApiError::ModelNotFound { .. } | ApiError::InvalidPrompt { .. } @@ -86,6 +88,9 @@ fn map_api_error_details(err: ApiError) -> CodexErr { }) } ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), + ApiError::AccessProgramNotEnabled { message } => { + CodexErrorDetails::AccessProgramNotEnabled(ProviderRejection::Stream(message)).into() + } ApiError::InvalidAudioInput { message, per_recording, @@ -284,6 +289,12 @@ fn map_api_error_details(err: ApiError) -> CodexErr { (http::StatusCode::FORBIDDEN, Some("invalid_workspace_selected")) => { CodexErrorDetails::InvalidWorkspaceSelected(response).into() } + (http::StatusCode::FORBIDDEN, Some("access_program_not_enabled")) => { + CodexErrorDetails::AccessProgramNotEnabled(ProviderRejection::Http( + response, + )) + .into() + } _ => CodexErr::UnexpectedStatus(response), } } diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index d1ce0446f8..647db0b64b 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -42,6 +42,8 @@ pub enum ApiError { #[error("invalid request: {message}")] InvalidRequest { message: String }, #[error("{message}")] + AccessProgramNotEnabled { message: String }, + #[error("{message}")] InvalidAudioInput { message: String, /// True when the provider rejected an individual clip, independent of request size. @@ -85,6 +87,15 @@ pub(crate) fn parse_terminal_provider_error( let error = error?; let code = error.get("code")?.as_str()?; match (code, status) { + // HTTP 403s retain their request ID and headers in the API bridge. + ("access_program_not_enabled", None) => Some(ApiError::AccessProgramNotEnabled { + message: error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .unwrap_or("The requested access program is not enabled.") + .to_owned(), + }), ("budget_reached", None | Some(StatusCode::BAD_REQUEST)) => { Some(ApiError::SessionBudgetExceeded) } diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 73463ed9b1..91aff22ab0 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -681,6 +681,7 @@ async fn process_sse_with_treatment( error, ApiError::FlexUnavailable | ApiError::SessionBudgetExceeded + | ApiError::AccessProgramNotEnabled { .. } | ApiError::ResponseIncomplete(_) ) { let _ = tx_event.send(Err(error)).await; diff --git a/codex-rs/codex-api/src/sse/responses_error_tests.rs b/codex-rs/codex-api/src/sse/responses_error_tests.rs index a9446c4abd..1274c009e3 100644 --- a/codex-rs/codex-api/src/sse/responses_error_tests.rs +++ b/codex-rs/codex-api/src/sse/responses_error_tests.rs @@ -115,6 +115,12 @@ async fn terminal_failure_with_retry_header_ends_stream_immediately() { for (code, expected) in [ ("flex_unavailable", CodexErrorInfo::FlexUnavailable), ("budget_reached", CodexErrorInfo::SessionBudgetExceeded), + ( + "access_program_not_enabled", + CodexErrorInfo::HttpConnectionFailed { + http_status_code: None, + }, + ), ] { let error = json!({ "code": code, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 3904ef450c..0c7d4ed5c5 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -129,9 +129,11 @@ 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("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")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn forbidden_responses_retry_only_without_workspace_denial( +async fn forbidden_responses_retry_only_without_an_explicit_access_denial( supports_websockets: bool, code: Option<&str>, expected_upgrades: u64, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs index 417967c57f..99b60bec5b 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs @@ -57,6 +57,7 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str { ApiError::InvalidRequest { .. } | ApiError::InvalidAudioInput { .. } | ApiError::InvalidPrompt { .. } => "invalid_request", + ApiError::AccessProgramNotEnabled { .. } => "access_program_not_enabled", ApiError::CyberPolicy { .. } | ApiError::BioPolicy { .. } | ApiError::MisalignmentPolicyViolation { .. } => "policy_error", diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs index d5e87ba2d2..7b869e910b 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs @@ -163,6 +163,7 @@ impl SamplingExecution { | ApiError::InvalidAudioInput { .. } | ApiError::ModelNotFound { .. } | ApiError::InvalidPrompt { .. } + | ApiError::AccessProgramNotEnabled { .. } | ApiError::MisalignmentPolicyViolation { .. } | ApiError::CyberPolicy { .. } | ApiError::BioPolicy { .. }, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 53a41bd211..08719bc017 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -151,6 +151,9 @@ pub enum CodexErrorDetails { /// The server rejected the selected workspace. #[error("{0}")] InvalidWorkspaceSelected(UnexpectedResponseError), + /// The provider denied the access program required for this request. + #[error("{0}")] + AccessProgramNotEnabled(ProviderRejection), /// Unexpected HTTP status code. #[error("{0}")] UnexpectedStatus(UnexpectedResponseError), @@ -470,6 +473,7 @@ impl CodexErr { | CodexErrorDetails::CyberPolicy { .. } | CodexErrorDetails::BioPolicy { .. } | CodexErrorDetails::InvalidWorkspaceSelected(_) + | CodexErrorDetails::AccessProgramNotEnabled(_) | CodexErrorDetails::MisalignmentPolicyViolation { .. } => None, CodexErrorDetails::ServerOverloaded | CodexErrorDetails::PolicyCapacityUnavailable @@ -545,6 +549,7 @@ impl CodexErr { }, CodexErrorDetails::ConnectionFailed(_) | CodexErrorDetails::InvalidWorkspaceSelected(_) + | CodexErrorDetails::AccessProgramNotEnabled(_) | CodexErrorDetails::UnexpectedStatus(_) => CodexErrorInfo::HttpConnectionFailed { http_status_code: self.http_status_code_value(), }, @@ -591,6 +596,7 @@ impl CodexErr { CodexErrorDetails::ModelNotFound { status, .. } => *status, CodexErrorDetails::RetryLimit(err) => Some(err.status), CodexErrorDetails::InvalidWorkspaceSelected(err) + | CodexErrorDetails::AccessProgramNotEnabled(ProviderRejection::Http(err)) | CodexErrorDetails::UnexpectedStatus(err) => Some(err.status), CodexErrorDetails::ConnectionFailed(err) => err.source.status(), CodexErrorDetails::ResponseStreamFailed(err) => err.source.status(), @@ -631,6 +637,15 @@ impl std::fmt::Display for ResponseStreamFailed { } } +/// A provider can reject a request before or after opening the response stream. +#[derive(Debug, Error)] +pub enum ProviderRejection { + #[error(transparent)] + Http(UnexpectedResponseError), + #[error("{0}")] + Stream(String), +} + #[derive(Clone, Debug)] pub struct UnexpectedResponseError { pub status: StatusCode, diff --git a/codex-rs/response-debug-context/src/lib.rs b/codex-rs/response-debug-context/src/lib.rs index aca010b013..5d7564aae1 100644 --- a/codex-rs/response-debug-context/src/lib.rs +++ b/codex-rs/response-debug-context/src/lib.rs @@ -93,6 +93,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String { ApiError::RateLimitExceeded { .. } => "rate limit exceeded".to_string(), ApiError::RateLimit(_) => "rate limit".to_string(), ApiError::ModelNotFound { .. } => "model not found".to_string(), + ApiError::AccessProgramNotEnabled { .. } => "access program not enabled".to_string(), ApiError::InvalidRequest { .. } | ApiError::InvalidAudioInput { .. } | ApiError::InvalidPrompt { .. } => "invalid request".to_string(),