2121//! This module is hand-written and listed in `.openapi-generator-ignore`, so it
2222//! survives client regeneration.
2323
24+ use std:: error:: Error as StdError ;
25+ use std:: io;
2426use std:: time:: { Duration , Instant , SystemTime , UNIX_EPOCH } ;
2527
2628use reqwest:: StatusCode ;
@@ -31,17 +33,73 @@ use crate::query::RetryPolicy;
3133/// the status code since 429 is unambiguous and the body is not always parsed.
3234const HTTP_TOO_MANY_REQUESTS : StatusCode = StatusCode :: TOO_MANY_REQUESTS ;
3335
34- /// Execute `req`, retrying on HTTP 429 (OVERLOADED admission-shedding) per
35- /// `retry`: honor `Retry-After` when present, else bounded exponential backoff
36- /// with jitter. Retries stop at `retry.max_retries` OR once the overall
37- /// `retry.deadline` budget would be exceeded — whichever comes first. The
38- /// request is cloned per attempt; a non-clonable (streaming) body degrades to a
39- /// single attempt.
36+ /// Classify a [`reqwest::Error`] as a **pre-response connection error** — a
37+ /// transport failure that happened *before any response bytes were received*, so
38+ /// the server did no work and a retry cannot double-execute. Safe to retry on
39+ /// **any** method, including `POST` (cf. hotdata-dev/sdk-rust#63,
40+ /// hotdata-dev/sdk-python#118).
41+ ///
42+ /// Two classes qualify:
43+ ///
44+ /// * **Connect-phase failures** ([`reqwest::Error::is_connect`]): the connection
45+ /// was never established (DNS / TCP connect / TLS), so the request never left
46+ /// the client.
47+ /// * **Send-phase connection resets**: a pooled keep-alive socket that an
48+ /// intermediary (load balancer / reverse proxy) closed on its idle timeout
49+ /// surfaces, on the next reuse, as a `ConnectionReset` / `ConnectionAborted` /
50+ /// `BrokenPipe` `io::Error` (or an `UnexpectedEof` before the status line)
51+ /// while sending the request. The request never reached the server.
52+ ///
53+ /// Errors that imply a response was already in flight are deliberately excluded:
54+ /// [`is_body`](reqwest::Error::is_body), [`is_decode`](reqwest::Error::is_decode),
55+ /// and [`is_status`](reqwest::Error::is_status) all mean the request reached the
56+ /// server, so retrying a non-idempotent `POST` there could double-execute. Those
57+ /// stay caller-driven / idempotent-only, exactly as #63 scopes it.
58+ pub ( crate ) fn is_pre_response_transport_error ( err : & reqwest:: Error ) -> bool {
59+ // A response was (at least partially) received — not pre-response.
60+ if err. is_body ( ) || err. is_decode ( ) || err. is_status ( ) {
61+ return false ;
62+ }
63+ // Connection establishment failed: the request never left the client.
64+ if err. is_connect ( ) {
65+ return true ;
66+ }
67+ // Otherwise look for a connection-level I/O error in the source chain. A
68+ // stale pooled socket reset on reuse lands here (kind ConnectionReset on the
69+ // request send), distinct from a connect-phase failure.
70+ let mut source: Option < & ( dyn StdError + ' static ) > = err. source ( ) ;
71+ while let Some ( e) = source {
72+ if let Some ( io_err) = e. downcast_ref :: < io:: Error > ( ) {
73+ return matches ! (
74+ io_err. kind( ) ,
75+ io:: ErrorKind :: ConnectionReset
76+ | io:: ErrorKind :: ConnectionAborted
77+ | io:: ErrorKind :: BrokenPipe
78+ | io:: ErrorKind :: UnexpectedEof
79+ ) ;
80+ }
81+ source = e. source ( ) ;
82+ }
83+ false
84+ }
85+
86+ /// Execute `req`, retrying on HTTP 429 (OVERLOADED admission-shedding) **and on
87+ /// pre-response connection errors** (stale keep-alive resets — see
88+ /// [`is_pre_response_transport_error`]) per `retry`: honor `Retry-After` when
89+ /// present (429 only), else bounded exponential backoff with jitter. Retries
90+ /// stop at `retry.max_retries` OR once the overall `retry.deadline` budget would
91+ /// be exceeded — whichever comes first. The request is cloned per attempt; a
92+ /// non-clonable (streaming) body degrades to a single attempt.
93+ ///
94+ /// A pre-response connection error is safe to retry on any method (the request
95+ /// never reached the server); response-phase transport errors are *not* retried
96+ /// here, so a non-idempotent `POST` can't double-execute.
4097///
4198/// When the budget or retry count is exhausted the last response (the 429) is
42- /// returned so the op's normal error mapping surfaces it to the caller — no new
43- /// error type. This mirrors `crate::query::submit_with_retry`, which enforces
44- /// the same `deadline` on the hand-written query path, so the two stay aligned.
99+ /// returned, or the last transport error is propagated, so the op's normal error
100+ /// mapping surfaces it to the caller — no new error type. This mirrors
101+ /// `crate::query::submit_with_retry`, which enforces the same `deadline` on the
102+ /// hand-written query path, so the two stay aligned.
45103pub ( crate ) async fn execute_retrying (
46104 client : & reqwest:: Client ,
47105 req : reqwest:: Request ,
@@ -50,12 +108,30 @@ pub(crate) async fn execute_retrying(
50108 let start = Instant :: now ( ) ;
51109 // attempt 0 is the initial request; 1..=max_retries are the retries.
52110 for attempt in 0 ..=retry. max_retries {
53- // Clone the request before consuming it so a 429 can be retried. A
54- // streaming body can't be cloned (`None`) — send it once with no retry.
111+ // Clone the request before consuming it so a 429 or a pre-response reset
112+ // can be retried. A streaming body can't be cloned (`None`) — send it
113+ // once with no retry.
55114 let Some ( clone) = req. try_clone ( ) else {
56115 return client. execute ( req) . await ;
57116 } ;
58- let resp = client. execute ( clone) . await ?;
117+ let resp = match client. execute ( clone) . await {
118+ Ok ( resp) => resp,
119+ Err ( e) => {
120+ // Pre-response connection reset (e.g. a stale pooled keep-alive
121+ // socket) with attempts remaining and budget left: retry on a
122+ // fresh connection. Anything else (or budget/count exhausted)
123+ // propagates unchanged.
124+ if attempt == retry. max_retries || !is_pre_response_transport_error ( & e) {
125+ return Err ( e) ;
126+ }
127+ let delay = backoff_delay ( retry, attempt + 1 , None ) ;
128+ if start. elapsed ( ) + delay > retry. deadline {
129+ return Err ( e) ;
130+ }
131+ tokio:: time:: sleep ( delay) . await ;
132+ continue ;
133+ }
134+ } ;
59135 if resp. status ( ) != HTTP_TOO_MANY_REQUESTS || attempt == retry. max_retries {
60136 return Ok ( resp) ;
61137 }
@@ -132,9 +208,98 @@ pub(crate) fn retry_after_secs(value: &str) -> Option<Duration> {
132208mod tests {
133209 use super :: * ;
134210 use serde_json:: json;
211+ #[ cfg( unix) ]
212+ use std:: io:: { Read , Write } ;
213+ #[ cfg( unix) ]
214+ use std:: net:: TcpListener ;
215+ #[ cfg( unix) ]
216+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
217+ #[ cfg( unix) ]
218+ use std:: sync:: Arc ;
135219 use wiremock:: matchers:: { method, path} ;
136220 use wiremock:: { Mock , MockServer , ResponseTemplate } ;
137221
222+ /// Force a TCP RST on close by setting `SO_LINGER` to 0. `std`'s
223+ /// `TcpStream::set_linger` is still unstable, so go through `setsockopt`
224+ /// directly (test-only, `unix`-only). A reset, not a graceful FIN, is the
225+ /// stale keep-alive symptom #63 targets (hyper surfaces it as a
226+ /// `ConnectionReset` `io::Error`, distinct from an `IncompleteMessage`).
227+ #[ cfg( unix) ]
228+ fn force_rst_on_close ( fd : i32 ) {
229+ #[ repr( C ) ]
230+ struct Linger {
231+ l_onoff : i32 ,
232+ l_linger : i32 ,
233+ }
234+ extern "C" {
235+ fn setsockopt (
236+ s : i32 ,
237+ level : i32 ,
238+ name : i32 ,
239+ val : * const core:: ffi:: c_void ,
240+ len : u32 ,
241+ ) -> i32 ;
242+ }
243+ #[ cfg( target_os = "linux" ) ]
244+ let ( sol_socket, so_linger) = ( 1i32 , 13i32 ) ;
245+ #[ cfg( not( target_os = "linux" ) ) ]
246+ let ( sol_socket, so_linger) = ( 0xffffi32 , 0x0080i32 ) ; // macOS / BSD
247+ let l = Linger {
248+ l_onoff : 1 ,
249+ l_linger : 0 ,
250+ } ;
251+ unsafe {
252+ setsockopt (
253+ fd,
254+ sol_socket,
255+ so_linger,
256+ & l as * const _ as * const core:: ffi:: c_void ,
257+ std:: mem:: size_of :: < Linger > ( ) as u32 ,
258+ ) ;
259+ }
260+ }
261+
262+ /// Spawn a bare TCP server that resets the first `reset_count` connections
263+ /// before any response (forcing a `ConnectionReset` via `SO_LINGER` 0 — the
264+ /// stale keep-alive symptom from #63), then answers `200 OK` with a tiny
265+ /// JSON body. Returns the base URL and a counter of accepted connections so
266+ /// a test can assert how many attempts reached the wire.
267+ #[ cfg( unix) ]
268+ fn reset_then_ok_server ( reset_count : usize ) -> ( String , Arc < AtomicUsize > ) {
269+ use std:: os:: unix:: io:: AsRawFd ;
270+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . expect ( "bind ephemeral port" ) ;
271+ let addr = listener. local_addr ( ) . expect ( "local addr" ) ;
272+ let conns = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
273+ let counter = Arc :: clone ( & conns) ;
274+ std:: thread:: spawn ( move || {
275+ let mut i = 0usize ;
276+ for stream in listener. incoming ( ) {
277+ let Ok ( mut s) = stream else { continue } ;
278+ counter. fetch_add ( 1 , Ordering :: SeqCst ) ;
279+ // Drain the client's request bytes so it finishes writing before
280+ // we act (otherwise the RST can race the request send).
281+ let mut buf = [ 0u8 ; 4096 ] ;
282+ let _ = s. read ( & mut buf) ;
283+ if i < reset_count {
284+ force_rst_on_close ( s. as_raw_fd ( ) ) ;
285+ drop ( s) ;
286+ } else {
287+ let body = br#"{"ok":true}"# ;
288+ let head = format ! (
289+ "HTTP/1.1 200 OK\r \n content-type: application/json\r \n \
290+ content-length: {}\r \n connection: close\r \n \r \n ",
291+ body. len( )
292+ ) ;
293+ let _ = s. write_all ( head. as_bytes ( ) ) ;
294+ let _ = s. write_all ( body) ;
295+ let _ = s. flush ( ) ;
296+ }
297+ i += 1 ;
298+ }
299+ } ) ;
300+ ( format ! ( "http://{addr}" ) , conns)
301+ }
302+
138303 /// A fast, deterministic retry policy: tiny backoffs, no jitter.
139304 fn fast_retry ( max_retries : u32 ) -> RetryPolicy {
140305 RetryPolicy {
@@ -248,10 +413,69 @@ mod tests {
248413 assert_eq ! ( server. received_requests( ) . await . unwrap( ) . len( ) , 1 ) ;
249414 }
250415
416+ #[ cfg( unix) ]
417+ #[ tokio:: test]
418+ async fn retries_pre_response_reset_then_succeeds ( ) {
419+ // First connection is reset before any response (stale keep-alive
420+ // symptom); the retry on a fresh connection gets a 200. A POST must be
421+ // retried here — the request never reached the server.
422+ let ( base, conns) = reset_then_ok_server ( 1 ) ;
423+ let client = reqwest:: Client :: new ( ) ;
424+ let req = client
425+ . post ( format ! ( "{base}/thing" ) )
426+ . json ( & json ! ( { "k" : "v" } ) )
427+ . build ( )
428+ . expect ( "request should build" ) ;
429+ let resp = execute_retrying ( & client, req, & fast_retry ( 5 ) )
430+ . await
431+ . expect ( "pre-response reset should be retried, then succeed" ) ;
432+ assert_eq ! ( resp. status( ) , StatusCode :: OK ) ;
433+ // 1 reset + 1 success = 2 connections reached the wire.
434+ assert_eq ! ( conns. load( Ordering :: SeqCst ) , 2 ) ;
435+ }
436+
437+ #[ cfg( unix) ]
438+ #[ tokio:: test]
439+ async fn pre_response_reset_propagates_after_max_retries ( ) {
440+ // Every connection is reset: retries are exhausted and the transport
441+ // error propagates (no new error type, mirroring the 429 path).
442+ let ( base, conns) = reset_then_ok_server ( usize:: MAX ) ;
443+ let client = reqwest:: Client :: new ( ) ;
444+ let req = client
445+ . post ( format ! ( "{base}/thing" ) )
446+ . json ( & json ! ( { "k" : "v" } ) )
447+ . build ( )
448+ . expect ( "request should build" ) ;
449+ let err = execute_retrying ( & client, req, & fast_retry ( 2 ) )
450+ . await
451+ . expect_err ( "persistent reset should propagate after retries" ) ;
452+ assert ! ( is_pre_response_transport_error( & err) ) ;
453+ // 1 initial + 2 retries = 3 connections, all reset.
454+ assert_eq ! ( conns. load( Ordering :: SeqCst ) , 3 ) ;
455+ }
456+
457+ #[ tokio:: test]
458+ async fn connect_failure_is_pre_response ( ) {
459+ // A refused connection (nothing listening) never reaches the server, so
460+ // it classifies as a pre-response error retryable on any method.
461+ let client = reqwest:: Client :: new ( ) ;
462+ let err = client
463+ . post ( "http://127.0.0.1:1/thing" )
464+ . json ( & json ! ( { "k" : "v" } ) )
465+ . send ( )
466+ . await
467+ . expect_err ( "connect to port 1 should fail" ) ;
468+ assert ! ( err. is_connect( ) ) ;
469+ assert ! ( is_pre_response_transport_error( & err) ) ;
470+ }
471+
251472 #[ test]
252473 fn retry_after_secs_parses_and_rejects_malformed ( ) {
253474 assert_eq ! ( retry_after_secs( "2" ) , Some ( Duration :: from_secs( 2 ) ) ) ;
254- assert_eq ! ( retry_after_secs( " 1.5 " ) , Some ( Duration :: from_secs_f64( 1.5 ) ) ) ;
475+ assert_eq ! (
476+ retry_after_secs( " 1.5 " ) ,
477+ Some ( Duration :: from_secs_f64( 1.5 ) )
478+ ) ;
255479 assert_eq ! ( retry_after_secs( "0" ) , Some ( Duration :: ZERO ) ) ;
256480 // Malformed / hostile values must degrade to None, never panic.
257481 assert_eq ! ( retry_after_secs( "inf" ) , None ) ;
0 commit comments