1use crate::ThreadId;
2use crate::auth::KnownPlan;
3use crate::auth::PlanType;
4pub use crate::auth::RefreshTokenFailedError;
5pub use crate::auth::RefreshTokenFailedReason;
6use crate::exec_output::ExecToolCallOutput;
7use crate::network_policy::NetworkPolicyDecisionPayload;
8use crate::protocol::CodexErrorInfo;
9use crate::protocol::ErrorEvent;
10use crate::protocol::RateLimitReachedType;
11use crate::protocol::RateLimitSnapshot;
12use crate::protocol::TruncationPolicy;
13use chrono::DateTime;
14use chrono::Datelike;
15use chrono::Local;
16use chrono::Utc;
17use codex_async_utils::CancelErr;
18use codex_utils_string::truncate_middle_chars;
19use codex_utils_string::truncate_middle_with_token_budget;
20use reqwest::StatusCode;
21use serde_json;
22use std::io;
23use std::time::Duration;
24use thiserror::Error;
25use tokio::task::JoinError;
26
27pub type Result<T> = std::result::Result<T, CodexErr>;
28
29const ERROR_MESSAGE_UI_MAX_BYTES: usize = 2 * 1024;
31
32#[derive(Error, Debug)]
33pub enum SandboxErr {
34 #[error(
36 "sandbox denied exec error, exit code: {}, stdout: {}, stderr: {}",
37 .output.exit_code, .output.stdout.text, .output.stderr.text
38 )]
39 Denied {
40 output: Box<ExecToolCallOutput>,
41 network_policy_decision: Option<NetworkPolicyDecisionPayload>,
42 },
43
44 #[cfg(target_os = "linux")]
46 #[error("seccomp setup error")]
47 SeccompInstall(#[from] seccompiler::Error),
48
49 #[cfg(target_os = "linux")]
51 #[error("seccomp backend error")]
52 SeccompBackend(#[from] seccompiler::BackendError),
53
54 #[error("command timed out")]
56 Timeout { output: Box<ExecToolCallOutput> },
57
58 #[error("command was killed by a signal")]
60 Signal(i32),
61
62 #[error("Landlock was not able to fully enforce all sandbox rules")]
64 LandlockRestrict,
65}
66
67#[derive(Error, Debug)]
68pub enum CodexErr {
69 #[error("turn aborted. Something went wrong? Hit `/feedback` to report the issue.")]
70 TurnAborted,
71
72 #[error("shared rollout token budget exhausted")]
73 SessionBudgetExceeded,
74
75 #[error("stream disconnected before completion: {0}")]
82 Stream(String, Option<Duration>),
83 #[error(
84 "Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying."
85 )]
86 ContextWindowExceeded,
87 #[error("no thread with id: {0}")]
88 ThreadNotFound(ThreadId),
89 #[error("agent thread limit reached")]
90 AgentLimitReached { max_threads: usize },
91 #[error("session configured event was not the first event in the stream")]
92 SessionConfiguredNotFirstEvent,
93 #[error("timeout waiting for child process to exit")]
95 Timeout,
96 #[error("request timed out")]
97 RequestTimeout,
98 #[error("spawn failed: child stdout/stderr not captured")]
101 Spawn,
102 #[error("interrupted (Ctrl-C). Something went wrong? Hit `/feedback` to report the issue.")]
105 Interrupted,
106 #[error("{0}")]
108 UnexpectedStatus(UnexpectedResponseError),
109 #[error("{0}")]
111 InvalidRequest(String),
112 #[error("Image poisoning")]
114 InvalidImageRequest(),
115 #[error("{0}")]
116 UsageLimitReached(UsageLimitReachedError),
117 #[error("Selected model is at capacity. Please try a different model.")]
118 ServerOverloaded,
119 #[error("{message}")]
120 CyberPolicy { message: String },
121 #[error("{0}")]
122 ResponseStreamFailed(ResponseStreamFailed),
123 #[error("{0}")]
124 ConnectionFailed(ConnectionFailedError),
125 #[error("Quota exceeded. Check your plan and billing details.")]
126 QuotaExceeded,
127 #[error(
128 "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus."
129 )]
130 UsageNotIncluded,
131 #[error("We're currently experiencing high demand, which may cause temporary errors.")]
132 InternalServerError,
133 #[error("{0}")]
135 RetryLimit(RetryLimitReachedError),
136 #[error("internal error; agent loop died unexpectedly")]
138 InternalAgentDied,
139 #[error("sandbox error: {0}")]
141 Sandbox(#[from] SandboxErr),
142 #[error("codex-linux-sandbox was required but not provided")]
143 LandlockSandboxExecutableNotProvided,
144 #[error("unsupported operation: {0}")]
145 UnsupportedOperation(String),
146 #[error("{0}")]
147 RefreshTokenFailed(RefreshTokenFailedError),
148 #[error("Fatal error: {0}")]
149 Fatal(String),
150 #[error(transparent)]
154 Io(#[from] io::Error),
155 #[error(transparent)]
156 Json(#[from] serde_json::Error),
157 #[cfg(target_os = "linux")]
158 #[error(transparent)]
159 LandlockRuleset(#[from] landlock::RulesetError),
160 #[cfg(target_os = "linux")]
161 #[error(transparent)]
162 LandlockPathFd(#[from] landlock::PathFdError),
163 #[error(transparent)]
164 TokioJoin(#[from] JoinError),
165 #[error("{0}")]
166 EnvVar(EnvVarError),
167}
168
169impl From<CancelErr> for CodexErr {
170 fn from(_: CancelErr) -> Self {
171 CodexErr::TurnAborted
172 }
173}
174
175impl CodexErr {
176 pub fn is_retryable(&self) -> bool {
177 match self {
178 CodexErr::TurnAborted
179 | CodexErr::SessionBudgetExceeded
180 | CodexErr::Interrupted
181 | CodexErr::EnvVar(_)
182 | CodexErr::Fatal(_)
183 | CodexErr::UsageNotIncluded
184 | CodexErr::QuotaExceeded
185 | CodexErr::InvalidImageRequest()
186 | CodexErr::InvalidRequest(_)
187 | CodexErr::RefreshTokenFailed(_)
188 | CodexErr::UnsupportedOperation(_)
189 | CodexErr::Sandbox(_)
190 | CodexErr::LandlockSandboxExecutableNotProvided
191 | CodexErr::RetryLimit(_)
192 | CodexErr::ContextWindowExceeded
193 | CodexErr::ThreadNotFound(_)
194 | CodexErr::AgentLimitReached { .. }
195 | CodexErr::Spawn
196 | CodexErr::SessionConfiguredNotFirstEvent
197 | CodexErr::UsageLimitReached(_)
198 | CodexErr::ServerOverloaded
199 | CodexErr::CyberPolicy { .. } => false,
200 CodexErr::Stream(..)
201 | CodexErr::Timeout
202 | CodexErr::RequestTimeout
203 | CodexErr::UnexpectedStatus(_)
204 | CodexErr::ResponseStreamFailed(_)
205 | CodexErr::ConnectionFailed(_)
206 | CodexErr::InternalServerError
207 | CodexErr::InternalAgentDied
208 | CodexErr::Io(_)
209 | CodexErr::Json(_)
210 | CodexErr::TokioJoin(_) => true,
211 #[cfg(target_os = "linux")]
212 CodexErr::LandlockRuleset(_) | CodexErr::LandlockPathFd(_) => false,
213 }
214 }
215
216 pub fn downcast_ref<T: std::any::Any>(&self) -> Option<&T> {
220 (self as &dyn std::any::Any).downcast_ref::<T>()
221 }
222
223 pub fn to_codex_protocol_error(&self) -> CodexErrorInfo {
225 match self {
226 CodexErr::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
227 CodexErr::SessionBudgetExceeded => CodexErrorInfo::SessionBudgetExceeded,
228 CodexErr::UsageLimitReached(_)
229 | CodexErr::QuotaExceeded
230 | CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
231 CodexErr::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
232 CodexErr::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy,
233 CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts {
234 http_status_code: self.http_status_code_value(),
235 },
236 CodexErr::ConnectionFailed(_) => CodexErrorInfo::HttpConnectionFailed {
237 http_status_code: self.http_status_code_value(),
238 },
239 CodexErr::ResponseStreamFailed(_) => CodexErrorInfo::ResponseStreamConnectionFailed {
240 http_status_code: self.http_status_code_value(),
241 },
242 CodexErr::RefreshTokenFailed(_) => CodexErrorInfo::Unauthorized,
243 CodexErr::SessionConfiguredNotFirstEvent
244 | CodexErr::InternalServerError
245 | CodexErr::InternalAgentDied => CodexErrorInfo::InternalServerError,
246 CodexErr::UnsupportedOperation(_)
247 | CodexErr::ThreadNotFound(_)
248 | CodexErr::AgentLimitReached { .. } => CodexErrorInfo::BadRequest,
249 CodexErr::Sandbox(_) => CodexErrorInfo::SandboxError,
250 _ => CodexErrorInfo::Other,
251 }
252 }
253
254 pub fn to_error_event(&self, message_prefix: Option<String>) -> ErrorEvent {
255 let error_message = self.to_string();
256 let message: String = match message_prefix {
257 Some(prefix) => format!("{prefix}: {error_message}"),
258 None => error_message,
259 };
260 ErrorEvent {
261 message,
262 codex_error_info: Some(self.to_codex_protocol_error()),
263 }
264 }
265
266 pub fn http_status_code_value(&self) -> Option<u16> {
267 let http_status_code = match self {
268 CodexErr::RetryLimit(err) => Some(err.status),
269 CodexErr::UnexpectedStatus(err) => Some(err.status),
270 CodexErr::ConnectionFailed(err) => err.source.status(),
271 CodexErr::ResponseStreamFailed(err) => err.source.status(),
272 _ => None,
273 };
274 http_status_code.as_ref().map(StatusCode::as_u16)
275 }
276}
277
278#[derive(Debug)]
279pub struct ConnectionFailedError {
280 pub source: reqwest::Error,
281}
282
283impl std::fmt::Display for ConnectionFailedError {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 write!(f, "Connection failed: {}", self.source)
286 }
287}
288
289#[derive(Debug)]
290pub struct ResponseStreamFailed {
291 pub source: reqwest::Error,
292 pub request_id: Option<String>,
293}
294
295impl std::fmt::Display for ResponseStreamFailed {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 write!(
298 f,
299 "Error while reading the server response: {}{}",
300 self.source,
301 self.request_id
302 .as_ref()
303 .map(|id| format!(", request id: {id}"))
304 .unwrap_or_default()
305 )
306 }
307}
308
309#[derive(Debug)]
310pub struct UnexpectedResponseError {
311 pub status: StatusCode,
312 pub body: String,
313 pub user_message: Option<String>,
314 pub url: Option<String>,
315 pub cf_ray: Option<String>,
316 pub request_id: Option<String>,
317 pub identity_authorization_error: Option<String>,
318 pub identity_error_code: Option<String>,
319}
320
321const UNEXPECTED_RESPONSE_BODY_MAX_BYTES: usize = 1000;
322
323impl UnexpectedResponseError {
324 fn display_body(&self) -> String {
325 if let Some(message) = self.extract_error_message() {
326 return message;
327 }
328
329 let trimmed_body = self.body.trim();
330 if trimmed_body.is_empty() {
331 return "Unknown error".to_string();
332 }
333
334 truncate_with_ellipsis(trimmed_body, UNEXPECTED_RESPONSE_BODY_MAX_BYTES)
335 }
336
337 fn extract_error_message(&self) -> Option<String> {
338 let json = serde_json::from_str::<serde_json::Value>(&self.body).ok()?;
339 let message = json
340 .get("error")
341 .and_then(|error| error.get("message"))
342 .and_then(serde_json::Value::as_str)?;
343 let message = message.trim();
344 if message.is_empty() {
345 None
346 } else {
347 Some(message.to_string())
348 }
349 }
350}
351
352impl std::fmt::Display for UnexpectedResponseError {
353 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354 let mut message = if let Some(user_message) = &self.user_message {
355 user_message.clone()
356 } else {
357 let status = self.status;
358 let body = self.display_body();
359 format!("unexpected status {status}: {body}")
360 };
361 if let Some(url) = &self.url {
362 message.push_str(&format!(", url: {url}"));
363 }
364 if let Some(cf_ray) = &self.cf_ray {
365 message.push_str(&format!(", cf-ray: {cf_ray}"));
366 }
367 if let Some(id) = &self.request_id {
368 message.push_str(&format!(", request id: {id}"));
369 }
370 if let Some(auth_error) = &self.identity_authorization_error {
371 message.push_str(&format!(", auth error: {auth_error}"));
372 }
373 if let Some(error_code) = &self.identity_error_code {
374 message.push_str(&format!(", auth error code: {error_code}"));
375 }
376 write!(f, "{message}")
377 }
378}
379
380impl std::error::Error for UnexpectedResponseError {}
381
382fn truncate_with_ellipsis(text: &str, max_bytes: usize) -> String {
383 if text.len() <= max_bytes {
384 return text.to_string();
385 }
386
387 let mut cut = max_bytes;
388 while !text.is_char_boundary(cut) {
389 cut = cut.saturating_sub(1);
390 }
391 let mut truncated = text[..cut].to_string();
392 truncated.push_str("...");
393 truncated
394}
395
396fn truncate_text(content: &str, policy: TruncationPolicy) -> String {
397 match policy {
398 TruncationPolicy::Bytes(bytes) => truncate_middle_chars(content, bytes),
399 TruncationPolicy::Tokens(tokens) => truncate_middle_with_token_budget(content, tokens).0,
400 }
401}
402
403#[derive(Debug)]
404pub struct RetryLimitReachedError {
405 pub status: StatusCode,
406 pub request_id: Option<String>,
407}
408
409impl std::fmt::Display for RetryLimitReachedError {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 write!(
412 f,
413 "exceeded retry limit, last status: {}{}",
414 self.status,
415 self.request_id
416 .as_ref()
417 .map(|id| format!(", request id: {id}"))
418 .unwrap_or_default()
419 )
420 }
421}
422
423#[derive(Debug)]
424pub struct UsageLimitReachedError {
425 pub plan_type: Option<PlanType>,
426 pub resets_at: Option<DateTime<Utc>>,
427 pub rate_limits: Option<Box<RateLimitSnapshot>>,
428 pub promo_message: Option<String>,
429 pub rate_limit_reached_type: Option<RateLimitReachedType>,
430}
431
432impl std::fmt::Display for UsageLimitReachedError {
433 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434 if let Some(limit_name) = self
435 .rate_limits
436 .as_ref()
437 .and_then(|snapshot| snapshot.limit_name.as_deref())
438 .map(str::trim)
439 .filter(|name| !name.is_empty())
440 && !limit_name.eq_ignore_ascii_case("codex")
441 {
442 return write!(
443 f,
444 "You've hit your usage limit for {limit_name}. Switch to another model now,{}",
445 retry_suffix_after_or(self.resets_at.as_ref())
446 );
447 }
448
449 if let Some(rate_limit_reached_type) = self.rate_limit_reached_type {
450 match rate_limit_reached_type {
451 RateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
452 return write!(
453 f,
454 "Your workspace is out of credits. Add credits to continue."
455 );
456 }
457 RateLimitReachedType::WorkspaceMemberCreditsDepleted => {
458 return write!(
459 f,
460 "Your workspace is out of credits. Ask your workspace owner to refill in order to continue."
461 );
462 }
463 RateLimitReachedType::WorkspaceOwnerUsageLimitReached => {
464 return write!(
465 f,
466 "You hit your spend cap set in your workspace. Increase your spend cap to continue."
467 );
468 }
469 RateLimitReachedType::WorkspaceMemberUsageLimitReached => {
470 return write!(
471 f,
472 "You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue."
473 );
474 }
475 RateLimitReachedType::RateLimitReached => {
476 }
478 }
479 }
480
481 if let Some(promo_message) = &self.promo_message {
482 return write!(
483 f,
484 "You've hit your usage limit. {promo_message},{}",
485 retry_suffix_after_or(self.resets_at.as_ref())
486 );
487 }
488
489 let message = match self.plan_type.as_ref() {
490 Some(PlanType::Known(KnownPlan::Plus)) => format!(
491 "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{}",
492 retry_suffix_after_or(self.resets_at.as_ref())
493 ),
494 Some(PlanType::Known(
495 KnownPlan::Team
496 | KnownPlan::SelfServeBusinessUsageBased
497 | KnownPlan::Business
498 | KnownPlan::EnterpriseCbpUsageBased,
499 )) => {
500 format!(
501 "You've hit your usage limit. To get more access now, send a request to your admin{}",
502 retry_suffix_after_or(self.resets_at.as_ref())
503 )
504 }
505 Some(PlanType::Known(KnownPlan::Free)) | Some(PlanType::Known(KnownPlan::Go)) => {
506 format!(
507 "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus),{}",
508 retry_suffix_after_or(self.resets_at.as_ref())
509 )
510 }
511 Some(PlanType::Known(KnownPlan::Pro | KnownPlan::ProLite)) => format!(
512 "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
513 retry_suffix_after_or(self.resets_at.as_ref())
514 ),
515 Some(PlanType::Known(KnownPlan::Enterprise))
516 | Some(PlanType::Known(KnownPlan::Edu)) => format!(
517 "You've hit your usage limit.{}",
518 retry_suffix(self.resets_at.as_ref())
519 ),
520 Some(PlanType::Unknown(_)) | None => format!(
521 "You've hit your usage limit.{}",
522 retry_suffix(self.resets_at.as_ref())
523 ),
524 };
525
526 write!(f, "{message}")
527 }
528}
529
530fn retry_suffix(resets_at: Option<&DateTime<Utc>>) -> String {
531 if let Some(resets_at) = resets_at {
532 let formatted = format_retry_timestamp(resets_at);
533 format!(" Try again at {formatted}.")
534 } else {
535 " Try again later.".to_string()
536 }
537}
538
539fn retry_suffix_after_or(resets_at: Option<&DateTime<Utc>>) -> String {
540 if let Some(resets_at) = resets_at {
541 let formatted = format_retry_timestamp(resets_at);
542 format!(" or try again at {formatted}.")
543 } else {
544 " or try again later.".to_string()
545 }
546}
547
548fn format_retry_timestamp(resets_at: &DateTime<Utc>) -> String {
549 let local_reset = resets_at.with_timezone(&Local);
550 let local_now = now_for_retry().with_timezone(&Local);
551 if local_reset.date_naive() == local_now.date_naive() {
552 local_reset.format("%-I:%M %p").to_string()
553 } else {
554 let suffix = day_suffix(local_reset.day());
555 local_reset
556 .format(&format!("%b %-d{suffix}, %Y %-I:%M %p"))
557 .to_string()
558 }
559}
560
561fn day_suffix(day: u32) -> &'static str {
562 match day {
563 11..=13 => "th",
564 _ => match day % 10 {
565 1 => "st",
566 2 => "nd", 3 => "rd",
568 _ => "th",
569 },
570 }
571}
572
573#[cfg(test)]
574thread_local! {
575 static NOW_OVERRIDE: std::cell::RefCell<Option<DateTime<Utc>>> =
576 const { std::cell::RefCell::new(None) };
577}
578
579fn now_for_retry() -> DateTime<Utc> {
580 #[cfg(test)]
581 {
582 if let Some(now) = NOW_OVERRIDE.with(|cell| *cell.borrow()) {
583 return now;
584 }
585 }
586 Utc::now()
587}
588
589#[derive(Debug)]
590pub struct EnvVarError {
591 pub var: String,
593 pub instructions: Option<String>,
596}
597
598impl std::fmt::Display for EnvVarError {
599 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
600 write!(f, "Missing environment variable: `{}`.", self.var)?;
601 if let Some(instructions) = &self.instructions {
602 write!(f, " {instructions}")?;
603 }
604 Ok(())
605 }
606}
607
608pub fn get_error_message_ui(e: &CodexErr) -> String {
609 let message = match e {
610 CodexErr::Sandbox(SandboxErr::Denied { output, .. }) => {
611 let aggregated = output.aggregated_output.text.trim();
612 if !aggregated.is_empty() {
613 output.aggregated_output.text.clone()
614 } else {
615 let stderr = output.stderr.text.trim();
616 let stdout = output.stdout.text.trim();
617 match (stderr.is_empty(), stdout.is_empty()) {
618 (false, false) => format!("{stderr}\n{stdout}"),
619 (false, true) => output.stderr.text.clone(),
620 (true, false) => output.stdout.text.clone(),
621 (true, true) => format!(
622 "command failed inside sandbox with exit code {}",
623 output.exit_code
624 ),
625 }
626 }
627 }
628 CodexErr::Sandbox(SandboxErr::Timeout { output }) => {
630 format!(
631 "error: command timed out after {} ms",
632 output.duration.as_millis()
633 )
634 }
635 _ => e.to_string(),
636 };
637
638 truncate_text(
639 &message,
640 TruncationPolicy::Bytes(ERROR_MESSAGE_UI_MAX_BYTES),
641 )
642}
643
644#[cfg(test)]
645#[path = "error_tests.rs"]
646mod tests;