Distinguish policy-restricted capacity failures from server overloads (#57452)

## What changed

- Recognize `policy_capacity_unavailable` in HTTP and streaming API errors and expose it as `policyCapacityUnavailable` through the app-server protocol and generated bindings.
- Display “Policy-restricted capacity is unavailable. Please try again later.” while preserving overload retry behavior based on `Retry-After`.
- Handle the new error in TUI overload rendering, reviewer retries, and error telemetry.

## Testing

Add coverage for HTTP and streaming error classification, retry advice, WebSocket terminal error attribution, protocol serialization, and TUI rendering with and without an upstream message.

GitOrigin-RevId: 1b9536fba1e11cc891dcf689aee35410d82f5412
This commit is contained in:
Tom Leach (@toml)
2026-10-27 13:31:38 +00:00
committed by copyberry
parent 824b7666fb
commit 6a6e2fb1f0
42 changed files with 232 additions and 54 deletions
@@ -664,6 +664,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -8594,6 +8594,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -4410,6 +4410,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -12,6 +12,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -62,6 +62,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -143,6 +143,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -65,6 +65,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -65,6 +65,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -65,6 +65,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -143,6 +143,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -65,6 +65,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -143,6 +143,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -65,6 +65,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -62,6 +62,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -65,6 +65,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -62,6 +62,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -62,6 +62,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -62,6 +62,7 @@
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -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 };
@@ -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,
}),
@@ -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<CoreCodexErrorInfo> 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 => {
@@ -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")),
+2 -1
View File
@@ -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}"),
}
}
+7
View File
@@ -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(
@@ -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);
+3
View File
@@ -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<RetryAfter> },
#[error("server overloaded")]
ServerOverloaded { retry_after: Option<RetryAfter> },
}
@@ -84,6 +84,9 @@ pub(super) fn parse_failed_response(response: Option<Value>) -> 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,
},
@@ -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() {
+16 -9
View File
@@ -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;
}
@@ -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 }
@@ -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),
@@ -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",
},
}
@@ -115,6 +115,7 @@ impl SamplingExecution {
| ApiError::Stream(_)
| ApiError::ContentFilter
| ApiError::ServerOverloaded { .. }
| ApiError::PolicyCapacityUnavailable { .. }
| ApiError::FlexUnavailable,
)
| LunaSamplerError::Api(ApiError::Transport(
@@ -24,6 +24,8 @@ enum CodexErrorInfoWire {
FlexUnavailable,
ServerOverloaded,
ModelNotFound,
/// Capacity is unavailable on a route restricted by server policy.
PolicyCapacityUnavailable,
CyberPolicy,
BioPolicy,
MisalignmentPolicyViolation,
+9 -3
View File
@@ -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,
+3
View File
@@ -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
@@ -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(),
}
}
+4 -1
View File
@@ -164,7 +164,10 @@ pub(super) fn app_server_rate_limit_error_kind(
info: &AppServerCodexErrorInfo,
) -> Option<RateLimitErrorKind> {
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),
+54 -37
View File
@@ -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]
@@ -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"