mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
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:
@@ -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]",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -163,6 +163,7 @@ impl SamplingExecution {
|
||||
| ApiError::InvalidAudioInput { .. }
|
||||
| ApiError::ModelNotFound { .. }
|
||||
| ApiError::InvalidPrompt { .. }
|
||||
| ApiError::AccessProgramNotEnabled { .. }
|
||||
| ApiError::MisalignmentPolicyViolation { .. }
|
||||
| ApiError::CyberPolicy { .. }
|
||||
| ApiError::BioPolicy { .. },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user