@@ -8,6 +8,88 @@ use reqwest::Response;
88use serde_json:: Value ;
99use vtcode_commons:: sanitizer:: sanitize_provider_diagnostic;
1010
11+ /// Stable classification for failures reported by reqwest.
12+ ///
13+ /// The value is persisted in [`LLMErrorMetadata::code`] so callers can inspect
14+ /// the transport failure without parsing reqwest's display text.
15+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
16+ pub ( crate ) enum ReqwestErrorKind {
17+ Connect ,
18+ Timeout ,
19+ Request ,
20+ Body ,
21+ Decode ,
22+ Redirect ,
23+ Status ,
24+ Unknown ,
25+ }
26+
27+ impl ReqwestErrorKind {
28+ const fn metadata_code ( self ) -> & ' static str {
29+ match self {
30+ Self :: Connect => "reqwest_connect_error" ,
31+ Self :: Timeout => "reqwest_timeout_error" ,
32+ Self :: Request => "reqwest_request_error" ,
33+ Self :: Body => "reqwest_body_error" ,
34+ Self :: Decode => "reqwest_decode_error" ,
35+ Self :: Redirect => "reqwest_redirect_error" ,
36+ Self :: Status => "reqwest_status_error" ,
37+ Self :: Unknown => "reqwest_unknown_error" ,
38+ }
39+ }
40+ }
41+
42+ #[ derive( Debug , Clone , Copy , Default ) ]
43+ struct ReqwestErrorFlags {
44+ is_connect : bool ,
45+ is_timeout : bool ,
46+ is_request : bool ,
47+ is_body : bool ,
48+ is_decode : bool ,
49+ is_redirect : bool ,
50+ is_status : bool ,
51+ }
52+
53+ impl From < & reqwest:: Error > for ReqwestErrorFlags {
54+ fn from ( error : & reqwest:: Error ) -> Self {
55+ Self {
56+ is_connect : error. is_connect ( ) ,
57+ is_timeout : error. is_timeout ( ) ,
58+ is_request : error. is_request ( ) ,
59+ is_body : error. is_body ( ) ,
60+ is_decode : error. is_decode ( ) ,
61+ is_redirect : error. is_redirect ( ) ,
62+ is_status : error. is_status ( ) ,
63+ }
64+ }
65+ }
66+
67+ const fn classify_reqwest_flags ( flags : ReqwestErrorFlags ) -> ReqwestErrorKind {
68+ // Some reqwest errors occupy more than one category. Prefer the most
69+ // actionable classification over the generic request/body buckets.
70+ if flags. is_timeout {
71+ ReqwestErrorKind :: Timeout
72+ } else if flags. is_connect {
73+ ReqwestErrorKind :: Connect
74+ } else if flags. is_decode {
75+ ReqwestErrorKind :: Decode
76+ } else if flags. is_redirect {
77+ ReqwestErrorKind :: Redirect
78+ } else if flags. is_status {
79+ ReqwestErrorKind :: Status
80+ } else if flags. is_body {
81+ ReqwestErrorKind :: Body
82+ } else if flags. is_request {
83+ ReqwestErrorKind :: Request
84+ } else {
85+ ReqwestErrorKind :: Unknown
86+ }
87+ }
88+
89+ pub ( crate ) fn classify_reqwest_error ( error : & reqwest:: Error ) -> ReqwestErrorKind {
90+ classify_reqwest_flags ( error. into ( ) )
91+ }
92+
1193#[ derive( Debug , Clone , Default ) ]
1294struct ApiResponseMetadata {
1395 request_id : Option < String > ,
@@ -111,6 +193,54 @@ pub(crate) fn format_network_error(provider: &str, error: &impl std::fmt::Displa
111193 LLMError :: Network { message : formatted_error, metadata : None }
112194}
113195
196+ /// Formats a reqwest transport failure while preserving its structured
197+ /// classification and source chain in canonical LLM error metadata.
198+ #[ cold]
199+ pub ( crate ) fn format_reqwest_network_error ( provider : & str , error : & reqwest:: Error ) -> LLMError {
200+ let kind = classify_reqwest_error ( error) ;
201+ let formatted_error = error_display:: format_llm_error ( provider, & format ! ( "network error: {error}" ) ) ;
202+ let source_chain = reqwest_source_chain ( error) ;
203+ LLMError :: Network {
204+ message : formatted_error,
205+ metadata : Some ( LLMErrorMetadata :: new (
206+ provider,
207+ error. status ( ) . map ( |status| status. as_u16 ( ) ) ,
208+ Some ( kind. metadata_code ( ) . to_owned ( ) ) ,
209+ None ,
210+ None ,
211+ None ,
212+ source_chain,
213+ ) ) ,
214+ }
215+ }
216+
217+ fn reqwest_source_chain ( error : & reqwest:: Error ) -> Option < String > {
218+ use std:: error:: Error as _;
219+
220+ const MAX_SOURCE_DEPTH : usize = 8 ;
221+
222+ let mut current = error. source ( ) ;
223+ let mut depth = 0 ;
224+ let mut diagnostic = String :: new ( ) ;
225+ while let Some ( source) = current {
226+ if !diagnostic. is_empty ( ) {
227+ diagnostic. push_str ( ": " ) ;
228+ }
229+ diagnostic. push_str ( & source. to_string ( ) ) ;
230+ current = source. source ( ) ;
231+ depth += 1 ;
232+ if depth == MAX_SOURCE_DEPTH {
233+ break ;
234+ }
235+ }
236+
237+ if diagnostic. is_empty ( ) {
238+ None
239+ } else {
240+ Some ( sanitize_provider_diagnostic ( diagnostic. as_bytes ( ) ) )
241+ }
242+ }
243+
114244/// Handle JSON parsing errors with consistent formatting
115245#[ cold]
116246pub ( crate ) fn format_parse_error ( provider : & str , error : & impl std:: fmt:: Display ) -> LLMError {
@@ -333,6 +463,87 @@ fn extract_response_metadata(response: &Response) -> ApiResponseMetadata {
333463mod tests {
334464 use super :: * ;
335465
466+ #[ test]
467+ fn reqwest_error_classification_covers_transport_categories ( ) {
468+ let cases = [
469+ ( ReqwestErrorFlags { is_connect : true , ..Default :: default ( ) } , ReqwestErrorKind :: Connect ) ,
470+ ( ReqwestErrorFlags { is_timeout : true , ..Default :: default ( ) } , ReqwestErrorKind :: Timeout ) ,
471+ ( ReqwestErrorFlags { is_request : true , ..Default :: default ( ) } , ReqwestErrorKind :: Request ) ,
472+ ( ReqwestErrorFlags { is_body : true , ..Default :: default ( ) } , ReqwestErrorKind :: Body ) ,
473+ ( ReqwestErrorFlags { is_decode : true , ..Default :: default ( ) } , ReqwestErrorKind :: Decode ) ,
474+ ( ReqwestErrorFlags { is_redirect : true , ..Default :: default ( ) } , ReqwestErrorKind :: Redirect ) ,
475+ ( ReqwestErrorFlags { is_status : true , ..Default :: default ( ) } , ReqwestErrorKind :: Status ) ,
476+ ( ReqwestErrorFlags :: default ( ) , ReqwestErrorKind :: Unknown ) ,
477+ ] ;
478+
479+ for ( flags, expected) in cases {
480+ assert_eq ! ( classify_reqwest_flags( flags) , expected) ;
481+ }
482+ }
483+
484+ #[ test]
485+ fn reqwest_error_classification_prefers_specific_categories ( ) {
486+ let request_timeout = ReqwestErrorFlags {
487+ is_timeout : true ,
488+ is_request : true ,
489+ ..Default :: default ( )
490+ } ;
491+ let request_connect = ReqwestErrorFlags {
492+ is_connect : true ,
493+ is_request : true ,
494+ ..Default :: default ( )
495+ } ;
496+ let body_decode = ReqwestErrorFlags {
497+ is_body : true ,
498+ is_decode : true ,
499+ ..Default :: default ( )
500+ } ;
501+
502+ assert_eq ! ( classify_reqwest_flags( request_timeout) , ReqwestErrorKind :: Timeout ) ;
503+ assert_eq ! ( classify_reqwest_flags( request_connect) , ReqwestErrorKind :: Connect ) ;
504+ assert_eq ! ( classify_reqwest_flags( body_decode) , ReqwestErrorKind :: Decode ) ;
505+ }
506+
507+ #[ test]
508+ fn reqwest_error_metadata_codes_are_stable ( ) {
509+ assert_eq ! ( ReqwestErrorKind :: Connect . metadata_code( ) , "reqwest_connect_error" ) ;
510+ assert_eq ! ( ReqwestErrorKind :: Timeout . metadata_code( ) , "reqwest_timeout_error" ) ;
511+ assert_eq ! ( ReqwestErrorKind :: Request . metadata_code( ) , "reqwest_request_error" ) ;
512+ assert_eq ! ( ReqwestErrorKind :: Body . metadata_code( ) , "reqwest_body_error" ) ;
513+ assert_eq ! ( ReqwestErrorKind :: Decode . metadata_code( ) , "reqwest_decode_error" ) ;
514+ assert_eq ! ( ReqwestErrorKind :: Redirect . metadata_code( ) , "reqwest_redirect_error" ) ;
515+ assert_eq ! ( ReqwestErrorKind :: Status . metadata_code( ) , "reqwest_status_error" ) ;
516+ assert_eq ! ( ReqwestErrorKind :: Unknown . metadata_code( ) , "reqwest_unknown_error" ) ;
517+ }
518+
519+ #[ tokio:: test]
520+ async fn reqwest_redirect_error_is_preserved_in_llm_metadata ( ) {
521+ use wiremock:: { Mock , MockServer , ResponseTemplate , matchers:: path} ;
522+
523+ let server = MockServer :: start ( ) . await ;
524+ Mock :: given ( path ( "/loop" ) )
525+ . respond_with ( ResponseTemplate :: new ( 302 ) . insert_header ( "location" , format ! ( "{}/loop" , server. uri( ) ) ) )
526+ . mount ( & server)
527+ . await ;
528+ let client = reqwest:: Client :: builder ( )
529+ . redirect ( reqwest:: redirect:: Policy :: limited ( 1 ) )
530+ . build ( )
531+ . expect ( "redirect-limited test client should build" ) ;
532+ let error = client
533+ . post ( format ! ( "{}/loop" , server. uri( ) ) )
534+ . send ( )
535+ . await
536+ . expect_err ( "redirect loop should exceed the configured limit" ) ;
537+
538+ let llm_error = format_reqwest_network_error ( "Test provider" , & error) ;
539+ match llm_error {
540+ LLMError :: Network { metadata, .. } => {
541+ assert_eq ! ( metadata. as_ref( ) . and_then( |value| value. code. as_deref( ) ) , Some ( "reqwest_redirect_error" ) ) ;
542+ }
543+ other => panic ! ( "expected a network error, got {other:?}" ) ,
544+ }
545+ }
546+
336547 #[ test]
337548 fn test_rate_limit_detection ( ) {
338549 assert ! ( is_rate_limit_error( 429 , "" ) ) ;
0 commit comments