mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
4bcabbfbec
## Why `openai/openai#947613` adds `X-Codex-Rate-Limit-Reached-Type` for Codex workspace credit-depletion and spend-cap responses. The CLI currently reads the adjacent promo header but otherwise renders generic usage-limit copy, so those responses do not explain the workspace-specific action the user needs to take. Backend dependency: https://github.com/openai/openai/pull/947613 ## What Changed - Parse `X-Codex-Rate-Limit-Reached-Type` in the usage-limit error handling path alongside `x-codex-promo-message`. - Keep the header value parsing with the shared `RateLimitReachedType` enum. - Carry the parsed type on `UsageLimitReachedError` and render client-owned copy for the four workspace owner/member credit and spend-cap values. - Preserve existing promo and plan-based text for absent, generic, or unknown header values. - Keep the existing TUI workspace-owner nudge state path unchanged; the response header only selects the displayed error string. - Add focused display coverage for all specific type values and the generic fallback case. ## Test Plan - Added `usage_limit_reached_error_formats_rate_limit_reached_types` coverage. - Not run manually, per request; CI runs validation on the pushed commit.
262 lines
8.1 KiB
Rust
262 lines
8.1 KiB
Rust
use super::*;
|
|
use base64::Engine;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn map_api_error_maps_server_overloaded() {
|
|
let err = map_api_error(ApiError::ServerOverloaded);
|
|
assert!(matches!(err, CodexErr::ServerOverloaded));
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_maps_server_overloaded_from_503_body() {
|
|
let body = serde_json::json!({
|
|
"error": {
|
|
"code": "server_is_overloaded"
|
|
}
|
|
})
|
|
.to_string();
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::SERVICE_UNAVAILABLE,
|
|
url: Some("http://example.com/v1/responses".to_string()),
|
|
headers: None,
|
|
body: Some(body),
|
|
}));
|
|
|
|
assert!(matches!(err, CodexErr::ServerOverloaded));
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_maps_cyber_policy_from_400_body() {
|
|
let body = serde_json::json!({
|
|
"error": {
|
|
"message": "This request has been flagged for potentially high-risk cyber activity.",
|
|
"type": "invalid_request",
|
|
"param": null,
|
|
"code": "cyber_policy"
|
|
}
|
|
})
|
|
.to_string();
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::BAD_REQUEST,
|
|
url: Some("http://example.com/v1/responses".to_string()),
|
|
headers: None,
|
|
body: Some(body),
|
|
}));
|
|
|
|
let CodexErr::CyberPolicy { message } = err else {
|
|
panic!("expected CodexErr::CyberPolicy, got {err:?}");
|
|
};
|
|
assert_eq!(
|
|
message,
|
|
"This request has been flagged for potentially high-risk cyber activity."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_maps_wrapped_websocket_cyber_policy_from_400_body() {
|
|
let body = serde_json::json!({
|
|
"type": "error",
|
|
"status": 400,
|
|
"error": {
|
|
"message": "This websocket request was flagged.",
|
|
"type": "invalid_request",
|
|
"code": "cyber_policy"
|
|
}
|
|
})
|
|
.to_string();
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::BAD_REQUEST,
|
|
url: Some("ws://example.com/v1/responses".to_string()),
|
|
headers: None,
|
|
body: Some(body),
|
|
}));
|
|
|
|
let CodexErr::CyberPolicy { message } = err else {
|
|
panic!("expected CodexErr::CyberPolicy, got {err:?}");
|
|
};
|
|
assert_eq!(message, "This websocket request was flagged.");
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
|
|
let body = serde_json::json!({
|
|
"error": {
|
|
"code": "cyber_policy"
|
|
}
|
|
})
|
|
.to_string();
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::BAD_REQUEST,
|
|
url: Some("http://example.com/v1/responses".to_string()),
|
|
headers: None,
|
|
body: Some(body),
|
|
}));
|
|
|
|
let CodexErr::CyberPolicy { message } = err else {
|
|
panic!("expected CodexErr::CyberPolicy, got {err:?}");
|
|
};
|
|
assert_eq!(
|
|
message,
|
|
"This request has been flagged for possible cybersecurity risk."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_keeps_unknown_400_errors_generic() {
|
|
let body = serde_json::json!({
|
|
"error": {
|
|
"message": "Some other bad request.",
|
|
"code": "some_other_policy"
|
|
}
|
|
})
|
|
.to_string();
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::BAD_REQUEST,
|
|
url: Some("http://example.com/v1/responses".to_string()),
|
|
headers: None,
|
|
body: Some(body.clone()),
|
|
}));
|
|
|
|
let CodexErr::InvalidRequest(message) = err else {
|
|
panic!("expected CodexErr::InvalidRequest, got {err:?}");
|
|
};
|
|
assert_eq!(message, body);
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_maps_usage_limit_limit_name_header() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
ACTIVE_LIMIT_HEADER,
|
|
http::HeaderValue::from_static("codex_other"),
|
|
);
|
|
headers.insert(
|
|
"x-codex-other-limit-name",
|
|
http::HeaderValue::from_static("codex_other"),
|
|
);
|
|
let body = serde_json::json!({
|
|
"error": {
|
|
"type": "usage_limit_reached",
|
|
"plan_type": "pro",
|
|
}
|
|
})
|
|
.to_string();
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::TOO_MANY_REQUESTS,
|
|
url: Some("http://example.com/v1/responses".to_string()),
|
|
headers: Some(headers),
|
|
body: Some(body),
|
|
}));
|
|
|
|
let CodexErr::UsageLimitReached(usage_limit) = err else {
|
|
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
|
|
};
|
|
assert_eq!(
|
|
usage_limit
|
|
.rate_limits
|
|
.as_ref()
|
|
.and_then(|snapshot| snapshot.limit_name.as_deref()),
|
|
Some("codex_other")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
ACTIVE_LIMIT_HEADER,
|
|
http::HeaderValue::from_static("codex_other"),
|
|
);
|
|
let body = serde_json::json!({
|
|
"error": {
|
|
"type": "usage_limit_reached",
|
|
"plan_type": "pro",
|
|
}
|
|
})
|
|
.to_string();
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::TOO_MANY_REQUESTS,
|
|
url: Some("http://example.com/v1/responses".to_string()),
|
|
headers: Some(headers),
|
|
body: Some(body),
|
|
}));
|
|
|
|
let CodexErr::UsageLimitReached(usage_limit) = err else {
|
|
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
|
|
};
|
|
assert_eq!(
|
|
usage_limit
|
|
.rate_limits
|
|
.as_ref()
|
|
.and_then(|snapshot| snapshot.limit_name.as_deref()),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() {
|
|
let values = [
|
|
http::HeaderValue::from_static("future_rate_limit_reached_type"),
|
|
http::HeaderValue::from_bytes(&[0xff]).expect("valid opaque header value"),
|
|
];
|
|
|
|
for value in values {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-codex-rate-limit-reached-type", value);
|
|
let body = serde_json::json!({
|
|
"error": {
|
|
"type": "usage_limit_reached",
|
|
"plan_type": "pro",
|
|
}
|
|
})
|
|
.to_string();
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::TOO_MANY_REQUESTS,
|
|
url: Some("http://example.com/v1/responses".to_string()),
|
|
headers: Some(headers),
|
|
body: Some(body),
|
|
}));
|
|
|
|
let CodexErr::UsageLimitReached(usage_limit) = err else {
|
|
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
|
|
};
|
|
assert_eq!(usage_limit.rate_limit_reached_type, None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn map_api_error_extracts_identity_auth_details_from_headers() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(REQUEST_ID_HEADER, http::HeaderValue::from_static("req-401"));
|
|
headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-401"));
|
|
headers.insert(
|
|
X_OPENAI_AUTHORIZATION_ERROR_HEADER,
|
|
http::HeaderValue::from_static("missing_authorization_header"),
|
|
);
|
|
let x_error_json =
|
|
base64::engine::general_purpose::STANDARD.encode(r#"{"error":{"code":"token_expired"}}"#);
|
|
headers.insert(
|
|
X_ERROR_JSON_HEADER,
|
|
http::HeaderValue::from_str(&x_error_json).expect("valid x-error-json header"),
|
|
);
|
|
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::UNAUTHORIZED,
|
|
url: Some("https://chatgpt.com/backend-api/codex/models".to_string()),
|
|
headers: Some(headers),
|
|
body: Some(r#"{"detail":"Unauthorized"}"#.to_string()),
|
|
}));
|
|
|
|
let CodexErr::UnexpectedStatus(err) = err else {
|
|
panic!("expected CodexErr::UnexpectedStatus, got {err:?}");
|
|
};
|
|
assert_eq!(err.request_id.as_deref(), Some("req-401"));
|
|
assert_eq!(err.cf_ray.as_deref(), Some("ray-401"));
|
|
assert_eq!(
|
|
err.identity_authorization_error.as_deref(),
|
|
Some("missing_authorization_header")
|
|
);
|
|
assert_eq!(err.identity_error_code.as_deref(), Some("token_expired"));
|
|
}
|