From 650beb177e675aa8b0498b459b757451c347db57 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Thu, 12 Mar 2026 20:30:51 -0700 Subject: [PATCH] Refactor cloud requirements error and surface in JSON-RPC error (#14504) Refactors cloud requirements error handling to carry structured error metadata and surfaces that metadata through JSON-RPC config-load failures, including: * adds typed CloudRequirementsLoadErrorCode values plus optional statusCode * marks thread/start, thread/resume, and thread/fork config failures with structured cloud-requirements error data --- .../app-server/src/codex_message_processor.rs | 119 ++++++++++++++-- .../app-server/tests/suite/v2/thread_fork.rs | 134 ++++++++++++++++++ .../tests/suite/v2/thread_resume.rs | 132 +++++++++++++++++ .../app-server/tests/suite/v2/thread_start.rs | 120 ++++++++++++++++ codex-rs/cloud-requirements/src/lib.rs | 51 +++++-- codex-rs/config/src/cloud_requirements.rs | 27 +++- codex-rs/config/src/lib.rs | 1 + codex-rs/core/src/config_loader/mod.rs | 1 + codex-rs/core/src/config_loader/tests.rs | 6 +- 9 files changed, 562 insertions(+), 29 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7a73304f5..d35e18ca0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -201,6 +201,8 @@ use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::McpServerTransportConfig; +use codex_core::config_loader::CloudRequirementsLoadError; +use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::error::CodexErr; @@ -1959,11 +1961,7 @@ impl CodexMessageProcessor { { Ok(config) => config, Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; + let error = config_load_error(&err); listener_task_context .outgoing .send_error(request_id, error) @@ -3366,11 +3364,7 @@ impl CodexMessageProcessor { { Ok(config) => config, Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; + let error = config_load_error(&err); self.outgoing.send_error(request_id, error).await; return; } @@ -3889,11 +3883,9 @@ impl CodexMessageProcessor { { Ok(config) => config, Err(err) => { - self.send_invalid_request_error( - request_id, - format!("error deriving config: {err}"), - ) - .await; + self.outgoing + .send_error(request_id, config_load_error(&err)) + .await; return; } }; @@ -7464,6 +7456,42 @@ fn errors_to_info( .collect() } +fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { + let mut current: Option<&(dyn std::error::Error + 'static)> = err + .get_ref() + .map(|source| source as &(dyn std::error::Error + 'static)); + while let Some(source) = current { + if let Some(cloud_error) = source.downcast_ref::() { + return Some(cloud_error); + } + current = source.source(); + } + None +} + +fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { + let data = cloud_requirements_load_error(err).map(|cloud_error| { + let mut data = serde_json::json!({ + "reason": "cloudRequirements", + "errorCode": format!("{:?}", cloud_error.code()), + "detail": cloud_error.to_string(), + }); + if let Some(status_code) = cloud_error.status_code() { + data["statusCode"] = serde_json::json!(status_code); + } + if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { + data["action"] = serde_json::json!("relogin"); + } + data + }); + + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to load configuration: {err}"), + data, + } +} + fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { let mut seen = HashSet::new(); for tool in tools { @@ -8099,6 +8127,67 @@ mod tests { validate_dynamic_tools(&tools).expect("valid schema"); } + #[test] + fn config_load_error_marks_cloud_requirements_failures_for_relogin() { + let err = std::io::Error::other(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + Some(401), + "Your authentication session could not be refreshed automatically. Please log out and sign in again.", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your authentication session could not be refreshed automatically. Please log out and sign in again.", + })) + ); + assert!( + error.message.contains("failed to load configuration"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { + let err = std::io::Error::other("required MCP servers failed to initialize"); + + let error = config_load_error(&err); + + assert_eq!(error.data, None); + assert!( + error.message.contains("failed to load configuration"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { + let err = std::io::Error::other(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::RequestFailed, + None, + "failed to load your workspace-managed config", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "RequestFailed", + "detail": "failed to load your workspace-managed config", + })) + ); + } + #[test] fn collect_resume_override_mismatches_includes_service_tier() { let request = ThreadResumeParams { diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 62fecd743..1b40fadc6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -1,8 +1,10 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::create_fake_rollout; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; @@ -22,11 +24,19 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -212,6 +222,102 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + None, + )?; + + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let fork_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), + ) + .await??; + + assert!( + fork_err + .error + .message + .contains("failed to load configuration"), + "unexpected fork error: {}", + fork_err.error.message + ); + assert_eq!( + fork_err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + #[tokio::test] async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -398,3 +504,31 @@ stream_max_retries = 0 ), ) } + +fn create_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 12a74ed6e..27803db42 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_fake_rollout_with_text_elements; @@ -8,6 +9,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::rollout_path; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use chrono::Utc; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CommandExecutionApprovalDecision; @@ -36,6 +38,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; @@ -60,6 +64,11 @@ use std::process::Command; use tempfile::TempDir; use tokio::time::timeout; use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."; @@ -1409,6 +1418,98 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R Ok(()) } +#[tokio::test] +async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + None, + )?; + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + + assert!( + err.error.message.contains("failed to load configuration"), + "unexpected error message: {}", + err.error.message + ); + assert_eq!( + err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -1734,6 +1835,37 @@ stream_max_retries = 0 ) } +fn create_config_toml_with_chatgpt_base_url( + codex_home: &std::path::Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "gpt-5.2-codex" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[features] +personality = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + fn create_config_toml_with_required_broken_mcp( codex_home: &std::path::Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index bc383a19d..34431a48f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -1,7 +1,9 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; @@ -11,15 +13,23 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -318,6 +328,88 @@ async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Re Ok(()) } +#[tokio::test] +async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(req_id)), + ) + .await??; + + assert!( + err.error.message.contains("failed to load configuration"), + "unexpected error message: {}", + err.error.message + ); + assert_eq!( + err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); @@ -342,6 +434,34 @@ stream_max_retries = 0 ) } +fn create_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + fn create_config_toml_with_required_broken_mcp( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 94f78edcc..c6852200e 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -19,6 +19,7 @@ use codex_core::AuthManager; use codex_core::auth::CodexAuth; use codex_core::auth::RefreshTokenError; use codex_core::config_loader::CloudRequirementsLoadError; +use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::util::backoff; @@ -82,7 +83,7 @@ enum FetchAttemptError { Retryable(RetryableFailureKind), Unauthorized { status_code: Option, - error: CloudRequirementsLoadError, + message: String, }, } @@ -224,7 +225,7 @@ impl RequirementsFetcher for BackendRequirementsFetcher { if err.is_unauthorized() { FetchAttemptError::Unauthorized { status_code, - error: CloudRequirementsLoadError::new(err.to_string()), + message: err.to_string(), } } else { FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code }) @@ -282,10 +283,14 @@ impl CloudRequirementsService { emit_load_metric("startup", "error"); }) .map_err(|_| { - CloudRequirementsLoadError::new(format!( - "timed out waiting for cloud requirements after {}s", - self.timeout.as_secs() - )) + CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Timeout, + None, + format!( + "timed out waiting for cloud requirements after {}s", + self.timeout.as_secs() + ), + ) })?; let result = match fetch_result { @@ -381,7 +386,10 @@ impl CloudRequirementsService { attempt += 1; continue; } - Err(FetchAttemptError::Unauthorized { status_code, error }) => { + Err(FetchAttemptError::Unauthorized { + status_code, + message, + }) => { last_status_code = status_code; emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); if auth_recovery.has_next() { @@ -404,6 +412,8 @@ impl CloudRequirementsService { status_code, ); return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); }; @@ -422,7 +432,11 @@ impl CloudRequirementsService { attempt, status_code, ); - return Err(CloudRequirementsLoadError::new(failed.message)); + return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, + failed.message, + )); } Err(RefreshTokenError::Transient(recovery_err)) => { if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { @@ -441,7 +455,7 @@ impl CloudRequirementsService { } tracing::warn!( - error = %error, + error = %message, "Cloud requirements request was unauthorized and no auth recovery is available" ); emit_fetch_final_metric( @@ -452,6 +466,8 @@ impl CloudRequirementsService { status_code, ); return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); } @@ -470,6 +486,8 @@ impl CloudRequirementsService { last_status_code, ); return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Parse, + None, CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, )); } @@ -498,6 +516,8 @@ impl CloudRequirementsService { "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" ); Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::RequestFailed, + last_status_code, CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, )) } @@ -686,7 +706,11 @@ pub fn cloud_requirements_loader( CloudRequirementsLoader::new(async move { task.await.map_err(|err| { tracing::error!(error = %err, "Cloud requirements task failed"); - CloudRequirementsLoadError::new(format!("cloud requirements load failed: {err}")) + CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Internal, + None, + format!("cloud requirements load failed: {err}"), + ) })? }) } @@ -1009,7 +1033,7 @@ mod tests { } else { Err(FetchAttemptError::Unauthorized { status_code: Some(401), - error: CloudRequirementsLoadError::new("GET /config/requirements failed: 401"), + message: "GET /config/requirements failed: 401".to_string(), }) } } @@ -1029,7 +1053,7 @@ mod tests { self.request_count.fetch_add(1, Ordering::SeqCst); Err(FetchAttemptError::Unauthorized { status_code: Some(401), - error: CloudRequirementsLoadError::new(self.message.clone()), + message: self.message.clone(), }) } } @@ -1385,6 +1409,8 @@ mod tests { err.to_string(), CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE ); + assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Auth); + assert_eq!(err.status_code(), Some(401)); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } @@ -1767,6 +1793,7 @@ mod tests { err.to_string(), "failed to load your workspace-managed config" ); + assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); assert_eq!( fetcher.request_count.load(Ordering::SeqCst), CLOUD_REQUIREMENTS_MAX_ATTEMPTS diff --git a/codex-rs/config/src/cloud_requirements.rs b/codex-rs/config/src/cloud_requirements.rs index 1cf58563b..85b904824 100644 --- a/codex-rs/config/src/cloud_requirements.rs +++ b/codex-rs/config/src/cloud_requirements.rs @@ -6,18 +6,43 @@ use std::fmt; use std::future::Future; use thiserror::Error; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloudRequirementsLoadErrorCode { + Auth, + Timeout, + Parse, + RequestFailed, + Internal, +} + #[derive(Clone, Debug, Eq, Error, PartialEq)] #[error("{message}")] pub struct CloudRequirementsLoadError { + code: CloudRequirementsLoadErrorCode, message: String, + status_code: Option, } impl CloudRequirementsLoadError { - pub fn new(message: impl Into) -> Self { + pub fn new( + code: CloudRequirementsLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { Self { + code, message: message.into(), + status_code, } } + + pub fn code(&self) -> CloudRequirementsLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } } #[derive(Clone)] diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index c8e25e6e6..e6355c6cc 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -11,6 +11,7 @@ mod state; pub const CONFIG_TOML_FILE: &str = "config.toml"; pub use cloud_requirements::CloudRequirementsLoadError; +pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index b94bf81d0..60f9d5d7e 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -25,6 +25,7 @@ use std::path::PathBuf; use toml::Value as TomlValue; pub use codex_config::CloudRequirementsLoadError; +pub use codex_config::CloudRequirementsLoadErrorCode; pub use codex_config::CloudRequirementsLoader; pub use codex_config::ConfigError; pub use codex_config::ConfigLayerEntry; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index fb8de0861..452c5da98 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -741,7 +741,11 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::new(async { - Err(CloudRequirementsLoadError::new("cloud requirements failed")) + Err(CloudRequirementsLoadError::new( + codex_config::CloudRequirementsLoadErrorCode::RequestFailed, + None, + "cloud requirements failed", + )) }), ) .await