@@ -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