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" />
607 lines
21 KiB
Rust
607 lines
21 KiB
Rust
use super::*;
|
|
use crate::exec_output::StreamOutput;
|
|
use crate::protocol::RateLimitWindow;
|
|
use chrono::DateTime;
|
|
use chrono::Duration as ChronoDuration;
|
|
use chrono::TimeZone;
|
|
use chrono::Utc;
|
|
use http::Response as HttpResponse;
|
|
use pretty_assertions::assert_eq;
|
|
use reqwest::Response;
|
|
use reqwest::ResponseBuilderExt;
|
|
use reqwest::StatusCode;
|
|
use reqwest::Url;
|
|
|
|
fn rate_limit_snapshot() -> RateLimitSnapshot {
|
|
let primary_reset_at = Utc
|
|
.with_ymd_and_hms(2024, 1, 1, 1, 0, 0)
|
|
.unwrap()
|
|
.timestamp();
|
|
let secondary_reset_at = Utc
|
|
.with_ymd_and_hms(2024, 1, 1, 2, 0, 0)
|
|
.unwrap()
|
|
.timestamp();
|
|
RateLimitSnapshot {
|
|
limit_id: None,
|
|
limit_name: None,
|
|
primary: Some(RateLimitWindow {
|
|
used_percent: 50.0,
|
|
window_minutes: Some(60),
|
|
resets_at: Some(primary_reset_at),
|
|
}),
|
|
secondary: Some(RateLimitWindow {
|
|
used_percent: 30.0,
|
|
window_minutes: Some(120),
|
|
resets_at: Some(secondary_reset_at),
|
|
}),
|
|
credits: None,
|
|
individual_limit: None,
|
|
plan_type: None,
|
|
rate_limit_reached_type: None,
|
|
}
|
|
}
|
|
|
|
fn with_now_override<T>(now: DateTime<Utc>, f: impl FnOnce() -> T) -> T {
|
|
NOW_OVERRIDE.with(|cell| {
|
|
*cell.borrow_mut() = Some(now);
|
|
let result = f();
|
|
*cell.borrow_mut() = None;
|
|
result
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_plus_plan() {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_rate_limit_reached_types() {
|
|
let cases = [
|
|
(
|
|
RateLimitReachedType::RateLimitReached,
|
|
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.",
|
|
),
|
|
(
|
|
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
|
|
"Your workspace is out of credits. Add credits to continue.",
|
|
),
|
|
(
|
|
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
|
|
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue.",
|
|
),
|
|
(
|
|
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
|
|
"You hit your spend cap set in your workspace. Increase your spend cap to continue.",
|
|
),
|
|
(
|
|
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
|
|
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.",
|
|
),
|
|
];
|
|
|
|
for (rate_limit_reached_type, expected) in cases {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: Some(rate_limit_reached_type),
|
|
};
|
|
|
|
assert_eq!(err.to_string(), expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn server_overloaded_maps_to_protocol() {
|
|
let err = CodexErr::ServerOverloaded;
|
|
assert_eq!(
|
|
err.to_codex_protocol_error(),
|
|
CodexErrorInfo::ServerOverloaded
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_denied_uses_aggregated_output_when_stderr_empty() {
|
|
let output = ExecToolCallOutput {
|
|
exit_code: 77,
|
|
stdout: StreamOutput::new(String::new()),
|
|
stderr: StreamOutput::new(String::new()),
|
|
aggregated_output: StreamOutput::new("aggregate detail".to_string()),
|
|
duration: Duration::from_millis(10),
|
|
timed_out: false,
|
|
};
|
|
let err = CodexErr::Sandbox(SandboxErr::Denied {
|
|
output: Box::new(output),
|
|
network_policy_decision: None,
|
|
});
|
|
assert_eq!(get_error_message_ui(&err), "aggregate detail");
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_denied_reports_both_streams_when_available() {
|
|
let output = ExecToolCallOutput {
|
|
exit_code: 9,
|
|
stdout: StreamOutput::new("stdout detail".to_string()),
|
|
stderr: StreamOutput::new("stderr detail".to_string()),
|
|
aggregated_output: StreamOutput::new(String::new()),
|
|
duration: Duration::from_millis(10),
|
|
timed_out: false,
|
|
};
|
|
let err = CodexErr::Sandbox(SandboxErr::Denied {
|
|
output: Box::new(output),
|
|
network_policy_decision: None,
|
|
});
|
|
assert_eq!(get_error_message_ui(&err), "stderr detail\nstdout detail");
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_denied_reports_stdout_when_no_stderr() {
|
|
let output = ExecToolCallOutput {
|
|
exit_code: 11,
|
|
stdout: StreamOutput::new("stdout only".to_string()),
|
|
stderr: StreamOutput::new(String::new()),
|
|
aggregated_output: StreamOutput::new(String::new()),
|
|
duration: Duration::from_millis(8),
|
|
timed_out: false,
|
|
};
|
|
let err = CodexErr::Sandbox(SandboxErr::Denied {
|
|
output: Box::new(output),
|
|
network_policy_decision: None,
|
|
});
|
|
assert_eq!(get_error_message_ui(&err), "stdout only");
|
|
}
|
|
|
|
#[test]
|
|
fn to_error_event_handles_response_stream_failed() {
|
|
let response = HttpResponse::builder()
|
|
.status(StatusCode::TOO_MANY_REQUESTS)
|
|
.url(Url::parse("http://example.com").unwrap())
|
|
.body("")
|
|
.unwrap();
|
|
let source = Response::from(response).error_for_status_ref().unwrap_err();
|
|
let err = CodexErr::ResponseStreamFailed(ResponseStreamFailed {
|
|
source,
|
|
request_id: Some("req-123".to_string()),
|
|
});
|
|
|
|
let event = err.to_error_event(Some("prefix".to_string()));
|
|
|
|
assert_eq!(
|
|
event.message,
|
|
"prefix: Error while reading the server response: HTTP status client error (429 Too Many Requests) for url (http://example.com/), request id: req-123"
|
|
);
|
|
assert_eq!(
|
|
event.codex_error_info,
|
|
Some(CodexErrorInfo::ResponseStreamConnectionFailed {
|
|
http_status_code: Some(429)
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_denied_reports_exit_code_when_no_output_available() {
|
|
let output = ExecToolCallOutput {
|
|
exit_code: 13,
|
|
stdout: StreamOutput::new(String::new()),
|
|
stderr: StreamOutput::new(String::new()),
|
|
aggregated_output: StreamOutput::new(String::new()),
|
|
duration: Duration::from_millis(5),
|
|
timed_out: false,
|
|
};
|
|
let err = CodexErr::Sandbox(SandboxErr::Denied {
|
|
output: Box::new(output),
|
|
network_policy_decision: None,
|
|
});
|
|
assert_eq!(
|
|
get_error_message_ui(&err),
|
|
"command failed inside sandbox with exit code 13"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_free_plan() {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Free)),
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_go_plan() {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Go)),
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_default_when_none() {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: None,
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"You've hit your usage limit. Try again later."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_team_plan() {
|
|
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let resets_at = base + ChronoDuration::hours(1);
|
|
with_now_override(base, move || {
|
|
let expected_time = format_retry_timestamp(&resets_at);
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Team)),
|
|
resets_at: Some(resets_at),
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
let expected = format!(
|
|
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
|
|
);
|
|
assert_eq!(err.to_string(), expected);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_business_plan_without_reset() {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Business)),
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessUsageBased)),
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::EnterpriseCbpUsageBased)),
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_default_for_other_plans() {
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Enterprise)),
|
|
resets_at: None,
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"You've hit your usage limit. Try again later."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_formats_pro_plan_with_reset() {
|
|
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let resets_at = base + ChronoDuration::hours(1);
|
|
with_now_override(base, move || {
|
|
let expected_time = format_retry_timestamp(&resets_at);
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
|
resets_at: Some(resets_at),
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
let expected = format!(
|
|
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
|
);
|
|
assert_eq!(err.to_string(), expected);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() {
|
|
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let resets_at = base + ChronoDuration::hours(1);
|
|
with_now_override(base, move || {
|
|
let expected_time = format_retry_timestamp(&resets_at);
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
|
resets_at: Some(resets_at),
|
|
rate_limits: Some(Box::new(RateLimitSnapshot {
|
|
limit_id: Some("codex_other".to_string()),
|
|
limit_name: Some("codex_other".to_string()),
|
|
..rate_limit_snapshot()
|
|
})),
|
|
promo_message: Some(
|
|
"Visit https://chatgpt.com/codex/settings/usage to purchase more credits"
|
|
.to_string(),
|
|
),
|
|
rate_limit_reached_type: None,
|
|
};
|
|
let expected = format!(
|
|
"You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}."
|
|
);
|
|
assert_eq!(err.to_string(), expected);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_includes_minutes_when_available() {
|
|
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let resets_at = base + ChronoDuration::minutes(5);
|
|
with_now_override(base, move || {
|
|
let expected_time = format_retry_timestamp(&resets_at);
|
|
let err = UsageLimitReachedError {
|
|
plan_type: None,
|
|
resets_at: Some(resets_at),
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
|
assert_eq!(err.to_string(), expected);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn unexpected_status_non_html_is_unchanged() {
|
|
let err = UnexpectedResponseError {
|
|
status: StatusCode::FORBIDDEN,
|
|
body: "plain text error".to_string(),
|
|
user_message: None,
|
|
url: Some("http://example.com/plain".to_string()),
|
|
cf_ray: None,
|
|
request_id: None,
|
|
identity_authorization_error: None,
|
|
identity_error_code: None,
|
|
};
|
|
let status = StatusCode::FORBIDDEN.to_string();
|
|
let url = "http://example.com/plain";
|
|
assert_eq!(
|
|
err.to_string(),
|
|
format!("unexpected status {status}: plain text error, url: {url}")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unexpected_status_uses_user_message_and_preserves_response_context() {
|
|
let err = UnexpectedResponseError {
|
|
status: StatusCode::UNAUTHORIZED,
|
|
body: "provider-specific response".to_string(),
|
|
user_message: Some("Provider-specific guidance".to_string()),
|
|
url: Some("https://example.com/v1/responses".to_string()),
|
|
cf_ray: None,
|
|
request_id: Some("req-provider".to_string()),
|
|
identity_authorization_error: None,
|
|
identity_error_code: None,
|
|
};
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"Provider-specific guidance, url: https://example.com/v1/responses, request id: req-provider"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unexpected_status_prefers_error_message_when_present() {
|
|
let err = UnexpectedResponseError {
|
|
status: StatusCode::UNAUTHORIZED,
|
|
body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"#
|
|
.to_string(),
|
|
user_message: None,
|
|
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
|
|
cf_ray: None,
|
|
request_id: Some("req-123".to_string()),
|
|
identity_authorization_error: None,
|
|
identity_error_code: None,
|
|
};
|
|
let status = StatusCode::UNAUTHORIZED.to_string();
|
|
assert_eq!(
|
|
err.to_string(),
|
|
format!(
|
|
"unexpected status {status}: Workspace is not authorized in this region., url: https://chatgpt.com/backend-api/codex/responses, request id: req-123"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unexpected_status_truncates_long_body_with_ellipsis() {
|
|
let long_body = "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES + 10);
|
|
let err = UnexpectedResponseError {
|
|
status: StatusCode::BAD_GATEWAY,
|
|
body: long_body,
|
|
user_message: None,
|
|
url: Some("http://example.com/long".to_string()),
|
|
cf_ray: None,
|
|
request_id: Some("req-long".to_string()),
|
|
identity_authorization_error: None,
|
|
identity_error_code: None,
|
|
};
|
|
let status = StatusCode::BAD_GATEWAY.to_string();
|
|
let expected_body = format!("{}...", "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES));
|
|
assert_eq!(
|
|
err.to_string(),
|
|
format!(
|
|
"unexpected status {status}: {expected_body}, url: http://example.com/long, request id: req-long"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unexpected_status_includes_cf_ray_and_request_id() {
|
|
let err = UnexpectedResponseError {
|
|
status: StatusCode::UNAUTHORIZED,
|
|
body: "plain text error".to_string(),
|
|
user_message: None,
|
|
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
|
|
cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()),
|
|
request_id: Some("req-xyz".to_string()),
|
|
identity_authorization_error: None,
|
|
identity_error_code: None,
|
|
};
|
|
let status = StatusCode::UNAUTHORIZED.to_string();
|
|
assert_eq!(
|
|
err.to_string(),
|
|
format!(
|
|
"unexpected status {status}: plain text error, url: https://chatgpt.com/backend-api/codex/responses, cf-ray: 9c81f9f18f2fa49d-LHR, request id: req-xyz"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unexpected_status_includes_identity_auth_details() {
|
|
let err = UnexpectedResponseError {
|
|
status: StatusCode::UNAUTHORIZED,
|
|
body: "plain text error".to_string(),
|
|
user_message: None,
|
|
url: Some("https://chatgpt.com/backend-api/codex/models".to_string()),
|
|
cf_ray: Some("cf-ray-auth-401-test".to_string()),
|
|
request_id: Some("req-auth".to_string()),
|
|
identity_authorization_error: Some("missing_authorization_header".to_string()),
|
|
identity_error_code: Some("token_expired".to_string()),
|
|
};
|
|
let status = StatusCode::UNAUTHORIZED.to_string();
|
|
assert_eq!(
|
|
err.to_string(),
|
|
format!(
|
|
"unexpected status {status}: plain text error, url: https://chatgpt.com/backend-api/codex/models, cf-ray: cf-ray-auth-401-test, request id: req-auth, auth error: missing_authorization_header, auth error code: token_expired"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_includes_hours_and_minutes() {
|
|
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32);
|
|
with_now_override(base, move || {
|
|
let expected_time = format_retry_timestamp(&resets_at);
|
|
let err = UsageLimitReachedError {
|
|
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
|
resets_at: Some(resets_at),
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
let expected = format!(
|
|
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
|
);
|
|
assert_eq!(err.to_string(), expected);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_includes_days_hours_minutes() {
|
|
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let resets_at =
|
|
base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5);
|
|
with_now_override(base, move || {
|
|
let expected_time = format_retry_timestamp(&resets_at);
|
|
let err = UsageLimitReachedError {
|
|
plan_type: None,
|
|
resets_at: Some(resets_at),
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
|
assert_eq!(err.to_string(), expected);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_less_than_minute() {
|
|
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let resets_at = base + ChronoDuration::seconds(30);
|
|
with_now_override(base, move || {
|
|
let expected_time = format_retry_timestamp(&resets_at);
|
|
let err = UsageLimitReachedError {
|
|
plan_type: None,
|
|
resets_at: Some(resets_at),
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
|
assert_eq!(err.to_string(), expected);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn usage_limit_reached_with_promo_message() {
|
|
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let resets_at = base + ChronoDuration::seconds(30);
|
|
with_now_override(base, move || {
|
|
let expected_time = format_retry_timestamp(&resets_at);
|
|
let err = UsageLimitReachedError {
|
|
plan_type: None,
|
|
resets_at: Some(resets_at),
|
|
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
|
promo_message: Some(
|
|
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
|
|
),
|
|
rate_limit_reached_type: None,
|
|
};
|
|
let expected = format!(
|
|
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
|
|
);
|
|
assert_eq!(err.to_string(), expected);
|
|
});
|
|
}
|