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
This commit is contained in:
Charlie Marsh
2026-11-10 00:08:32 +00:00
committed by copyberry
parent bb360e5db8
commit 5b98eaafeb
10 changed files with 55 additions and 1 deletions
+11
View File
@@ -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),
}
}
+11
View File
@@ -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)
}
+1
View File
@@ -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;
@@ -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,