Skip to main content

codex_protocol/
error.rs

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
29/// Limit UI error messages to a reasonable size while keeping useful context.
30const ERROR_MESSAGE_UI_MAX_BYTES: usize = 2 * 1024;
31
32#[derive(Error, Debug)]
33pub enum SandboxErr {
34    /// Error from sandbox execution
35    #[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    /// Error from linux seccomp filter setup
45    #[cfg(target_os = "linux")]
46    #[error("seccomp setup error")]
47    SeccompInstall(#[from] seccompiler::Error),
48
49    /// Error from linux seccomp backend
50    #[cfg(target_os = "linux")]
51    #[error("seccomp backend error")]
52    SeccompBackend(#[from] seccompiler::BackendError),
53
54    /// Command timed out
55    #[error("command timed out")]
56    Timeout { output: Box<ExecToolCallOutput> },
57
58    /// Command was killed by a signal
59    #[error("command was killed by a signal")]
60    Signal(i32),
61
62    /// Error from linux landlock
63    #[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    /// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP
76    /// handshake has succeeded but **before** it finished emitting `response.completed`.
77    ///
78    /// The Session loop treats this as a transient error and will automatically retry the turn.
79    ///
80    /// Optionally includes the requested delay before retrying the turn.
81    #[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    /// Returned by run_command_stream when the spawned child process timed out (10s).
94    #[error("timeout waiting for child process to exit")]
95    Timeout,
96    #[error("request timed out")]
97    RequestTimeout,
98    /// Returned by run_command_stream when the child could not be spawned (its stdout/stderr pipes
99    /// could not be captured). Analogous to the previous `CodexError::Spawn` variant.
100    #[error("spawn failed: child stdout/stderr not captured")]
101    Spawn,
102    /// Returned by run_command_stream when the user pressed Ctrl-C (SIGINT). Session uses this to
103    /// surface a polite FunctionCallOutput back to the model instead of crashing the CLI.
104    #[error("interrupted (Ctrl-C). Something went wrong? Hit `/feedback` to report the issue.")]
105    Interrupted,
106    /// Unexpected HTTP status code.
107    #[error("{0}")]
108    UnexpectedStatus(UnexpectedResponseError),
109    /// Invalid request.
110    #[error("{0}")]
111    InvalidRequest(String),
112    /// Invalid image.
113    #[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    /// Retry limit exceeded.
134    #[error("{0}")]
135    RetryLimit(RetryLimitReachedError),
136    /// Agent loop died unexpectedly
137    #[error("internal error; agent loop died unexpectedly")]
138    InternalAgentDied,
139    /// Sandbox error
140    #[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    // -----------------------------------------------------------------
151    // Automatic conversions for common external error types
152    // -----------------------------------------------------------------
153    #[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    /// Minimal shim so that existing `e.downcast_ref::<CodexErr>()` checks continue to compile
217    /// after replacing `anyhow::Error` in the return signature. This mirrors the behavior of
218    /// `anyhow::Error::downcast_ref` but works directly on our concrete enum.
219    pub fn downcast_ref<T: std::any::Any>(&self) -> Option<&T> {
220        (self as &dyn std::any::Any).downcast_ref::<T>()
221    }
222
223    /// Translate core error to client-facing protocol error.
224    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                    // Generic limits intentionally use the existing promo or plan copy below.
477                }
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", // codespell:ignore
567            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    /// Name of the environment variable that is missing.
592    pub var: String,
593    /// Optional instructions to help the user get a valid value for the
594    /// variable and set it.
595    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        // Timeouts are not sandbox errors from a UX perspective; present them plainly.
629        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;