mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
e65e480e0d
## Why Amazon Bedrock returns a `401 Unauthorized` response containing `Signature expired:` when an AWS credential, including a short-lived `AWS_BEARER_TOKEN_BEDROCK`, has expired. Codex currently surfaces that response as a generic `unexpected status` error, which does not explain how to recover. Environment-provided bearer tokens cannot be refreshed automatically, so the error should direct users to refresh their AWS credentials or replace or remove the environment token and restart Codex. This classification belongs to the Amazon Bedrock provider so similar responses from other providers retain their existing behavior. ## What changed - Add a synchronous `ModelProvider::map_api_error` hook that defaults to the existing provider-neutral API error mapping, and route model request, stream, WebSocket, and terminal unauthorized errors through the active provider. - Override the hook for Amazon Bedrock. After preserving the structured status, body, URL, and request metadata, recognize `401` responses containing `Signature expired:` and attach actionable credential guidance. - Keep `codex-protocol` provider-neutral by representing the guidance as an optional `user_message`. Error rendering prefers this message while continuing to append the URL, request ID, Cloudflare ray, and authorization diagnostics. - Add model-provider coverage for expired signatures and negative cases, core coverage for provider dispatch after unauthorized recovery, and a TUI snapshot for the rendered error. ## Testing Tested with a real request with expired bedrock key: <img width="962" height="126" alt="Screenshot 2026-06-22 at 3 56 51 PM" src="https://github.com/user-attachments/assets/7e21cc7c-798e-4662-8467-7f304a2f2b59" />
290 lines
9.2 KiB
Rust
290 lines
9.2 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_cloudflare_blocked_response_to_user_message() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-id"));
|
|
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
|
status: http::StatusCode::FORBIDDEN,
|
|
url: Some("http://example.com/blocked".to_string()),
|
|
headers: Some(headers),
|
|
body: Some(
|
|
"<html><body>Cloudflare error: Sorry, you have been blocked</body></html>".to_string(),
|
|
),
|
|
}));
|
|
|
|
let CodexErr::UnexpectedStatus(err) = err else {
|
|
panic!("expected CodexErr::UnexpectedStatus, got {err:?}");
|
|
};
|
|
assert_eq!(
|
|
err.user_message.as_deref(),
|
|
Some(
|
|
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region (status 403 Forbidden)"
|
|
)
|
|
);
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region (status 403 Forbidden), url: http://example.com/blocked, cf-ray: ray-id"
|
|
);
|
|
}
|
|
|
|
#[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"));
|
|
}
|