From 1eba113c36cd6bf9d1232662a53432098beebafb Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Fri, 27 Mar 2026 23:41:08 +0000 Subject: [PATCH 1/4] fix(cli): use curl-compatible error message for --fail HTTP errors Change the fail_on_error error message from "HTTP error NNN (fail_on_error enabled)" to "The requested URL returned error: NNN" to match curl's %{errormsg} format. This fixes --write-out %{onerror} and %{urlnum} output to stderr (curl test 1188). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/liburlx-ffi/src/lib.rs | 4 ++-- crates/liburlx/src/easy.rs | 2 +- crates/liburlx/tests/fail_on_error.rs | 2 +- crates/urlx-cli/src/transfer.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/liburlx-ffi/src/lib.rs b/crates/liburlx-ffi/src/lib.rs index 2dd1b62..164105b 100644 --- a/crates/liburlx-ffi/src/lib.rs +++ b/crates/liburlx-ffi/src/lib.rs @@ -4899,7 +4899,7 @@ fn error_to_curlcode(err: &liburlx::Error) -> CURLcode { CURLcode::CURLE_UNSUPPORTED_PROTOCOL } else if msg.contains("resolve") || msg.contains("DNS") { CURLcode::CURLE_COULDNT_RESOLVE_HOST - } else if msg.contains("HTTP error") && msg.contains("fail_on_error") { + } else if msg.contains("The requested URL returned error") { CURLcode::CURLE_HTTP_RETURNED_ERROR } else if msg.contains("aborted by") || msg.contains("callback") { CURLcode::CURLE_ABORTED_BY_CALLBACK @@ -5365,7 +5365,7 @@ mod tests { fn error_code_http_returned_error() { assert_eq!( error_to_curlcode(&liburlx::Error::Http( - "HTTP error 404 (fail_on_error enabled)".to_string() + "The requested URL returned error: 404".to_string() )), CURLcode::CURLE_HTTP_RETURNED_ERROR ); diff --git a/crates/liburlx/src/easy.rs b/crates/liburlx/src/easy.rs index 984952d..3177885 100644 --- a/crates/liburlx/src/easy.rs +++ b/crates/liburlx/src/easy.rs @@ -3174,7 +3174,7 @@ impl Easy { // Check fail_on_error: HTTP status >= 400 becomes an error if self.fail_on_error && response.status() >= 400 { return Err(Error::Http(format!( - "HTTP error {} (fail_on_error enabled)", + "The requested URL returned error: {}", response.status() ))); } diff --git a/crates/liburlx/tests/fail_on_error.rs b/crates/liburlx/tests/fail_on_error.rs index f27d1c0..5d2c393 100644 --- a/crates/liburlx/tests/fail_on_error.rs +++ b/crates/liburlx/tests/fail_on_error.rs @@ -179,7 +179,7 @@ async fn fail_on_error_http10_no_content_length_no_hang() { // Should return an HTTP error (status 404 with fail_on_error), NOT a timeout assert!(result.is_err(), "404 with fail_on_error should error"); let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("HTTP error 404"), "Expected HTTP 404 error, got: {err_msg}"); + assert!(err_msg.contains("returned error: 404"), "Expected HTTP 404 error, got: {err_msg}"); server_task.abort(); } diff --git a/crates/urlx-cli/src/transfer.rs b/crates/urlx-cli/src/transfer.rs index 948fd62..4de1c15 100644 --- a/crates/urlx-cli/src/transfer.rs +++ b/crates/urlx-cli/src/transfer.rs @@ -2477,7 +2477,7 @@ pub fn error_to_curl_code(err: &liburlx::Error) -> u8 { 52 // CURLE_GOT_NOTHING } else if msg.contains("too many redirects") || msg.contains("Too many redirects") { 47 // CURLE_TOO_MANY_REDIRECTS - } else if msg.contains("fail_on_error") { + } else if msg.contains("The requested URL returned error") { 22 // CURLE_HTTP_RETURNED_ERROR } else if msg.contains("unsupported protocol") || msg.contains("Unsupported protocol") From 95306bd8f4ce7eba1dd21ff93ed394850c5efd51 Mon Sep 17 00:00:00 2001 From: Jon Wiggins <35540058+jonwiggins@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:48:01 -0700 Subject: [PATCH 2/4] fix(http): use crate version for default User-Agent instead of hardcoded 0.1.0 (#115) The default User-Agent was hardcoded as "curl/0.1.0" in h1, h2, h3, and easy.rs (CONNECT proxy). After --next resets headers, subsequent request groups fell back to this stale default instead of the actual version, causing curl tests 386, 430, 431, 432 to fail. Replace all instances with concat!("curl/", env!("CARGO_PKG_VERSION")) so the default User-Agent always matches the crate version. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.6 (1M context) --- crates/liburlx/src/easy.rs | 11 +++++++++-- crates/liburlx/src/protocol/http/h1.rs | 2 +- crates/liburlx/src/protocol/http/h2.rs | 2 +- crates/liburlx/src/protocol/http/h3.rs | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/liburlx/src/easy.rs b/crates/liburlx/src/easy.rs index 3177885..9f40ea4 100644 --- a/crates/liburlx/src/easy.rs +++ b/crates/liburlx/src/easy.rs @@ -6024,7 +6024,10 @@ async fn do_single_request( let ua = headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case("user-agent")) - .map_or_else(|| "curl/0.1.0".to_string(), |(_, v)| v.clone()); + .map_or_else( + || concat!("curl/", env!("CARGO_PKG_VERSION")).to_string(), + |(_, v)| v.clone(), + ); Some(crate::protocol::ftp::FtpProxyConfig::HttpConnect { host: ph, port: pp, @@ -8039,7 +8042,11 @@ where let _ = write!(connect_req, "{name}: {value}\r\n"); } None => { - connect_req.push_str("User-Agent: curl/0.1.0\r\n"); + connect_req.push_str(concat!( + "User-Agent: curl/", + env!("CARGO_PKG_VERSION"), + "\r\n" + )); } } } diff --git a/crates/liburlx/src/protocol/http/h1.rs b/crates/liburlx/src/protocol/http/h1.rs index aac65b2..8d3e580 100644 --- a/crates/liburlx/src/protocol/http/h1.rs +++ b/crates/liburlx/src/protocol/http/h1.rs @@ -196,7 +196,7 @@ where let _ = write!(req, "{name}: {value}\r\n"); } None => { - req.push_str("User-Agent: curl/0.1.0\r\n"); + req.push_str(concat!("User-Agent: curl/", env!("CARGO_PKG_VERSION"), "\r\n")); } } diff --git a/crates/liburlx/src/protocol/http/h2.rs b/crates/liburlx/src/protocol/http/h2.rs index b99c0eb..f51d08c 100644 --- a/crates/liburlx/src/protocol/http/h2.rs +++ b/crates/liburlx/src/protocol/http/h2.rs @@ -158,7 +158,7 @@ pub async fn send_request( let has_user_agent = custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("user-agent")); if !has_user_agent { - builder = builder.header("user-agent", "curl/0.1.0"); + builder = builder.header("user-agent", concat!("curl/", env!("CARGO_PKG_VERSION"))); } let has_accept = custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")); diff --git a/crates/liburlx/src/protocol/http/h3.rs b/crates/liburlx/src/protocol/http/h3.rs index ce0ef3f..b5e8a21 100644 --- a/crates/liburlx/src/protocol/http/h3.rs +++ b/crates/liburlx/src/protocol/http/h3.rs @@ -173,7 +173,7 @@ pub async fn request( let has_user_agent = custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("user-agent")); if !has_user_agent { - builder = builder.header("user-agent", "curl/0.1.0"); + builder = builder.header("user-agent", concat!("curl/", env!("CARGO_PKG_VERSION"))); } let has_accept = custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")); From 92ccaa2e16f0e3f84c117bac20b23adcfeb8cbb2 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Sat, 28 Mar 2026 00:31:20 +0000 Subject: [PATCH 3/4] fix(cli): handle --fail HTTP errors in multi-URL mode for write-out and output When multiple URLs are used with --fail, the Easy handle's fail_on_error flag causes perform_async() to return an Err instead of Ok(response). The error branch was using a dummy response for write-out and not writing headers to the output file, which broke --write-out variables like %{onerror}, %{urlnum}, %{exitcode}, and %{errormsg} when combined with %{stderr}, and also broke --include output to files. Fix by detecting --fail HTTP errors (curl code 22) in the Err branch and using easy.last_response() to retrieve the actual response stored before the error was returned. This provides correct headers for --include output and proper response data for write-out variable expansion. Passes curl test 1188. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/urlx-cli/src/transfer.rs | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/urlx-cli/src/transfer.rs b/crates/urlx-cli/src/transfer.rs index 4de1c15..770864a 100644 --- a/crates/urlx-cli/src/transfer.rs +++ b/crates/urlx-cli/src/transfer.rs @@ -3306,10 +3306,53 @@ pub fn run_multi( false, ); } + let curl_code = error_to_curl_code(&e); + // --fail HTTP error: use actual response for headers/write-out + // (curl compat: test 1188). The Easy handle stores the response + // before returning the fail_on_error error. + let is_fail_http_error = fail_on_error && curl_code == 22; + if is_fail_http_error { + if let Some(response) = easy.last_response() { + let err_msg = + format!("The requested URL returned error: {}", response.status()); + let file_for_this = output_files.get(i).map(String::as_str); + let ctx = WriteOutContext { + urlnum: i, + exitcode: 22, + errormsg: err_msg.clone(), + had_error: true, + ..WriteOutContext::default() + }; + let suppress = !fail_with_body; + let shares_file_with_next = file_for_this.is_some() + && output_files.get(i + 1).map(String::as_str) == file_for_this; + let effective_file = + if suppress && shares_file_with_next { None } else { file_for_this }; + let effective_include = + if suppress && shares_file_with_next { false } else { include_headers }; + let _ = output_response_with_context( + response, + effective_file, + write_out, + effective_include, + silent, + suppress, + &ctx, + ); + if !silent || show_error { + eprintln!("curl: (22) {err_msg}"); + } + let exit_code = ExitCode::from(22_u8); + if fail_early { + return exit_code; + } + last_exit = exit_code; + continue; + } + } if !silent || show_error { eprintln!("curl: transfer {} ({}): {e}", i + 1, url); } - let curl_code = error_to_curl_code(&e); // Still produce write-out for failed transfers (curl compat: test 423) if let Some(wo) = write_out { let dummy_response = liburlx::Response::new( From a9675d87e257fd5ddf454999815e56271a1314fb Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Sat, 28 Mar 2026 00:55:58 +0000 Subject: [PATCH 4/4] fix(cli): don't retry --fail HTTP errors in perform_with_retry When --fail causes perform() to return Err(Error::Http) for non-retriable HTTP status codes (like 404), the retry loop should not retry. Previously, the Err branch retried unconditionally, causing an extra request when --retry was used with --fail on non-retriable responses. (curl compat: test 752) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/urlx-cli/src/transfer.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/urlx-cli/src/transfer.rs b/crates/urlx-cli/src/transfer.rs index b98ea50..027d65e 100644 --- a/crates/urlx-cli/src/transfer.rs +++ b/crates/urlx-cli/src/transfer.rs @@ -2679,9 +2679,12 @@ pub fn perform_with_retry(opts: &mut CliOptions) -> Result { + // Don't retry non-retriable errors like --fail HTTP errors + // (curl compat: test 752 — 404 should not be retried). + let should_break = error_to_curl_code(&e) == 22; // CURLE_HTTP_RETURNED_ERROR last_err = Some(e); opts.retry_attempts = attempt; - if attempt == max_retries { + if should_break || attempt == max_retries { break; } }