Skip to content

Commit 0c7fd33

Browse files
faysouwboayue
andauthored
fix(connection): retry failed session establishment (#761)
* fix(connection): retry failed session establishment - Retry when TCP reconnects before the TWS session is ready - Cover sync and async paths with transient handshake tests * fix(connection): surface last reconnect error and add changelog entry Exhausted reconnect attempts now return the final attempt's error instead of a bare Error::ConnectionFailed, so a permanent cause (e.g. incompatible server version) is named. Dispatchers still map any reconnect failure to the ConnectionFailed sentinel, so loop-exit behavior is unchanged. --------- Co-authored-by: Wil Boayue <wil.boayue@gmail.com>
1 parent 797bcd2 commit 0c7fd33

6 files changed

Lines changed: 93 additions & 16 deletions

File tree

CHANGELOG.md

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

5353
- `OrderBuilder::analyze()` (what-if orders, blocking and async) now returns the TWS rejection instead of `Error::UnexpectedEndOfStream`. A rejected what-if order arrives as a routed error, which the response read discarded — the blocking path via `if let Ok(..)` inside the loop, the async path by ending its `while let Some(Ok(..))` loop — so the caller lost the reason (e.g. code 201, `Order rejected - reason:...`) and got a generic end-of-stream error. Rejection is a routine outcome for a what-if order, so this was the likeliest path to hit it (#735).
5454

55+
- Reconnection now survives the window where TWS accepts the TCP connection but its API handshake is not yet ready — common during an automated restart. A session-establishment failure (handshake, `startAPI`, account info) used to abort the reconnect loop on the first occurrence; it now consumes one attempt and follows the same fibonacci backoff as a socket failure, on both the blocking and async clients. When every attempt fails, `reconnect` returns the last attempt's error instead of a bare `Error::ConnectionFailed`, so a permanent cause — say, an incompatible server version — is named rather than hidden behind a generic failure (#761).
56+
5557
- `matching_symbols()` on the blocking client now returns the TWS error instead of an empty list. A routed error arrives as `Some(Err(_))`, which the `if let Some(Ok(_))` read discarded, so a rejected pattern silently returned `Ok(vec![])` — indistinguishable from "no symbols matched". The async client already propagated it (#735).
5658

5759
- `TickTypes::MarketDataType` now reaches `Client::market_data` subscriptions. The message type was missing from the request-id routing allow-list, so TWS's market-data-type notifications (real-time / frozen / delayed / delayed-frozen, sent on subscribe and whenever the feed switches) were routed to a shared channel nobody subscribes to and dropped. The decoder has produced the variant since #516; nothing could ever yield it (#730).

src/connection/async.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,12 @@ impl<S: AsyncStream> AsyncConnection<S> {
128128
}
129129

130130
/// Reconnect to TWS with fibonacci backoff. Replays the handshake and
131-
/// re-fires the persisted startup / notice callbacks.
131+
/// re-fires the persisted startup / notice callbacks. When every attempt
132+
/// fails, returns the last attempt's error — a permanent cause (say, an
133+
/// incompatible server version) must not exit as a generic failure.
132134
pub async fn reconnect(&self) -> Result<(), Error> {
133135
let mut backoff = FibonacciBackoff::new(30);
136+
let mut last_error = None;
134137

135138
for i in 0..MAX_RECONNECT_ATTEMPTS {
136139
let next_delay = backoff.next_delay();
@@ -140,18 +143,30 @@ impl<S: AsyncStream> AsyncConnection<S> {
140143

141144
match self.socket.reconnect().await {
142145
Ok(_) => {
143-
info!("reconnected !!!");
144146
self.reset_connection_metadata().await;
145-
self.establish_connection().await?;
146-
return Ok(());
147+
match self.establish_connection().await {
148+
Ok(()) => {
149+
info!("reconnected");
150+
return Ok(());
151+
}
152+
Err(e) => {
153+
info!(
154+
"reconnection attempt {}/{} failed while establishing session: {e}",
155+
i + 1,
156+
MAX_RECONNECT_ATTEMPTS
157+
);
158+
last_error = Some(e);
159+
}
160+
}
147161
}
148162
Err(e) => {
149163
info!("reconnection attempt {}/{} failed: {e}", i + 1, MAX_RECONNECT_ATTEMPTS);
164+
last_error = Some(e);
150165
}
151166
}
152167
}
153168

154-
Err(Error::ConnectionFailed)
169+
Err(last_error.unwrap_or(Error::ConnectionFailed))
155170
}
156171

157172
async fn reset_connection_metadata(&self) {

src/connection/async_tests.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,33 @@ async fn reconnect_succeeds_after_transient_failures() {
210210
assert_eq!(connection.server_version(), SERVER_VERSION);
211211
}
212212

213+
#[tokio::test]
214+
async fn reconnect_retries_after_transient_handshake_failure() {
215+
let stream = MemoryStream::default();
216+
let connection = AsyncConnection::stubbed(stream.clone(), CLIENT_ID);
217+
218+
push_handshake(&stream);
219+
connection.establish_connection().await.expect("initial establish_connection failed");
220+
221+
let too_old = server_versions::PROTOBUF_REST_MESSAGES_3 - 1;
222+
stream.push_inbound(format!("{}\020240120 12:00:00 EST\0", too_old).into_bytes());
223+
push_handshake(&stream);
224+
225+
connection.reconnect().await.expect("reconnect must retry a failed handshake");
226+
227+
assert_eq!(connection.server_version(), SERVER_VERSION);
228+
let metadata = connection.connection_metadata().await;
229+
assert_eq!(metadata.next_order_id, 90);
230+
assert_eq!(metadata.managed_accounts, "DU1234567");
231+
}
232+
213233
/// When the socket refuses reconnects through every Fibonacci attempt, the
214-
/// loop exits with `Error::ConnectionFailed`. Pre-arming with exactly
234+
/// loop exits with the *last attempt's* error — not a generic
235+
/// `Error::ConnectionFailed` that hides the cause. Pre-arming with exactly
215236
/// `MAX_RECONNECT_ATTEMPTS` failures binds the test to the loop's exit
216237
/// condition (rather than a hardcoded count).
217238
#[tokio::test]
218-
async fn reconnect_returns_connection_failed_after_exhausting_attempts() {
239+
async fn reconnect_returns_last_error_after_exhausting_attempts() {
219240
let stream = MemoryStream::default();
220241
let connection = AsyncConnection::stubbed(stream.clone(), CLIENT_ID);
221242

@@ -225,7 +246,10 @@ async fn reconnect_returns_connection_failed_after_exhausting_attempts() {
225246
stream.set_reconnect_failures(MAX_RECONNECT_ATTEMPTS as usize);
226247

227248
let err = connection.reconnect().await.expect_err("must give up after MAX_RECONNECT_ATTEMPTS");
228-
assert!(matches!(err, crate::errors::Error::ConnectionFailed), "got {err:?}");
249+
assert!(
250+
matches!(&err, crate::errors::Error::Simple(msg) if msg == "simulated reconnect failure"),
251+
"got {err:?}"
252+
);
229253
}
230254

231255
/// During a reconnect, any caller of `connection_metadata()` must see cleared

src/connection/sync.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,12 @@ impl<S: Stream> Connection<S> {
111111
}
112112

113113
/// Reconnect to TWS with fibonacci backoff. Replays the handshake and
114-
/// re-fires the persisted startup / notice callbacks.
114+
/// re-fires the persisted startup / notice callbacks. When every attempt
115+
/// fails, returns the last attempt's error — a permanent cause (say, an
116+
/// incompatible server version) must not exit as a generic failure.
115117
pub fn reconnect(&self) -> Result<(), Error> {
116118
let mut backoff = FibonacciBackoff::new(30);
119+
let mut last_error = None;
117120

118121
for i in 0..self.max_retries {
119122
let next_delay = backoff.next_delay();
@@ -123,19 +126,30 @@ impl<S: Stream> Connection<S> {
123126

124127
match self.socket.reconnect() {
125128
Ok(_) => {
126-
info!("reconnected !!!");
127129
self.reset_connection_metadata();
128-
self.establish_connection()?;
129-
130-
return Ok(());
130+
match self.establish_connection() {
131+
Ok(()) => {
132+
info!("reconnected");
133+
return Ok(());
134+
}
135+
Err(e) => {
136+
info!(
137+
"reconnection attempt {}/{} failed while establishing session: {e}",
138+
i + 1,
139+
self.max_retries
140+
);
141+
last_error = Some(e);
142+
}
143+
}
131144
}
132145
Err(e) => {
133146
info!("reconnection attempt {}/{} failed: {e}", i + 1, self.max_retries);
147+
last_error = Some(e);
134148
}
135149
}
136150
}
137151

138-
Err(Error::ConnectionFailed)
152+
Err(last_error.unwrap_or(Error::ConnectionFailed))
139153
}
140154

141155
fn reset_connection_metadata(&self) {

src/connection/sync_tests.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ fn establish_connection_populates_metadata() {
7777
assert_eq!(metadata.time_zone, Some(timezones::db::EST));
7878
}
7979

80+
#[test]
81+
fn reconnect_retries_after_transient_handshake_failure() {
82+
let stream = MemoryStream::default();
83+
let connection = Connection::stubbed(stream.clone(), CLIENT_ID);
84+
85+
push_handshake(&stream);
86+
connection.establish_connection().expect("initial establish_connection failed");
87+
88+
let too_old = server_versions::PROTOBUF_REST_MESSAGES_3 - 1;
89+
stream.push_inbound(format!("{}\020240120 12:00:00 EST\0", too_old).into_bytes());
90+
push_handshake(&stream);
91+
92+
connection.reconnect().expect("reconnect must retry a failed handshake");
93+
94+
assert_eq!(connection.server_version(), SERVER_VERSION);
95+
let metadata = connection.connection_metadata();
96+
assert_eq!(metadata.next_order_id, 90);
97+
assert_eq!(metadata.managed_accounts, "DU1234567");
98+
}
99+
80100
#[test]
81101
fn disconnect_completes() {
82102
let (client, stream) = make_client();

src/transport/sync_tests.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,11 @@ fn test_reconnect_failed() -> Result<(), Error> {
401401

402402
let _ = connection.read_message();
403403

404+
// Exhausted attempts surface the last attempt's error, not a generic
405+
// `Error::ConnectionFailed`.
404406
match connection.reconnect() {
405-
Err(Error::ConnectionFailed) => Ok(()),
406-
_ => panic!(""),
407+
Err(Error::Io(e)) if e.kind() == ErrorKind::ConnectionRefused => Ok(()),
408+
other => panic!("expected the last reconnect error, got {other:?}"),
407409
}
408410
}
409411

0 commit comments

Comments
 (0)