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" />
213 lines
9.0 KiB
Rust
213 lines
9.0 KiB
Rust
use crate::TransportError;
|
|
use crate::error::ApiError;
|
|
use crate::rate_limits::parse_promo_message;
|
|
use crate::rate_limits::parse_rate_limit_for_limit;
|
|
use crate::rate_limits::parse_rate_limit_reached_type;
|
|
use base64::Engine;
|
|
use chrono::DateTime;
|
|
use chrono::Utc;
|
|
use codex_protocol::auth::PlanType;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::error::RetryLimitReachedError;
|
|
use codex_protocol::error::UnexpectedResponseError;
|
|
use codex_protocol::error::UsageLimitReachedError;
|
|
use http::HeaderMap;
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
|
|
pub fn map_api_error(err: ApiError) -> CodexErr {
|
|
match err {
|
|
ApiError::ContextWindowExceeded => CodexErr::ContextWindowExceeded,
|
|
ApiError::QuotaExceeded => CodexErr::QuotaExceeded,
|
|
ApiError::UsageNotIncluded => CodexErr::UsageNotIncluded,
|
|
ApiError::Retryable { message, delay } => CodexErr::Stream(message, delay),
|
|
ApiError::Stream(msg) => CodexErr::Stream(msg, None),
|
|
ApiError::ServerOverloaded => CodexErr::ServerOverloaded,
|
|
ApiError::Api { status, message } => {
|
|
let user_message = api_error_user_message(status, &message);
|
|
CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
|
status,
|
|
body: message,
|
|
user_message,
|
|
url: None,
|
|
cf_ray: None,
|
|
request_id: None,
|
|
identity_authorization_error: None,
|
|
identity_error_code: None,
|
|
})
|
|
}
|
|
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
|
|
ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message },
|
|
ApiError::Transport(transport) => match transport {
|
|
TransportError::Http {
|
|
status,
|
|
url,
|
|
headers,
|
|
body,
|
|
} => {
|
|
let body_text = body.unwrap_or_default();
|
|
|
|
if status == http::StatusCode::SERVICE_UNAVAILABLE
|
|
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&body_text)
|
|
&& matches!(
|
|
value
|
|
.get("error")
|
|
.and_then(|error| error.get("code"))
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("server_is_overloaded" | "slow_down")
|
|
)
|
|
{
|
|
return CodexErr::ServerOverloaded;
|
|
}
|
|
|
|
if status == http::StatusCode::BAD_REQUEST {
|
|
if let Ok(parsed) = serde_json::from_str::<Value>(&body_text)
|
|
&& let Some(error) = parsed.get("error")
|
|
&& error.get("code").and_then(Value::as_str)
|
|
== Some(CYBER_POLICY_ERROR_CODE)
|
|
{
|
|
let message = error
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.filter(|message| !message.trim().is_empty())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string());
|
|
CodexErr::CyberPolicy { message }
|
|
} else if body_text
|
|
.contains("The image data you provided does not represent a valid image")
|
|
{
|
|
CodexErr::InvalidImageRequest()
|
|
} else {
|
|
CodexErr::InvalidRequest(body_text)
|
|
}
|
|
} else if status == http::StatusCode::INTERNAL_SERVER_ERROR {
|
|
CodexErr::InternalServerError
|
|
} else if status == http::StatusCode::TOO_MANY_REQUESTS {
|
|
if let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&body_text) {
|
|
if err.error.error_type.as_deref() == Some("usage_limit_reached") {
|
|
let limit_id = extract_header(headers.as_ref(), ACTIVE_LIMIT_HEADER);
|
|
let rate_limits = headers.as_ref().and_then(|map| {
|
|
parse_rate_limit_for_limit(map, limit_id.as_deref())
|
|
});
|
|
let promo_message = headers.as_ref().and_then(parse_promo_message);
|
|
let rate_limit_reached_type =
|
|
headers.as_ref().and_then(parse_rate_limit_reached_type);
|
|
let resets_at = err
|
|
.error
|
|
.resets_at
|
|
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0));
|
|
return CodexErr::UsageLimitReached(UsageLimitReachedError {
|
|
plan_type: err.error.plan_type,
|
|
resets_at,
|
|
rate_limits: rate_limits.map(Box::new),
|
|
promo_message,
|
|
rate_limit_reached_type,
|
|
});
|
|
} else if err.error.error_type.as_deref() == Some("usage_not_included") {
|
|
return CodexErr::UsageNotIncluded;
|
|
}
|
|
}
|
|
|
|
CodexErr::RetryLimit(RetryLimitReachedError {
|
|
status,
|
|
request_id: extract_request_tracking_id(headers.as_ref()),
|
|
})
|
|
} else {
|
|
CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
|
status,
|
|
user_message: api_error_user_message(status, &body_text),
|
|
body: body_text,
|
|
url,
|
|
cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER),
|
|
request_id: extract_request_id(headers.as_ref()),
|
|
identity_authorization_error: extract_header(
|
|
headers.as_ref(),
|
|
X_OPENAI_AUTHORIZATION_ERROR_HEADER,
|
|
),
|
|
identity_error_code: extract_x_error_json_code(headers.as_ref()),
|
|
})
|
|
}
|
|
}
|
|
TransportError::RetryLimit => CodexErr::RetryLimit(RetryLimitReachedError {
|
|
status: http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
request_id: None,
|
|
}),
|
|
TransportError::Timeout => CodexErr::RequestTimeout,
|
|
TransportError::Network(msg) | TransportError::Build(msg) => {
|
|
CodexErr::Stream(msg, None)
|
|
}
|
|
},
|
|
ApiError::RateLimit(msg) => CodexErr::Stream(msg, None),
|
|
}
|
|
}
|
|
|
|
const ACTIVE_LIMIT_HEADER: &str = "x-codex-active-limit";
|
|
const REQUEST_ID_HEADER: &str = "x-request-id";
|
|
const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
|
|
const CF_RAY_HEADER: &str = "cf-ray";
|
|
const X_OPENAI_AUTHORIZATION_ERROR_HEADER: &str = "x-openai-authorization-error";
|
|
const X_ERROR_JSON_HEADER: &str = "x-error-json";
|
|
const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy";
|
|
const CYBER_POLICY_FALLBACK_MESSAGE: &str =
|
|
"This request has been flagged for possible cybersecurity risk.";
|
|
const CLOUDFLARE_BLOCKED_MESSAGE: &str =
|
|
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region";
|
|
|
|
#[cfg(test)]
|
|
#[path = "api_bridge_tests.rs"]
|
|
mod tests;
|
|
|
|
fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option<String> {
|
|
extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER))
|
|
}
|
|
|
|
fn api_error_user_message(status: http::StatusCode, body: &str) -> Option<String> {
|
|
if status == http::StatusCode::FORBIDDEN
|
|
&& body.contains("Cloudflare")
|
|
&& body.contains("blocked")
|
|
{
|
|
Some(format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})"))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn extract_request_id(headers: Option<&HeaderMap>) -> Option<String> {
|
|
extract_header(headers, REQUEST_ID_HEADER)
|
|
.or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER))
|
|
}
|
|
|
|
fn extract_header(headers: Option<&HeaderMap>, name: &str) -> Option<String> {
|
|
headers.and_then(|map| {
|
|
map.get(name)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_string)
|
|
})
|
|
}
|
|
|
|
fn extract_x_error_json_code(headers: Option<&HeaderMap>) -> Option<String> {
|
|
let encoded = extract_header(headers, X_ERROR_JSON_HEADER)?;
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded)
|
|
.ok()?;
|
|
let parsed = serde_json::from_slice::<Value>(&decoded).ok()?;
|
|
parsed
|
|
.get("error")
|
|
.and_then(|error| error.get("code"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct UsageErrorResponse {
|
|
error: UsageErrorBody,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct UsageErrorBody {
|
|
#[serde(rename = "type")]
|
|
error_type: Option<String>,
|
|
plan_type: Option<PlanType>,
|
|
resets_at: Option<i64>,
|
|
}
|