From 183fc8e01a121125682c7224f39a8d91bb862cd2 Mon Sep 17 00:00:00 2001 From: Alexander Smirnov <145155732+smalyu@users.noreply.github.com> Date: Sat, 8 Nov 2025 02:55:16 +0300 Subject: [PATCH] core: replace Cloudflare 403 HTML with friendly message (#6252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Motivation When Codex is launched from a region where Cloudflare blocks access (for example, Russia), the CLI currently dumps Cloudflare’s entire HTML error page. This isn’t actionable and makes it hard for users to understand what happened. We want to detect the Cloudflare block and show a concise, user-friendly explanation instead. ### What Changed - Added CLOUDFLARE_BLOCKED_MESSAGE and a friendly_message() helper to UnexpectedResponseError. Whenever we see a 403 whose body contains the Cloudflare block notice, we now emit a single-line message (Access blocked by Cloudflare…) while preserving the HTTP status and request id. All other responses keep the original behaviour. - Added two focused unit tests: - unexpected_status_cloudflare_html_is_simplified ensures the Cloudflare HTML case yields the friendly message. - unexpected_status_non_html_is_unchanged confirms plain-text 403s still return the raw body. ### Testing - cargo build -p codex-cli - cargo test -p codex-core - just fix -p codex-core - cargo test --all-features --------- Co-authored-by: Eric Traut --- codex-rs/core/src/error.rs | 75 +++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 10e936f1..64ba8df8 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -238,18 +238,44 @@ pub struct UnexpectedResponseError { pub request_id: Option, } +const CLOUDFLARE_BLOCKED_MESSAGE: &str = + "Access blocked by Cloudflare. This usually happens when connecting from a restricted region"; + +impl UnexpectedResponseError { + fn friendly_message(&self) -> Option { + if self.status != StatusCode::FORBIDDEN { + return None; + } + + if !self.body.contains("Cloudflare") || !self.body.contains("blocked") { + return None; + } + + let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {})", self.status); + if let Some(id) = &self.request_id { + message.push_str(&format!(", request id: {id}")); + } + + Some(message) + } +} + impl std::fmt::Display for UnexpectedResponseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "unexpected status {}: {}{}", - self.status, - self.body, - self.request_id - .as_ref() - .map(|id| format!(", request id: {id}")) - .unwrap_or_default() - ) + if let Some(friendly) = self.friendly_message() { + write!(f, "{friendly}") + } else { + write!( + f, + "unexpected status {}: {}{}", + self.status, + self.body, + self.request_id + .as_ref() + .map(|id| format!(", request id: {id}")) + .unwrap_or_default() + ) + } } } @@ -665,6 +691,35 @@ mod tests { }); } + #[test] + fn unexpected_status_cloudflare_html_is_simplified() { + let err = UnexpectedResponseError { + status: StatusCode::FORBIDDEN, + body: "Cloudflare error: Sorry, you have been blocked" + .to_string(), + request_id: Some("ray-id".to_string()), + }; + let status = StatusCode::FORBIDDEN.to_string(); + assert_eq!( + err.to_string(), + format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), request id: ray-id") + ); + } + + #[test] + fn unexpected_status_non_html_is_unchanged() { + let err = UnexpectedResponseError { + status: StatusCode::FORBIDDEN, + body: "plain text error".to_string(), + request_id: None, + }; + let status = StatusCode::FORBIDDEN.to_string(); + assert_eq!( + err.to_string(), + format!("unexpected status {status}: plain text error") + ); + } + #[test] fn usage_limit_reached_includes_hours_and_minutes() { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();