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
+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() {