diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 7e3ef7ffbb..42d77bab17 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -664,6 +664,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 18a9a1f7d5..3179934928 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8594,6 +8594,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 385ca8d148..bce1d13765 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4410,6 +4410,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 51f654296a..808a08aa98 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -12,6 +12,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 40d9228061..67299060ae 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -62,6 +62,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 9226add882..fe4279c9e0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -143,6 +143,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f84e2cc358..0e5b3b2cd8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -65,6 +65,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index fdd60d2bab..e77eb56829 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -65,6 +65,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 7c2a3312b7..4530ff70cc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -65,6 +65,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 7e6b864f17..b834a9250b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -143,6 +143,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index fa8c7eaf1d..f790116ab6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -65,6 +65,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 0ea67f6b20..ab3eb956be 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -143,6 +143,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index ce122a67a0..cfe899e0d5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -65,6 +65,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index b19cc193d6..ebb9d21a3c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -62,6 +62,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 87d6af83f9..ea8331cb1e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -65,6 +65,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index ac0e869aa6..9180d12593 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -62,6 +62,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 011a41907f..f2ce9c0426 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -62,6 +62,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index f7df365791..11ba5fd665 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -62,6 +62,7 @@ "flexUnavailable", "serverOverloaded", "modelNotFound", + "policyCapacityUnavailable", "cyberPolicy", "misalignmentPolicyViolation", "tooManyDenials", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 9eda51adc9..fec1d795b5 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index e199e847d5..0ff4c59a10 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 4b36f4de6a..2b51044eed 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "modelNotFound" | "cyberPolicy" | "misalignmentPolicyViolation" | "tooManyDenials" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other" | string | { [key: string]: unknown }; +export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "modelNotFound" | "policyCapacityUnavailable" | "cyberPolicy" | "misalignmentPolicyViolation" | "tooManyDenials" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other" | string | { [key: string]: unknown }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 077dfd5e0d..337d12b556 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -4679,7 +4679,7 @@ mod tests { error: Some(ErrorEvent { misalignment: None, message: "Selected model is at capacity. Please try a different model.".into(), - codex_error_info: Some(CodexErrorInfo::ServerOverloaded), + codex_error_info: Some(CodexErrorInfo::PolicyCapacityUnavailable), }), completed_at: Some(20), duration_ms: Some(10_000), @@ -4713,7 +4713,7 @@ mod tests { message: "Selected model is at capacity. Please try a different model." .into(), codex_error_info: Some( - crate::protocol::v2::CodexErrorInfo::ServerOverloaded, + crate::protocol::v2::CodexErrorInfo::PolicyCapacityUnavailable, ), additional_details: None, }), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs index 1eaa0d036d..1edd7b4a44 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -81,6 +81,8 @@ pub enum CodexErrorInfo { FlexUnavailable, ServerOverloaded, ModelNotFound, + // Capacity is unavailable on a route restricted by server policy. + PolicyCapacityUnavailable, CyberPolicy, MisalignmentPolicyViolation, TooManyDenials, @@ -157,6 +159,9 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::FlexUnavailable => CodexErrorInfo::FlexUnavailable, CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, CoreCodexErrorInfo::ModelNotFound => CodexErrorInfo::ModelNotFound, + CoreCodexErrorInfo::PolicyCapacityUnavailable => { + CodexErrorInfo::PolicyCapacityUnavailable + } CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, CoreCodexErrorInfo::BioPolicy => CodexErrorInfo::Other, CoreCodexErrorInfo::MisalignmentPolicyViolation => { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index bc722c514d..27b9236f7f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -4796,6 +4796,10 @@ fn codex_error_info_serializes_http_status_code_in_camel_case() { #[test] fn core_error_info_converts_to_camel_case() { for (core, expected) in [ + ( + CoreCodexErrorInfo::PolicyCapacityUnavailable, + json!("policyCapacityUnavailable"), + ), (CoreCodexErrorInfo::CyberPolicy, json!("cyberPolicy")), (CoreCodexErrorInfo::BioPolicy, json!("other")), (CoreCodexErrorInfo::InvalidPrompt, json!("other")), diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index fd9b250ed2..f7dcede626 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2444,7 +2444,8 @@ fn websocket_error_detail(err: &ApiError) -> String { | ApiError::BioPolicy { .. } | ApiError::MisalignmentPolicyViolation { .. } | ApiError::FlexUnavailable - | ApiError::ServerOverloaded { .. } => format!("handshake error: {err}"), + | ApiError::ServerOverloaded { .. } + | ApiError::PolicyCapacityUnavailable { .. } => format!("handshake error: {err}"), } } diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 40bd6664c3..0e849c1388 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -24,6 +24,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { ApiError::Retryable { retry_after, .. } | ApiError::RateLimitExceeded { retry_after, .. } | ApiError::ServerOverloaded { retry_after } + | ApiError::PolicyCapacityUnavailable { retry_after } | ApiError::Transport(TransportError::Http { retry_after, .. }) => *retry_after, ApiError::Transport(_) | ApiError::Api { .. } @@ -64,6 +65,9 @@ fn map_api_error_details(err: ApiError) -> CodexErr { CodexErr::new(CodexErrorDetails::ResponseIncomplete(incomplete)) } ApiError::ServerOverloaded { .. } => CodexErr::ServerOverloaded, + ApiError::PolicyCapacityUnavailable { .. } => { + CodexErr::new(CodexErrorDetails::PolicyCapacityUnavailable) + } ApiError::FlexUnavailable => CodexErr::new(CodexErrorDetails::FlexUnavailable), ApiError::Api { status, message } => { let user_message = api_error_user_message(status, &message); @@ -115,6 +119,9 @@ fn map_api_error_details(err: ApiError) -> CodexErr { && let Some(error) = provider_error { match error.get("code").and_then(Value::as_str) { + Some("policy_capacity_unavailable") => { + return CodexErr::new(CodexErrorDetails::PolicyCapacityUnavailable); + } Some("server_is_overloaded") => return CodexErr::ServerOverloaded, Some("slow_down") => { return CodexErr::new(CodexErrorDetails::RateLimitExceeded( diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index da780680c0..0f9417a489 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -11,6 +11,45 @@ fn map_api_error_maps_server_overloaded() { assert!(matches!(err.details(), CodexErrorDetails::ServerOverloaded)); } +#[tokio::test(start_paused = true)] +async fn http_policy_capacity_preserves_retry_advice_and_terminal_behavior() { + use std::time::Duration; + for (code, expected) in [ + ( + "policy_capacity_unavailable", + CodexErrorInfo::PolicyCapacityUnavailable, + ), + ("server_is_overloaded", CodexErrorInfo::ServerOverloaded), + ] { + for advice in [RetryAfter::from_delay(Duration::from_secs(3)), None] { + let error = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::SERVICE_UNAVAILABLE, + url: None, + headers: None, + body: Some( + serde_json::json!({"error": { + "code": code, "type": "service_unavailable_error", + }}) + .to_string(), + ), + retry_after: advice, + })); + assert_eq!( + ( + error.to_codex_protocol_error(), + error.retry_after(), + error.retry_delay(/*retry_count*/ 1) + ), + ( + expected.clone(), + advice, + advice.map(|_| Duration::from_secs(3)) + ) + ); + } + } +} + #[test] fn map_api_error_preserves_content_filter_retry_and_public_error() { let err = map_api_error(ApiError::ContentFilter); diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index 71524b1d95..6732a806cc 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -57,6 +57,9 @@ pub enum ApiError { }, #[error("Flex capacity unavailable.")] FlexUnavailable, + /// Server-confirmed capacity failure on a policy-restricted route. + #[error("policy-restricted capacity unavailable")] + PolicyCapacityUnavailable { retry_after: Option }, #[error("server overloaded")] ServerOverloaded { retry_after: Option }, } diff --git a/codex-rs/codex-api/src/sse/responses_error.rs b/codex-rs/codex-api/src/sse/responses_error.rs index 2e0e0bf4c9..09c1f41f4f 100644 --- a/codex-rs/codex-api/src/sse/responses_error.rs +++ b/codex-rs/codex-api/src/sse/responses_error.rs @@ -84,6 +84,9 @@ pub(super) fn parse_failed_response(response: Option) -> ApiError { .message .unwrap_or_else(|| "Invalid request.".to_string()), }, + Some("policy_capacity_unavailable") => ApiError::PolicyCapacityUnavailable { + retry_after: retry_after_header, + }, Some("server_is_overloaded") => ApiError::ServerOverloaded { retry_after: retry_after_header, }, 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 1f0cccb844..43f6aed886 100644 --- a/codex-rs/codex-api/src/sse/responses_error_tests.rs +++ b/codex-rs/codex-api/src/sse/responses_error_tests.rs @@ -16,6 +16,58 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::time::Duration; +/// Only the explicit policy capacity code changes overload attribution. +#[tokio::test(start_paused = true)] +async fn policy_capacity_classification_preserves_overload_retry_behavior() { + for (code, error_type, expected) in [ + ( + "policy_capacity_unavailable", + None, + CodexErrorInfo::PolicyCapacityUnavailable, + ), + ( + "server_is_overloaded", + Some("policy_error"), + CodexErrorInfo::ServerOverloaded, + ), + ( + "server_is_overloaded", + None, + CodexErrorInfo::ServerOverloaded, + ), + ] { + for retry_after in [Some("3"), None] { + let event = serde_json::from_value(json!({ + "type": "response.failed", + "response": {"error": { + "type": error_type, + "code": code, + "headers": {"Retry-After": retry_after}, + }}, + })) + .unwrap(); + let error = map_api_error(process_responses_event(event).unwrap_err().into_api_error()); + assert_eq!( + ( + error.to_codex_protocol_error(), + error.retry_delay(/*retry_count*/ 1), + error.to_string() + ), + ( + expected.clone(), + retry_after.map(|_| Duration::from_secs(3)), + if expected == CodexErrorInfo::PolicyCapacityUnavailable { + "Policy-restricted capacity is unavailable. Please try again later." + } else { + "Selected model is at capacity. Please try a different model." + } + .to_string() + ) + ); + } + } +} + /// Flex failures remain terminal even with retry headers and a later completed event. #[tokio::test(start_paused = true)] async fn flex_failure_with_retry_header_ends_stream_immediately() { diff --git a/codex-rs/core/tests/suite/retry_after.rs b/codex-rs/core/tests/suite/retry_after.rs index f4a069507b..81bc8bc349 100644 --- a/codex-rs/core/tests/suite/retry_after.rs +++ b/codex-rs/core/tests/suite/retry_after.rs @@ -2027,8 +2027,14 @@ async fn websocket_rate_limit_without_retry_after_is_terminal() -> Result<()> { /// When an overload stops giving advice, it is terminal even if the retry limit /// has been reached. The transport must not switch after that terminal error. +#[test_case::test_case("policy_capacity_unavailable", "server_is_overloaded", CodexErrorInfo::ServerOverloaded; "prior_policy_failure_stays_on_its_attempt")] +#[test_case::test_case("server_is_overloaded", "policy_capacity_unavailable", CodexErrorInfo::PolicyCapacityUnavailable; "terminal_policy_failure_is_preserved")] #[tokio::test(flavor = "current_thread")] -async fn websocket_overload_retries_only_while_it_has_retry_after() -> Result<()> { +async fn websocket_overload_retries_only_while_it_has_retry_after( + first_code: &str, + last_code: &str, + expected_error: CodexErrorInfo, +) -> Result<()> { skip_if_no_network!(Ok(())); let mut telemetry = RetryTelemetryCapture::install(); @@ -2042,7 +2048,7 @@ async fn websocket_overload_retries_only_while_it_has_retry_after() -> Result<() "type": "error", "status": 503, "error": { - "code": "server_is_overloaded", + "code": first_code, "message": "This model is disabled.", "headers": { "Retry-After": "1" } } @@ -2061,7 +2067,7 @@ async fn websocket_overload_retries_only_while_it_has_retry_after() -> Result<() vec![vec![json!({ "type": "error", "status": 503, - "error": {"code": "server_is_overloaded", "message": "This model is disabled."} + "error": {"code": last_code, "message": "This model is disabled."} })]], ]) .await; @@ -2095,13 +2101,14 @@ async fn websocket_overload_retries_only_while_it_has_retry_after() -> Result<() match wait_for_event(&test.codex, |_| true).await { EventMsg::Error(error) => { error_events += 1; - assert_eq!( - error.codex_error_info, - Some(CodexErrorInfo::ServerOverloaded) - ); + assert_eq!(error.codex_error_info, Some(expected_error.clone())); assert_eq!( error.message, - "Selected model is at capacity. Please try a different model." + if last_code == "policy_capacity_unavailable" { + "Policy-restricted capacity is unavailable. Please try again later." + } else { + "Selected model is at capacity. Please try a different model." + } ); } EventMsg::StreamError(_) => stream_error_events += 1, @@ -2113,7 +2120,7 @@ async fn websocket_overload_retries_only_while_it_has_retry_after() -> Result<() EventMsg::TurnComplete(event) => { assert_eq!( event.error.and_then(|error| error.codex_error_info), - Some(CodexErrorInfo::ServerOverloaded) + Some(expected_error.clone()) ); break; } diff --git a/codex-rs/ext/guardian-reviewer/src/retry.rs b/codex-rs/ext/guardian-reviewer/src/retry.rs index 4a6375eb45..2f847c69c2 100644 --- a/codex-rs/ext/guardian-reviewer/src/retry.rs +++ b/codex-rs/ext/guardian-reviewer/src/retry.rs @@ -186,6 +186,7 @@ fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool { CodexErrorInfo::RateLimitExceeded | CodexErrorInfo::FlexUnavailable | CodexErrorInfo::ServerOverloaded + | CodexErrorInfo::PolicyCapacityUnavailable | CodexErrorInfo::InternalServerError => true, CodexErrorInfo::HttpConnectionFailed { http_status_code } | CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } diff --git a/codex-rs/ext/guardian-reviewer/src/retry_tests.rs b/codex-rs/ext/guardian-reviewer/src/retry_tests.rs index 785b7e9e7a..d5992e7941 100644 --- a/codex-rs/ext/guardian-reviewer/src/retry_tests.rs +++ b/codex-rs/ext/guardian-reviewer/src/retry_tests.rs @@ -46,6 +46,7 @@ fn guardian_review_retry_only_retries_recoverable_errors() { }; let transient_error_info = [ CodexErrorInfo::ServerOverloaded, + CodexErrorInfo::PolicyCapacityUnavailable, CodexErrorInfo::FlexUnavailable, CodexErrorInfo::HttpConnectionFailed { http_status_code: Some(502), 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 cc64f4eb31..30182f00b7 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs @@ -58,6 +58,7 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str { | ApiError::BioPolicy { .. } | ApiError::MisalignmentPolicyViolation { .. } => "policy_error", ApiError::ServerOverloaded { .. } => "server_overloaded", + ApiError::PolicyCapacityUnavailable { .. } => "policy_capacity_unavailable", ApiError::FlexUnavailable => "flex_unavailable", }, } 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 0cc4073258..1d8b41c6de 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 @@ -115,6 +115,7 @@ impl SamplingExecution { | ApiError::Stream(_) | ApiError::ContentFilter | ApiError::ServerOverloaded { .. } + | ApiError::PolicyCapacityUnavailable { .. } | ApiError::FlexUnavailable, ) | LunaSamplerError::Api(ApiError::Transport( diff --git a/codex-rs/protocol/src/codex_error_info.rs b/codex-rs/protocol/src/codex_error_info.rs index 8a102ab9d0..0239b00283 100644 --- a/codex-rs/protocol/src/codex_error_info.rs +++ b/codex-rs/protocol/src/codex_error_info.rs @@ -24,6 +24,8 @@ enum CodexErrorInfoWire { FlexUnavailable, ServerOverloaded, ModelNotFound, + /// Capacity is unavailable on a route restricted by server policy. + PolicyCapacityUnavailable, CyberPolicy, BioPolicy, MisalignmentPolicyViolation, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 445fd560dd..108d36429e 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -168,6 +168,9 @@ pub enum CodexErrorDetails { UsageLimitReached(UsageLimitReachedError), #[error("Selected model is at capacity. Please try a different model.")] ServerOverloaded, + /// Policy-restricted capacity follows the same retry advice as ordinary overloads. + #[error("Policy-restricted capacity is unavailable. Please try again later.")] + PolicyCapacityUnavailable, #[error("Flex capacity unavailable.")] FlexUnavailable, #[error("{message}")] @@ -445,9 +448,9 @@ impl CodexErr { | CodexErrorDetails::CyberPolicy { .. } | CodexErrorDetails::BioPolicy { .. } | CodexErrorDetails::MisalignmentPolicyViolation { .. } => None, - CodexErrorDetails::ServerOverloaded | CodexErrorDetails::RetryLimit(_) => { - self.server_retry_delay() - } + CodexErrorDetails::ServerOverloaded + | CodexErrorDetails::PolicyCapacityUnavailable + | CodexErrorDetails::RetryLimit(_) => self.server_retry_delay(), CodexErrorDetails::Stream(..) | CodexErrorDetails::ContentFilter | CodexErrorDetails::RateLimitExceeded(_) @@ -503,6 +506,9 @@ impl CodexErr { | CodexErrorDetails::QuotaExceeded | CodexErrorDetails::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded, CodexErrorDetails::ServerOverloaded => CodexErrorInfo::ServerOverloaded, + CodexErrorDetails::PolicyCapacityUnavailable => { + CodexErrorInfo::PolicyCapacityUnavailable + } CodexErrorDetails::FlexUnavailable => CodexErrorInfo::FlexUnavailable, CodexErrorDetails::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy, CodexErrorDetails::BioPolicy { .. } => CodexErrorInfo::BioPolicy, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ffe6f1c0c8..3c79cb98ae 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2049,6 +2049,8 @@ pub enum CodexErrorInfo { FlexUnavailable, ServerOverloaded, ModelNotFound, + /// Capacity is unavailable on a route restricted by server policy. + PolicyCapacityUnavailable, CyberPolicy, BioPolicy, MisalignmentPolicyViolation, @@ -2095,6 +2097,7 @@ impl CodexErrorInfo { | Self::FlexUnavailable | Self::ServerOverloaded | Self::ModelNotFound + | Self::PolicyCapacityUnavailable | Self::CyberPolicy | Self::BioPolicy | Self::MisalignmentPolicyViolation diff --git a/codex-rs/response-debug-context/src/lib.rs b/codex-rs/response-debug-context/src/lib.rs index 957fde6334..768ad5893c 100644 --- a/codex-rs/response-debug-context/src/lib.rs +++ b/codex-rs/response-debug-context/src/lib.rs @@ -93,6 +93,9 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String { ApiError::BioPolicy { .. } => "bio policy".to_string(), ApiError::MisalignmentPolicyViolation { .. } => "misalignment policy violation".to_string(), ApiError::ServerOverloaded { .. } => "server overloaded".to_string(), + ApiError::PolicyCapacityUnavailable { .. } => { + "policy-restricted capacity unavailable".to_string() + } ApiError::FlexUnavailable => "flex capacity unavailable".to_string(), } } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 0e4852767d..a7162b98b9 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -164,7 +164,10 @@ pub(super) fn app_server_rate_limit_error_kind( info: &AppServerCodexErrorInfo, ) -> Option { match info { - AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), + AppServerCodexErrorInfo::ServerOverloaded + | AppServerCodexErrorInfo::PolicyCapacityUnavailable => { + Some(RateLimitErrorKind::ServerOverloaded) + } AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: Some(429), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index b562a30147..25fe42e718 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1889,46 +1889,63 @@ async fn live_app_server_rate_limit_error_renders_upstream_message() { #[tokio::test] async fn live_app_server_server_overloaded_error_renders_error() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + for (message, error_info) in [ + ("server overloaded", CodexErrorInfo::ServerOverloaded), + ( + "server overloaded", + CodexErrorInfo::PolicyCapacityUnavailable, + ), + ("", CodexErrorInfo::ServerOverloaded), + ("", CodexErrorInfo::PolicyCapacityUnavailable), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_server_notification( - ServerNotification::TurnStarted(TurnStartedNotification { - thread_id: "thread-1".to_string(), - turn: AppServerTurn { - id: "turn-1".to_string(), - root_turn_id: None, - items_view: codex_app_server_protocol::TurnItemsView::Full, - items: Vec::new(), - status: AppServerTurnStatus::InProgress, - error: None, - started_at: Some(0), - completed_at: None, - duration_ms: None, - }, - }), - /*replay_kind*/ None, - ); - drain_insert_history(&mut rx); + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + root_turn_id: None, + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, + }, + }), + /*replay_kind*/ None, + ); + drain_insert_history(&mut rx); - chat.handle_server_notification( - ServerNotification::Error(ErrorNotification { - error: AppServerTurnError { - misalignment: None, - message: "server overloaded".to_string(), - codex_error_info: Some(CodexErrorInfo::ServerOverloaded), - additional_details: None, - }, - will_retry: false, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - }), - /*replay_kind*/ None, - ); + chat.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + misalignment: None, + message: message.to_string(), + codex_error_info: Some(error_info), + additional_details: None, + }, + will_retry: false, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }), + /*replay_kind*/ None, + ); - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1); - assert_eq!(lines_to_single_string(&cells[0]), "■ server overloaded\n"); - assert!(!chat.bottom_pane.is_task_running()); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + insta::allow_duplicates! { + if message.is_empty() { + insta::assert_snapshot!(rendered, @"■ Codex is currently experiencing high load."); + } else { + insta::assert_snapshot!(rendered, @"■ server overloaded"); + } + } + assert!(!chat.bottom_pane.is_task_running()); + } } #[tokio::test] diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 0fecdf7612..d58f29ae71 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -513,6 +513,7 @@ class CodexErrorInfoValue(str, Enum): flex_unavailable = "flexUnavailable" server_overloaded = "serverOverloaded" model_not_found = "modelNotFound" + policy_capacity_unavailable = "policyCapacityUnavailable" cyber_policy = "cyberPolicy" misalignment_policy_violation = "misalignmentPolicyViolation" too_many_denials = "tooManyDenials"