| use crate::ThreadId; |
| use crate::auth::KnownPlan; |
| use crate::auth::PlanType; |
| pub use crate::auth::RefreshTokenFailedError; |
| pub use crate::auth::RefreshTokenFailedReason; |
| use crate::exec_output::ExecToolCallOutput; |
| use crate::network_policy::NetworkPolicyDecisionPayload; |
| use crate::protocol::CodexErrorInfo; |
| use crate::protocol::ErrorEvent; |
| use crate::protocol::MisalignmentErrorDetails; |
| use crate::protocol::RateLimitReachedType; |
| use crate::protocol::RateLimitSnapshot; |
| use crate::protocol::TruncationPolicy; |
| use chrono::DateTime; |
| use chrono::Datelike; |
| use chrono::Local; |
| use chrono::Utc; |
| use codex_async_utils::CancelErr; |
| use codex_http_client::HttpError; |
| use codex_utils_string::truncate_middle_chars; |
| use codex_utils_string::truncate_middle_with_token_budget; |
| use http::StatusCode; |
| use serde_json; |
| use std::fmt; |
| use std::io; |
| use std::time::Duration; |
| use strum_macros::EnumDiscriminants; |
| use thiserror::Error; |
| use tokio::task::JoinError; |
|
|
| pub type Result<T> = std::result::Result<T, CodexErr>; |
|
|
| |
| const ERROR_MESSAGE_UI_MAX_BYTES: usize = 2 * 1024; |
|
|
| #[derive(Error, Debug)] |
| pub enum SandboxErr { |
| |
| #[error( |
| "sandbox denied exec error, exit code: {}, stdout: {}, stderr: {}", |
| .output.exit_code, .output.stdout.text, .output.stderr.text |
| )] |
| Denied { |
| output: Box<ExecToolCallOutput>, |
| network_policy_decision: Option<NetworkPolicyDecisionPayload>, |
| }, |
|
|
| |
| #[cfg(target_os = "linux")] |
| #[error("seccomp setup error")] |
| SeccompInstall(#[from] seccompiler::Error), |
|
|
| |
| #[cfg(target_os = "linux")] |
| #[error("seccomp backend error")] |
| SeccompBackend(#[from] seccompiler::BackendError), |
|
|
| |
| #[error("command timed out")] |
| Timeout { output: Box<ExecToolCallOutput> }, |
|
|
| |
| #[error("command was killed by a signal")] |
| Signal(i32), |
|
|
| |
| #[error("Landlock was not able to fully enforce all sandbox rules")] |
| LandlockRestrict, |
| } |
|
|
| pub struct CodexErr { |
| details: CodexErrorDetails, |
| retry_delay: Option<Duration>, |
| } |
|
|
| |
| #[derive(Error, Debug, EnumDiscriminants)] |
| #[strum_discriminants(name(CodexErrKind))] |
| #[strum_discriminants(derive(serde::Serialize))] |
| #[strum_discriminants(serde(rename_all = "snake_case"))] |
| #[strum_discriminants(doc = "The payload-free semantic category used for analytics.")] |
| pub enum CodexErrorDetails { |
| #[error("turn aborted. Something went wrong? Hit `/feedback` to report the issue.")] |
| TurnAborted, |
|
|
| #[error("shared rollout token budget exhausted")] |
| SessionBudgetExceeded, |
|
|
| |
| |
| |
| |
| #[error("stream disconnected before completion: {0}")] |
| Stream(String), |
| |
| #[error("rate limit exceeded: {0}")] |
| RateLimitExceeded(String), |
| |
| #[error( |
| "Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying." |
| )] |
| ContextWindowExceeded, |
| #[error("no thread with id: {0}")] |
| ThreadNotFound(ThreadId), |
| #[error("agent thread limit reached")] |
| AgentLimitReached { max_threads: usize }, |
| #[error("session configured event was not the first event in the stream")] |
| SessionConfiguredNotFirstEvent, |
| |
| #[error("timeout waiting for child process to exit")] |
| Timeout, |
| #[error("request timed out")] |
| RequestTimeout, |
| |
| |
| #[error("spawn failed: child stdout/stderr not captured")] |
| Spawn, |
| |
| |
| #[error("interrupted (Ctrl-C). Something went wrong? Hit `/feedback` to report the issue.")] |
| Interrupted, |
| |
| #[error("{0}")] |
| UnexpectedStatus(UnexpectedResponseError), |
| |
| #[error("{0}")] |
| InvalidRequest(String), |
| |
| #[error("duplicate tool: {0}")] |
| ToolCollision(String), |
| |
| #[error("Image poisoning")] |
| InvalidImageRequest(), |
| #[error("{0}")] |
| UsageLimitReached(UsageLimitReachedError), |
| #[error("Selected model is at capacity. Please try a different model.")] |
| ServerOverloaded, |
| #[error("{message}")] |
| CyberPolicy { message: String }, |
| #[error("{message}")] |
| MisalignmentPolicyViolation { |
| message: String, |
| misalignment: Option<MisalignmentErrorDetails>, |
| }, |
| #[error("{0}")] |
| ResponseStreamFailed(ResponseStreamFailed), |
| #[error("{0}")] |
| ConnectionFailed(ConnectionFailedError), |
| #[error("Quota exceeded. Check your plan and billing details.")] |
| QuotaExceeded, |
| #[error( |
| "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus." |
| )] |
| UsageNotIncluded, |
| #[error("We’re currently experiencing high demand, which may cause temporary errors.")] |
| InternalServerError, |
| |
| #[error("{0}")] |
| RetryLimit(RetryLimitReachedError), |
| |
| #[error("internal error; agent loop died unexpectedly")] |
| InternalAgentDied, |
| |
| #[error("sandbox error: {0}")] |
| Sandbox(#[from] SandboxErr), |
| #[error("codex-linux-sandbox was required but not provided")] |
| LandlockSandboxExecutableNotProvided, |
| #[error("unsupported operation: {0}")] |
| UnsupportedOperation(String), |
| #[error("{0}")] |
| RefreshTokenFailed(RefreshTokenFailedError), |
| #[error("Fatal error: {0}")] |
| Fatal(String), |
| |
| |
| |
| #[error(transparent)] |
| Io(#[from] io::Error), |
| #[error(transparent)] |
| Json(#[from] serde_json::Error), |
| #[cfg(target_os = "linux")] |
| #[error(transparent)] |
| LandlockRuleset(#[from] landlock::RulesetError), |
| #[cfg(target_os = "linux")] |
| #[error(transparent)] |
| LandlockPathFd(#[from] landlock::PathFdError), |
| #[error(transparent)] |
| TokioJoin(#[from] JoinError), |
| #[error("{0}")] |
| EnvVar(EnvVarError), |
| } |
|
|
| impl From<&CodexErr> for CodexErrKind { |
| fn from(error: &CodexErr) -> Self { |
| error.details().into() |
| } |
| } |
|
|
| impl fmt::Debug for CodexErr { |
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { |
| match &self.details { |
| CodexErrorDetails::Stream(message) => formatter |
| .debug_tuple("Stream") |
| .field(message) |
| .field(&self.retry_delay) |
| .finish(), |
| details => fmt::Debug::fmt(details, formatter), |
| } |
| } |
| } |
|
|
| impl fmt::Display for CodexErr { |
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { |
| fmt::Display::fmt(&self.details, formatter) |
| } |
| } |
|
|
| impl std::error::Error for CodexErr { |
| fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { |
| self.details.source() |
| } |
| } |
|
|
| impl From<CodexErrorDetails> for CodexErr { |
| fn from(details: CodexErrorDetails) -> Self { |
| Self { |
| details, |
| retry_delay: None, |
| } |
| } |
| } |
|
|
| impl From<CancelErr> for CodexErr { |
| fn from(error: CancelErr) -> Self { |
| CodexErrorDetails::from(error).into() |
| } |
| } |
|
|
| impl From<SandboxErr> for CodexErr { |
| fn from(error: SandboxErr) -> Self { |
| CodexErrorDetails::from(error).into() |
| } |
| } |
|
|
| impl From<io::Error> for CodexErr { |
| fn from(error: io::Error) -> Self { |
| CodexErrorDetails::from(error).into() |
| } |
| } |
|
|
| impl From<serde_json::Error> for CodexErr { |
| fn from(error: serde_json::Error) -> Self { |
| CodexErrorDetails::from(error).into() |
| } |
| } |
|
|
| impl From<JoinError> for CodexErr { |
| fn from(error: JoinError) -> Self { |
| CodexErrorDetails::from(error).into() |
| } |
| } |
|
|
| #[cfg(target_os = "linux")] |
| impl From<landlock::RulesetError> for CodexErr { |
| fn from(error: landlock::RulesetError) -> Self { |
| CodexErrorDetails::from(error).into() |
| } |
| } |
|
|
| #[cfg(target_os = "linux")] |
| impl From<landlock::PathFdError> for CodexErr { |
| fn from(error: landlock::PathFdError) -> Self { |
| CodexErrorDetails::from(error).into() |
| } |
| } |
|
|
| impl From<CancelErr> for CodexErrorDetails { |
| fn from(_: CancelErr) -> Self { |
| CodexErrorDetails::TurnAborted |
| } |
| } |
|
|
| |
| |
| macro_rules! codex_err_unit_constructors { |
| ($($variant:ident),* $(,)?) => { |
| $( |
| #[doc(hidden)] |
| #[allow(non_upper_case_globals)] |
| pub const $variant: Self = Self { |
| details: CodexErrorDetails::$variant, |
| retry_delay: None, |
| }; |
| )* |
| }; |
| } |
|
|
| |
| |
| macro_rules! codex_err_tuple_constructors { |
| ($($(#[$attr:meta])* $variant:ident($value:ident: $value_type:ty)),* $(,)?) => { |
| $( |
| $(#[$attr])* |
| #[doc(hidden)] |
| #[allow(non_snake_case)] |
| pub fn $variant($value: $value_type) -> Self { |
| CodexErrorDetails::$variant($value).into() |
| } |
| )* |
| }; |
| } |
|
|
| impl CodexErr { |
| codex_err_unit_constructors!( |
| TurnAborted, |
| SessionBudgetExceeded, |
| ContextWindowExceeded, |
| SessionConfiguredNotFirstEvent, |
| Timeout, |
| RequestTimeout, |
| Spawn, |
| Interrupted, |
| ServerOverloaded, |
| QuotaExceeded, |
| UsageNotIncluded, |
| InternalServerError, |
| InternalAgentDied, |
| LandlockSandboxExecutableNotProvided, |
| ); |
|
|
| codex_err_tuple_constructors!( |
| Stream(message: String), |
| ThreadNotFound(thread_id: ThreadId), |
| UnexpectedStatus(error: UnexpectedResponseError), |
| InvalidRequest(message: String), |
| UsageLimitReached(error: UsageLimitReachedError), |
| ResponseStreamFailed(error: ResponseStreamFailed), |
| ConnectionFailed(error: ConnectionFailedError), |
| RetryLimit(error: RetryLimitReachedError), |
| Sandbox(error: SandboxErr), |
| UnsupportedOperation(message: String), |
| RefreshTokenFailed(error: RefreshTokenFailedError), |
| Fatal(message: String), |
| Io(error: io::Error), |
| Json(error: serde_json::Error), |
| #[cfg(target_os = "linux")] |
| LandlockRuleset(error: landlock::RulesetError), |
| #[cfg(target_os = "linux")] |
| LandlockPathFd(error: landlock::PathFdError), |
| TokioJoin(error: JoinError), |
| EnvVar(error: EnvVarError), |
| ); |
|
|
| |
| |
| #[doc(hidden)] |
| #[allow(non_snake_case)] |
| pub fn InvalidImageRequest() -> Self { |
| CodexErrorDetails::InvalidImageRequest().into() |
| } |
|
|
| |
| pub fn new(details: CodexErrorDetails) -> Self { |
| details.into() |
| } |
|
|
| |
| pub fn details(&self) -> &CodexErrorDetails { |
| &self.details |
| } |
|
|
| pub fn is_retryable(&self) -> bool { |
| match self.details() { |
| CodexErrorDetails::TurnAborted |
| | CodexErrorDetails::SessionBudgetExceeded |
| | CodexErrorDetails::Interrupted |
| | CodexErrorDetails::EnvVar(_) |
| | CodexErrorDetails::Fatal(_) |
| | CodexErrorDetails::UsageNotIncluded |
| | CodexErrorDetails::QuotaExceeded |
| | CodexErrorDetails::InvalidImageRequest() |
| | CodexErrorDetails::InvalidRequest(_) |
| | CodexErrorDetails::ToolCollision(_) |
| | CodexErrorDetails::RefreshTokenFailed(_) |
| | CodexErrorDetails::UnsupportedOperation(_) |
| | CodexErrorDetails::Sandbox(_) |
| | CodexErrorDetails::LandlockSandboxExecutableNotProvided |
| | CodexErrorDetails::RetryLimit(_) |
| | CodexErrorDetails::ContextWindowExceeded |
| | CodexErrorDetails::ThreadNotFound(_) |
| | CodexErrorDetails::AgentLimitReached { .. } |
| | CodexErrorDetails::Spawn |
| | CodexErrorDetails::SessionConfiguredNotFirstEvent |
| | CodexErrorDetails::UsageLimitReached(_) |
| | CodexErrorDetails::ServerOverloaded |
| | CodexErrorDetails::CyberPolicy { .. } |
| | CodexErrorDetails::MisalignmentPolicyViolation { .. } => false, |
| CodexErrorDetails::Stream(..) |
| | CodexErrorDetails::RateLimitExceeded(_) |
| | CodexErrorDetails::Timeout |
| | CodexErrorDetails::RequestTimeout |
| | CodexErrorDetails::UnexpectedStatus(_) |
| | CodexErrorDetails::ResponseStreamFailed(_) |
| | CodexErrorDetails::ConnectionFailed(_) |
| | CodexErrorDetails::InternalServerError |
| | CodexErrorDetails::InternalAgentDied |
| | CodexErrorDetails::Io(_) |
| | CodexErrorDetails::Json(_) |
| | CodexErrorDetails::TokioJoin(_) => true, |
| #[cfg(target_os = "linux")] |
| CodexErrorDetails::LandlockRuleset(_) | CodexErrorDetails::LandlockPathFd(_) => false, |
| } |
| } |
|
|
| pub fn retry_delay(&self) -> Option<Duration> { |
| self.retry_delay |
| } |
|
|
| pub fn with_retry_delay(mut self, retry_delay: Duration) -> Self { |
| self.retry_delay = Some(retry_delay); |
| self |
| } |
|
|
| |
| |
| |
| pub fn downcast_ref<T: std::any::Any>(&self) -> Option<&T> { |
| (self as &dyn std::any::Any).downcast_ref::<T>() |
| } |
|
|
| |
| pub fn to_codex_protocol_error(&self) -> CodexErrorInfo { |
| match &self.details { |
| CodexErrorDetails::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, |
| CodexErrorDetails::SessionBudgetExceeded => CodexErrorInfo::SessionBudgetExceeded, |
| CodexErrorDetails::RateLimitExceeded(_) => CodexErrorInfo::RateLimitExceeded, |
| CodexErrorDetails::UsageLimitReached(_) |
| | CodexErrorDetails::QuotaExceeded |
| | CodexErrorDetails::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded, |
| CodexErrorDetails::ServerOverloaded => CodexErrorInfo::ServerOverloaded, |
| CodexErrorDetails::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy, |
| CodexErrorDetails::MisalignmentPolicyViolation { .. } => { |
| CodexErrorInfo::MisalignmentPolicyViolation |
| } |
| CodexErrorDetails::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts { |
| http_status_code: self.http_status_code_value(), |
| }, |
| CodexErrorDetails::ConnectionFailed(_) => CodexErrorInfo::HttpConnectionFailed { |
| http_status_code: self.http_status_code_value(), |
| }, |
| CodexErrorDetails::ResponseStreamFailed(_) => { |
| CodexErrorInfo::ResponseStreamConnectionFailed { |
| http_status_code: self.http_status_code_value(), |
| } |
| } |
| CodexErrorDetails::RefreshTokenFailed(_) => CodexErrorInfo::Unauthorized, |
| CodexErrorDetails::SessionConfiguredNotFirstEvent |
| | CodexErrorDetails::InternalServerError |
| | CodexErrorDetails::InternalAgentDied => CodexErrorInfo::InternalServerError, |
| CodexErrorDetails::UnsupportedOperation(_) |
| | CodexErrorDetails::ThreadNotFound(_) |
| | CodexErrorDetails::AgentLimitReached { .. } => CodexErrorInfo::BadRequest, |
| CodexErrorDetails::Sandbox(_) => CodexErrorInfo::SandboxError, |
| _ => CodexErrorInfo::Other, |
| } |
| } |
|
|
| pub fn to_error_event(&self, message_prefix: Option<String>) -> ErrorEvent { |
| let error_message = self.to_string(); |
| let message: String = match message_prefix { |
| Some(prefix) => format!("{prefix}: {error_message}"), |
| None => error_message, |
| }; |
| ErrorEvent { |
| message, |
| codex_error_info: Some(self.to_codex_protocol_error()), |
| misalignment: match &self.details { |
| CodexErrorDetails::MisalignmentPolicyViolation { misalignment, .. } => { |
| misalignment.clone() |
| } |
| _ => None, |
| }, |
| } |
| } |
|
|
| pub fn http_status_code_value(&self) -> Option<u16> { |
| let http_status_code = match &self.details { |
| CodexErrorDetails::RetryLimit(err) => Some(err.status), |
| CodexErrorDetails::UnexpectedStatus(err) => Some(err.status), |
| CodexErrorDetails::ConnectionFailed(err) => err.source.status(), |
| CodexErrorDetails::ResponseStreamFailed(err) => err.source.status(), |
| _ => None, |
| }; |
| http_status_code.as_ref().map(StatusCode::as_u16) |
| } |
| } |
|
|
| #[derive(Debug)] |
| pub struct ConnectionFailedError { |
| pub source: HttpError, |
| } |
|
|
| impl std::fmt::Display for ConnectionFailedError { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| write!(f, "Connection failed: {}", self.source) |
| } |
| } |
|
|
| #[derive(Debug)] |
| pub struct ResponseStreamFailed { |
| pub source: HttpError, |
| pub request_id: Option<String>, |
| } |
|
|
| impl std::fmt::Display for ResponseStreamFailed { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| write!( |
| f, |
| "Error while reading the server response: {}{}", |
| self.source, |
| self.request_id |
| .as_ref() |
| .map(|id| format!(", request id: {id}")) |
| .unwrap_or_default() |
| ) |
| } |
| } |
|
|
| #[derive(Clone, Debug)] |
| pub struct UnexpectedResponseError { |
| pub status: StatusCode, |
| pub body: String, |
| pub user_message: Option<String>, |
| pub url: Option<String>, |
| pub cf_ray: Option<String>, |
| pub request_id: Option<String>, |
| pub identity_authorization_error: Option<String>, |
| pub identity_error_code: Option<String>, |
| } |
|
|
| const UNEXPECTED_RESPONSE_BODY_MAX_BYTES: usize = 1000; |
|
|
| impl UnexpectedResponseError { |
| fn display_body(&self) -> String { |
| if let Some(message) = self.extract_error_message() { |
| return message; |
| } |
|
|
| let trimmed_body = self.body.trim(); |
| if trimmed_body.is_empty() { |
| return "Unknown error".to_string(); |
| } |
|
|
| truncate_with_ellipsis(trimmed_body, UNEXPECTED_RESPONSE_BODY_MAX_BYTES) |
| } |
|
|
| fn extract_error_message(&self) -> Option<String> { |
| let json = serde_json::from_str::<serde_json::Value>(&self.body).ok()?; |
| let message = json |
| .get("error") |
| .and_then(|error| error.get("message")) |
| .and_then(serde_json::Value::as_str)?; |
| let message = message.trim(); |
| if message.is_empty() { |
| None |
| } else { |
| Some(message.to_string()) |
| } |
| } |
| } |
|
|
| impl std::fmt::Display for UnexpectedResponseError { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| let mut message = if let Some(user_message) = &self.user_message { |
| user_message.clone() |
| } else { |
| let status = self.status; |
| let body = self.display_body(); |
| format!("unexpected status {status}: {body}") |
| }; |
| if let Some(url) = &self.url { |
| message.push_str(&format!(", url: {url}")); |
| } |
| if let Some(cf_ray) = &self.cf_ray { |
| message.push_str(&format!(", cf-ray: {cf_ray}")); |
| } |
| if let Some(id) = &self.request_id { |
| message.push_str(&format!(", request id: {id}")); |
| } |
| if let Some(auth_error) = &self.identity_authorization_error { |
| message.push_str(&format!(", auth error: {auth_error}")); |
| } |
| if let Some(error_code) = &self.identity_error_code { |
| message.push_str(&format!(", auth error code: {error_code}")); |
| } |
| write!(f, "{message}") |
| } |
| } |
|
|
| impl std::error::Error for UnexpectedResponseError {} |
|
|
| fn truncate_with_ellipsis(text: &str, max_bytes: usize) -> String { |
| if text.len() <= max_bytes { |
| return text.to_string(); |
| } |
|
|
| let mut cut = max_bytes; |
| while !text.is_char_boundary(cut) { |
| cut = cut.saturating_sub(1); |
| } |
| let mut truncated = text[..cut].to_string(); |
| truncated.push_str("..."); |
| truncated |
| } |
|
|
| fn truncate_text(content: &str, policy: TruncationPolicy) -> String { |
| match policy { |
| TruncationPolicy::Bytes(bytes) => truncate_middle_chars(content, bytes), |
| TruncationPolicy::Tokens(tokens) => truncate_middle_with_token_budget(content, tokens).0, |
| } |
| } |
|
|
| #[derive(Debug)] |
| pub struct RetryLimitReachedError { |
| pub status: StatusCode, |
| pub request_id: Option<String>, |
| } |
|
|
| impl std::fmt::Display for RetryLimitReachedError { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| write!( |
| f, |
| "exceeded retry limit, last status: {}{}", |
| self.status, |
| self.request_id |
| .as_ref() |
| .map(|id| format!(", request id: {id}")) |
| .unwrap_or_default() |
| ) |
| } |
| } |
|
|
| #[derive(Debug)] |
| pub struct UsageLimitReachedError { |
| pub plan_type: Option<PlanType>, |
| pub resets_at: Option<DateTime<Utc>>, |
| pub rate_limits: Option<Box<RateLimitSnapshot>>, |
| pub promo_message: Option<String>, |
| pub rate_limit_reached_type: Option<RateLimitReachedType>, |
| } |
|
|
| impl std::fmt::Display for UsageLimitReachedError { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| |
| |
| if let Some(limit_name) = self |
| .rate_limits |
| .as_ref() |
| .and_then(|snapshot| snapshot.limit_name.as_deref()) |
| .map(str::trim) |
| .filter(|name| !name.is_empty()) |
| && !limit_name.eq_ignore_ascii_case("codex") |
| && !limit_name.eq_ignore_ascii_case("gpt-reserve") |
| { |
| return write!( |
| f, |
| "You’ve hit your usage limit for {limit_name}. Switch to another model now,{}", |
| retry_suffix_after_or(self.resets_at.as_ref()) |
| ); |
| } |
|
|
| if let Some(rate_limit_reached_type) = self.rate_limit_reached_type { |
| match rate_limit_reached_type { |
| RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { |
| return write!( |
| f, |
| "Your workspace is out of credits. Add credits to continue." |
| ); |
| } |
| RateLimitReachedType::WorkspaceMemberCreditsDepleted => { |
| return write!( |
| f, |
| "Your workspace is out of credits. Ask your workspace owner to refill in order to continue." |
| ); |
| } |
| RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { |
| return write!( |
| f, |
| "You hit your spend cap set in your workspace. Increase your spend cap to continue." |
| ); |
| } |
| RateLimitReachedType::WorkspaceMemberUsageLimitReached => { |
| return write!( |
| f, |
| "You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue." |
| ); |
| } |
| RateLimitReachedType::RateLimitReached => { |
| |
| } |
| } |
| } |
|
|
| if let Some(promo_message) = &self.promo_message { |
| return write!( |
| f, |
| "You’ve hit your usage limit. {promo_message},{}", |
| retry_suffix_after_or(self.resets_at.as_ref()) |
| ); |
| } |
|
|
| let message = match self.plan_type.as_ref() { |
| Some(PlanType::Known(KnownPlan::Plus)) => 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{}", |
| retry_suffix_after_or(self.resets_at.as_ref()) |
| ), |
| Some(PlanType::Known( |
| KnownPlan::Team |
| | KnownPlan::SelfServeBusinessProLite |
| | KnownPlan::SelfServeBusinessUsageBased |
| | KnownPlan::Business |
| | KnownPlan::Ent26 |
| | KnownPlan::EnterpriseCbpAutomation |
| | KnownPlan::EnterpriseCbpUsageBased, |
| )) => { |
| format!( |
| "You’ve hit your usage limit. To get more access now, send a request to your admin{}", |
| retry_suffix_after_or(self.resets_at.as_ref()) |
| ) |
| } |
| Some(PlanType::Known(KnownPlan::Free)) | Some(PlanType::Known(KnownPlan::Go)) => { |
| format!( |
| "You’ve hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus),{}", |
| retry_suffix_after_or(self.resets_at.as_ref()) |
| ) |
| } |
| Some(PlanType::Known(KnownPlan::Pro | KnownPlan::ProLite)) => format!( |
| "You’ve hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}", |
| retry_suffix_after_or(self.resets_at.as_ref()) |
| ), |
| Some(PlanType::Known( |
| KnownPlan::Enterprise | KnownPlan::Edu | KnownPlan::EduPlus | KnownPlan::EduPro, |
| )) => format!( |
| "You’ve hit your usage limit.{}", |
| retry_suffix(self.resets_at.as_ref()) |
| ), |
| Some(PlanType::Unknown(_)) | None => format!( |
| "You’ve hit your usage limit.{}", |
| retry_suffix(self.resets_at.as_ref()) |
| ), |
| }; |
|
|
| write!(f, "{message}") |
| } |
| } |
|
|
| fn retry_suffix(resets_at: Option<&DateTime<Utc>>) -> String { |
| if let Some(resets_at) = resets_at { |
| let formatted = format_retry_timestamp(resets_at); |
| format!(" Try again at {formatted}.") |
| } else { |
| " Try again later.".to_string() |
| } |
| } |
|
|
| fn retry_suffix_after_or(resets_at: Option<&DateTime<Utc>>) -> String { |
| if let Some(resets_at) = resets_at { |
| let formatted = format_retry_timestamp(resets_at); |
| format!(" or try again at {formatted}.") |
| } else { |
| " or try again later.".to_string() |
| } |
| } |
|
|
| fn format_retry_timestamp(resets_at: &DateTime<Utc>) -> String { |
| let local_reset = resets_at.with_timezone(&Local); |
| let local_now = now_for_retry().with_timezone(&Local); |
| if local_reset.date_naive() == local_now.date_naive() { |
| local_reset.format("%-I:%M %p").to_string() |
| } else { |
| let suffix = day_suffix(local_reset.day()); |
| local_reset |
| .format(&format!("%b %-d{suffix}, %Y %-I:%M %p")) |
| .to_string() |
| } |
| } |
|
|
| fn day_suffix(day: u32) -> &'static str { |
| match day { |
| 11..=13 => "th", |
| _ => match day % 10 { |
| 1 => "st", |
| 2 => "nd", |
| 3 => "rd", |
| _ => "th", |
| }, |
| } |
| } |
|
|
| #[cfg(test)] |
| thread_local! { |
| static NOW_OVERRIDE: std::cell::RefCell<Option<DateTime<Utc>>> = |
| const { std::cell::RefCell::new(None) }; |
| } |
|
|
| fn now_for_retry() -> DateTime<Utc> { |
| #[cfg(test)] |
| { |
| if let Some(now) = NOW_OVERRIDE.with(|cell| *cell.borrow()) { |
| return now; |
| } |
| } |
| Utc::now() |
| } |
|
|
| #[derive(Debug)] |
| pub struct EnvVarError { |
| |
| pub var: String, |
| |
| |
| pub instructions: Option<String>, |
| } |
|
|
| impl std::fmt::Display for EnvVarError { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| write!(f, "Missing environment variable: `{}`.", self.var)?; |
| if let Some(instructions) = &self.instructions { |
| write!(f, " {instructions}")?; |
| } |
| Ok(()) |
| } |
| } |
|
|
| pub fn get_error_message_ui(e: &CodexErr) -> String { |
| let message = match e.details() { |
| CodexErrorDetails::Sandbox(SandboxErr::Denied { output, .. }) => { |
| let aggregated = output.aggregated_output.text.trim(); |
| if !aggregated.is_empty() { |
| output.aggregated_output.text.clone() |
| } else { |
| let stderr = output.stderr.text.trim(); |
| let stdout = output.stdout.text.trim(); |
| match (stderr.is_empty(), stdout.is_empty()) { |
| (false, false) => format!("{stderr}\n{stdout}"), |
| (false, true) => output.stderr.text.clone(), |
| (true, false) => output.stdout.text.clone(), |
| (true, true) => format!( |
| "command failed inside sandbox with exit code {}", |
| output.exit_code |
| ), |
| } |
| } |
| } |
| |
| CodexErrorDetails::Sandbox(SandboxErr::Timeout { output }) => { |
| format!( |
| "error: command timed out after {} ms", |
| output.duration.as_millis() |
| ) |
| } |
| _ => e.to_string(), |
| }; |
|
|
| truncate_text( |
| &message, |
| TruncationPolicy::Bytes(ERROR_MESSAGE_UI_MAX_BYTES), |
| ) |
| } |
|
|
| #[cfg(test)] |
| #[path = "error_tests.rs"] |
| mod tests; |
|
|