Skip to content

Commit 7235ee9

Browse files
Merge pull request #64 from YellowSnnowmann/feat/transport-error-diagnosis
Name the class of a transport failure, not just "request failed"
2 parents adcbbea + d30e63d commit 7235ee9

1 file changed

Lines changed: 124 additions & 6 deletions

File tree

adapters/remote/src/common.rs

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,40 @@ impl HttpClient {
9191
}
9292

9393
/// Sends a JSON request and decodes a successful JSON response.
94+
/// The error for a request that never produced a response.
95+
///
96+
/// `reqwest`'s own Display is one clause — "error sending request" — and
97+
/// the cause that matters (DNS, TLS, timeout, refused) is one or more
98+
/// `source()` hops down, which a host that logs only the top line never
99+
/// sees. Real case this was written for: a hosted endpoint that accepted
100+
/// TCP and then aborted the TLS handshake, reported to the operator as
101+
/// "request failed" with nothing to act on.
102+
///
103+
/// So the class is named up front and the underlying chain is appended.
104+
/// Naming the class is a judgement, not a parse: `reqwest` exposes
105+
/// `is_timeout`/`is_connect` directly, and TLS is recognised from the
106+
/// chain's text because rustls' error types are not in this crate's
107+
/// public dependencies.
108+
fn transport_error(&self, error: reqwest::Error) -> anyhow::Error {
109+
let host = self.endpoint.host_str().unwrap_or("<endpoint>");
110+
let chain = {
111+
let mut parts: Vec<String> = Vec::new();
112+
let mut source: Option<&(dyn std::error::Error + 'static)> =
113+
std::error::Error::source(&error);
114+
while let Some(cause) = source {
115+
parts.push(cause.to_string());
116+
source = cause.source();
117+
}
118+
parts.join(": ")
119+
};
120+
let class = classify_transport(error.is_timeout(), error.is_connect(), &chain);
121+
if chain.is_empty() {
122+
anyhow::anyhow!("memory API request to {host}: {class}")
123+
} else {
124+
anyhow::anyhow!("memory API request to {host}: {class} ({chain})")
125+
}
126+
}
127+
94128
/// The error for a non-success status, written for the operator reading a
95129
/// log: it names the endpoint host (never the credential) and calls out a
96130
/// rejected credential specifically, because "HTTP 401" three layers deep
@@ -126,11 +160,10 @@ impl HttpClient {
126160
if let Some(body) = body {
127161
request = request.json(body);
128162
}
129-
let host = self.endpoint.host_str().unwrap_or("<endpoint>").to_owned();
130163
let response = request
131164
.send()
132165
.await
133-
.with_context(|| format!("memory API request to {host} failed"))?;
166+
.map_err(|error| self.transport_error(error))?;
134167
let status = response.status();
135168
if !status.is_success() {
136169
return Err(self.status_error(path, status));
@@ -143,12 +176,11 @@ impl HttpClient {
143176

144177
/// Sends a request and returns a successful response body as text.
145178
pub(crate) async fn text(&self, method: Method, path: &str) -> anyhow::Result<String> {
146-
let host = self.endpoint.host_str().unwrap_or("<endpoint>").to_owned();
147179
let response = self
148180
.request(method, path)?
149181
.send()
150182
.await
151-
.with_context(|| format!("memory API request to {host} failed"))?;
183+
.map_err(|error| self.transport_error(error))?;
152184
let status = response.status();
153185
if !status.is_success() {
154186
return Err(self.status_error(path, status));
@@ -170,11 +202,10 @@ impl HttpClient {
170202
if let Some(body) = body {
171203
request = request.json(body);
172204
}
173-
let host = self.endpoint.host_str().unwrap_or("<endpoint>").to_owned();
174205
let response = request
175206
.send()
176207
.await
177-
.with_context(|| format!("memory API request to {host} failed"))?;
208+
.map_err(|error| self.transport_error(error))?;
178209
let status = response.status();
179210
if !status.is_success() {
180211
return Err(self.status_error(path, status));
@@ -476,3 +507,90 @@ fn matches_filters(entry: &StoredEntry, opts: &RecallOpts<'_>) -> bool {
476507
.session_id
477508
.is_none_or(|value| entry.session_id.as_deref() == Some(value))
478509
}
510+
511+
/// Name the class of a transport failure from what the error chain says.
512+
///
513+
/// Pure so the ORDER is testable, which is the whole reason it exists as its
514+
/// own function: `is_connect()` is also true for DNS and TLS failures, so a
515+
/// naive `if is_connect()` first collapses every class into "could not
516+
/// connect". That is exactly what the first version of this did, and it took
517+
/// a live run against a real broken endpoint to notice.
518+
fn classify_transport(is_timeout: bool, is_connect: bool, chain: &str) -> &'static str {
519+
let lower = chain.to_ascii_lowercase();
520+
if is_timeout {
521+
"timed out"
522+
} else if lower.contains("dns")
523+
|| lower.contains("name or service")
524+
|| lower.contains("failed to lookup")
525+
{
526+
"the host could not be resolved — check the URL"
527+
} else if lower.contains("tls")
528+
|| lower.contains("handshake")
529+
|| lower.contains("certificate")
530+
|| lower.contains("fatal alert")
531+
|| lower.contains("invalid peer")
532+
|| lower.contains("unknown issuer")
533+
{
534+
"TLS failed — the endpoint answered on the port but could not establish a \
535+
secure connection; check that the URL is the engine's real API host"
536+
} else if is_connect {
537+
"could not connect — check the URL and that the service is reachable"
538+
} else {
539+
"the request did not complete"
540+
}
541+
}
542+
543+
#[cfg(test)]
544+
mod transport_tests {
545+
use super::classify_transport;
546+
547+
/// The verbatim chain a rustls handshake abort produces. Cognee's hosted
548+
/// endpoint answered TCP and then sent this; `reqwest` reports it as a
549+
/// CONNECT error, so an `is_connect` check placed first swallows it — and
550+
/// the string never contains the word "TLS", so matching on that alone
551+
/// misses it too. Both traps, pinned.
552+
#[test]
553+
fn a_rustls_handshake_abort_is_named_tls_not_connect() {
554+
let class = classify_transport(
555+
false,
556+
true, // reqwest really does set is_connect for this
557+
"client error (Connect): received fatal alert: InternalError",
558+
);
559+
assert!(class.starts_with("TLS failed"), "got: {class}");
560+
}
561+
562+
/// DNS failures are also CONNECT errors; the specific class must win.
563+
#[test]
564+
fn a_dns_failure_is_named_dns_not_connect() {
565+
let class = classify_transport(
566+
false,
567+
true,
568+
"client error (Connect): dns error: failed to lookup address information",
569+
);
570+
assert!(class.contains("could not be resolved"), "got: {class}");
571+
}
572+
573+
#[test]
574+
fn a_refused_connection_is_the_connect_class() {
575+
let class = classify_transport(
576+
false,
577+
true,
578+
"client error (Connect): tcp connect error: Connection refused (os error 61)",
579+
);
580+
assert!(class.starts_with("could not connect"), "got: {class}");
581+
}
582+
583+
/// A timeout outranks everything: it is the one class reqwest states
584+
/// outright rather than leaving to the chain's wording.
585+
#[test]
586+
fn a_timeout_wins_over_every_chain_hint() {
587+
let class = classify_transport(true, true, "dns error: something tls certificate");
588+
assert_eq!(class, "timed out");
589+
}
590+
591+
#[test]
592+
fn an_unrecognised_chain_degrades_without_claiming_a_cause() {
593+
let class = classify_transport(false, false, "body error: incomplete message");
594+
assert_eq!(class, "the request did not complete");
595+
}
596+
}

0 commit comments

Comments
 (0)