From b818378a33f9bc5c41ae90ec2188f9b3fee364fd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 10 Jul 2026 21:11:15 +0000 Subject: [PATCH] harness(openai): surface the full transport error source chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reqwest send failure renders only its outermost context in Display (e.g. "error sending request for url (...)"), while the actionable cause — a "connection refused (os error 111)" errno, a TLS failure, a DNS error — lives one or more links down the source() chain. send_checked stringified just {e}, so a host classifying on the TinyAgentsError::Model message (loopback-offline detection, transport retryability) could not see it. Add error_source_chain(err) which walks the full source() chain joined by ": ", and use it when mapping the send failure. Unit-tested; fmt + clippy clean. --- src/harness/providers/openai/mod.rs | 2 +- src/harness/providers/openai/test.rs | 30 +++++++++++++++++++++++ src/harness/providers/openai/transport.rs | 29 ++++++++++++++++++++-- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/harness/providers/openai/mod.rs b/src/harness/providers/openai/mod.rs index 787eddf..df50a41 100644 --- a/src/harness/providers/openai/mod.rs +++ b/src/harness/providers/openai/mod.rs @@ -75,7 +75,7 @@ use convert::*; use sse::*; #[cfg(test)] use transport::{ - auth_headers, effective_temperature, glob_match, merge_provider_options, + auth_headers, effective_temperature, error_source_chain, glob_match, merge_provider_options, merge_system_into_user, request_timeout, }; diff --git a/src/harness/providers/openai/test.rs b/src/harness/providers/openai/test.rs index 5ae6268..42147e6 100644 --- a/src/harness/providers/openai/test.rs +++ b/src/harness/providers/openai/test.rs @@ -1475,3 +1475,33 @@ fn merge_provider_options_prefers_overrides_and_handles_nulls() { json!(["top_k", 40]) ); } + +#[test] +fn error_source_chain_walks_all_causes() { + use std::fmt; + // A transport error whose outer Display hides the actionable errno one link + // down the `source()` chain (the reqwest/hyper shape). + #[derive(Debug)] + struct Outer(std::io::Error); + impl fmt::Display for Outer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "error sending request for url (http://localhost:1234/)") + } + } + impl std::error::Error for Outer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } + } + let io = std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused (os error 111)", + ); + let chain = error_source_chain(&Outer(io)); + assert!(chain.contains("error sending request for url (http://localhost:1234/)")); + assert!(chain.contains("connection refused (os error 111)")); + assert_eq!( + chain, + "error sending request for url (http://localhost:1234/): connection refused (os error 111)" + ); +} diff --git a/src/harness/providers/openai/transport.rs b/src/harness/providers/openai/transport.rs index 10cdb90..c969125 100644 --- a/src/harness/providers/openai/transport.rs +++ b/src/harness/providers/openai/transport.rs @@ -742,8 +742,12 @@ impl OpenAiModel { url: &str, ) -> Result { let response = builder.send().await.map_err(|e| { - let error = - self.provider_error(format!("{what} to {url} failed: {e}"), None, None, None); + let error = self.provider_error( + format!("{what} to {url} failed: {}", error_source_chain(&e)), + None, + None, + None, + ); TinyAgentsError::Model(self.provider_failure_message(&error)) })?; @@ -853,6 +857,27 @@ pub(super) fn request_timeout(timeout_ms: Option, streaming: bool) -> Optio } } +/// Renders an error together with its full `source()` cause chain, joined by +/// `": "`. +/// +/// `reqwest`/`hyper` transport errors surface only their outermost context in +/// `Display` (e.g. `error sending request for url (...)`), while the actionable +/// detail — `connection refused (os error 111)`, a TLS failure, a DNS error — +/// lives one or more links down the `source()` chain. A plain `{e}` drops it, so +/// callers that classify on the message (loopback-offline detection, transport +/// retryability) never see it. Walking the chain here keeps the whole shape in +/// the `TinyAgentsError::Model` string. Pure, so it is unit-testable. +pub(super) fn error_source_chain(err: &dyn std::error::Error) -> String { + let mut rendered = err.to_string(); + let mut source = err.source(); + while let Some(inner) = source { + rendered.push_str(": "); + rendered.push_str(&inner.to_string()); + source = inner.source(); + } + rendered +} + /// Merges baked `defaults` under a request's own `overrides` provider options. /// /// Keys present in `overrides` win over `defaults`. A `Null` on either side