Skip to content

Commit 2fbca96

Browse files
committed
fix: retry pre-response connection resets on all methods
Pre-response transport errors (a stale keep-alive socket reset before the request reaches the server) are now retried transparently on any method, including POST, on the same RetryPolicy budget as HTTP 429. Covers every generated op via execute_retrying and the hand-written query/submit paths. Response-phase errors stay un-retried so a non-idempotent POST can't double-execute (#63).
1 parent bd6cd72 commit 2fbca96

3 files changed

Lines changed: 382 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Pre-response connection errors are now retried transparently on **any** method,
13+
including `POST`. A pooled keep-alive socket that an intermediary closed on its
14+
idle timeout surfaces, on the next reuse, as a connection reset before the
15+
request reaches the server; since the server did no work, the retry can't
16+
double-execute. This covers every generated op (via `execute_retrying`) and the
17+
hand-written `Client::query` / `Client::submit_query` paths, governed by the
18+
same `RetryPolicy` budget as 429. Response-phase transport errors are left
19+
un-retried so a non-idempotent `POST` can't double-execute (#63).
20+
1021

1122
## [0.3.1] - 2026-06-16
1223

src/http.rs

Lines changed: 237 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
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;
2426
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
2527

2628
use 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.
3234
const 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.
45103
pub(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> {
132208
mod 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\ncontent-type: application/json\r\n\
290+
content-length: {}\r\nconnection: 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

Comments
 (0)