Skip to content

Commit d3376ee

Browse files
committed
fix(dashboard): bypass system proxy in embedding probe + surface cause chain
Two related fixes to the `test_embedding_endpoint` Tauri command so the dashboard probe matches doctor's behavior and surfaces actionable error messages. **1. `.no_proxy()` on the reqwest client.** reqwest auto-detects macOS/Windows system proxy settings by default, which produces a surprising failure mode where `doctor` (Node fetch) and the dashboard (Rust reqwest) classify the same endpoint differently. Doctor's fetch only honors `HTTP_PROXY`/`HTTPS_PROXY` env vars; the dashboard tries to route the same `http://localhost:1234/v1` URL through whatever the user has configured in System Settings → Network → Proxies, which often refuses or fails for localhost endpoints even when a system proxy bypass list exists. Setting `.no_proxy()` aligns the dashboard probe with Node fetch so users get the same outcome from both tools. Users who genuinely want to route embedding traffic through a proxy can expose that as an explicit config field later. **2. Walk the reqwest error cause chain.** reqwest's `Display` only renders the top-level message (`error sending request for url (...)`) and drops the underlying cause. Users hit "Connection failed: error sending request for url (http://localhost:1234/v1/embeddings)" with no clue whether it was DNS, connection refused, TLS handshake, or proxy refusal. New `format_error_with_causes()` walks `error.source()` (capped at 5 levels for safety), de-duplicates immediately-repeated messages, and joins with ": ". Users now see e.g.: error sending request for url (http://localhost:1234/v1/embeddings): client error (Connect): Connection refused (os error 61) instead of just the URL. Adds 3 rust tests covering the formatter against a `ChainErr` mock (joins/single-level/dedup). cargo check + 20/20 embedding_probe tests green.
1 parent 49558f4 commit d3376ee

1 file changed

Lines changed: 107 additions & 1 deletion

File tree

packages/dashboard/src-tauri/src/embedding_probe.rs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,19 @@ pub async fn probe_embedding_endpoint(
199199

200200
let url = format!("{}/embeddings", endpoint);
201201

202+
// `.no_proxy()` is deliberate: by default reqwest auto-detects macOS
203+
// / Windows system proxy settings, which produces a confusing failure
204+
// mode where `doctor` works (Node's fetch ignores system proxies and
205+
// only honors HTTP_PROXY/HTTPS_PROXY env vars) but the dashboard
206+
// tries to route the same localhost URL through whatever the user
207+
// has configured in System Settings → Network → Proxies. Setting
208+
// no_proxy() here aligns the dashboard probe with Node's behavior so
209+
// both surfaces classify the same endpoint the same way. Users who
210+
// genuinely want to route embedding traffic through a proxy can
211+
// expose that as an explicit config field later if needed.
202212
let client = match reqwest::Client::builder()
203213
.timeout(Duration::from_millis(options.timeout_ms))
214+
.no_proxy()
204215
.build()
205216
{
206217
Ok(c) => c,
@@ -240,7 +251,12 @@ pub async fn probe_embedding_endpoint(
240251
};
241252
}
242253
return EmbeddingProbeOutcome::NetworkError {
243-
message: e.to_string(),
254+
// reqwest's Display only renders the top-level message
255+
// (`error sending request for url (...)`) and drops the
256+
// underlying cause. Walk the source chain so users see the
257+
// actual failure (connection refused, DNS, TLS handshake,
258+
// etc.) instead of just the URL.
259+
message: format_error_with_causes(&e),
244260
};
245261
}
246262
};
@@ -309,6 +325,32 @@ fn extract_dimensions(body: &serde_json::Value) -> Option<usize> {
309325
Some(embedding.len())
310326
}
311327

328+
/// Walk a reqwest error's source chain so the user sees the underlying
329+
/// cause (`connection refused`, `dns error: failed to lookup ...`,
330+
/// `tls handshake eof`) instead of only the top-level `error sending
331+
/// request for url (...)` message. Limited to 5 levels of depth as a
332+
/// safety bound — reqwest errors typically only carry 1–2 sources.
333+
fn format_error_with_causes(err: &(dyn std::error::Error + 'static)) -> String {
334+
let mut parts = vec![err.to_string()];
335+
let mut current = err.source();
336+
let mut depth = 0;
337+
while let Some(cause) = current {
338+
if depth >= 5 {
339+
break;
340+
}
341+
let cause_str = cause.to_string();
342+
// Skip empty causes and de-duplicate against the immediately
343+
// preceding part — reqwest occasionally wraps the same message
344+
// at multiple layers and we'd rather not surface it twice.
345+
if !cause_str.is_empty() && parts.last().map(|p| p.as_str()) != Some(cause_str.as_str()) {
346+
parts.push(cause_str);
347+
}
348+
current = cause.source();
349+
depth += 1;
350+
}
351+
parts.join(": ")
352+
}
353+
312354
fn truncate_preview(text: &str) -> String {
313355
// Char-safe truncation so multi-byte bodies don't panic.
314356
let mut buf = String::with_capacity(MAX_PREVIEW_CHARS.min(text.len()));
@@ -469,6 +511,70 @@ mod tests {
469511
assert_eq!(preview, "short");
470512
}
471513

514+
// ── format_error_with_causes ──────────────────────────────
515+
516+
use std::error::Error;
517+
use std::fmt;
518+
519+
/// Tiny error with a manually-controlled source chain so we can verify
520+
/// the formatter walks it correctly without needing reqwest internals.
521+
#[derive(Debug)]
522+
struct ChainErr {
523+
msg: &'static str,
524+
source: Option<Box<ChainErr>>,
525+
}
526+
impl fmt::Display for ChainErr {
527+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
528+
f.write_str(self.msg)
529+
}
530+
}
531+
impl Error for ChainErr {
532+
fn source(&self) -> Option<&(dyn Error + 'static)> {
533+
self.source
534+
.as_deref()
535+
.map(|e| e as &(dyn Error + 'static))
536+
}
537+
}
538+
539+
#[test]
540+
fn format_error_with_causes_joins_chain() {
541+
let inner = ChainErr {
542+
msg: "Connection refused (os error 61)",
543+
source: None,
544+
};
545+
let outer = ChainErr {
546+
msg: "error sending request for url (http://localhost:1234)",
547+
source: Some(Box::new(inner)),
548+
};
549+
let formatted = format_error_with_causes(&outer);
550+
assert_eq!(
551+
formatted,
552+
"error sending request for url (http://localhost:1234): Connection refused (os error 61)"
553+
);
554+
}
555+
556+
#[test]
557+
fn format_error_with_causes_handles_single_level() {
558+
let only = ChainErr {
559+
msg: "standalone failure",
560+
source: None,
561+
};
562+
assert_eq!(format_error_with_causes(&only), "standalone failure");
563+
}
564+
565+
#[test]
566+
fn format_error_with_causes_dedups_repeated_messages() {
567+
let inner = ChainErr {
568+
msg: "same message",
569+
source: None,
570+
};
571+
let outer = ChainErr {
572+
msg: "same message",
573+
source: Some(Box::new(inner)),
574+
};
575+
assert_eq!(format_error_with_causes(&outer), "same message");
576+
}
577+
472578
#[tokio::test]
473579
async fn probe_detects_invalid_scheme() {
474580
let outcome = probe_embedding_endpoint(EmbeddingProbeOptions {

0 commit comments

Comments
 (0)