Skip to content

Commit 5d6ddbf

Browse files
authored
Add Error::is_connection_lost() predicate for reconnect loops (#685) (#690)
* Add Error::is_connection_lost() predicate for reconnect loops (#685) Public predicate so reconnect consumers branch on connection loss without matching internal Error variants. Matches ConnectionReset/ConnectionFailed and connection-kind Io errors; excludes Shutdown (intentional teardown) and ConnectionRejected (unrecoverable handshake refusal). Internal is_connection_error() now delegates to the public method. README, stream_retry example, and api-patterns docs adopt the new idiom. * Break on terminal ConnectionFailed in stream-retry idiom is_connection_lost() also matches ConnectionFailed, which the transport returns only after reconnection permanently fails and the client shuts down. Resubscribing then loops on a dead client (panicking via .expect). Add a terminal ConnectionFailed arm before the is_connection_lost guard in the stream_retry example and the README idiom. * Scope is_connection_lost to recoverable loss; drop ConnectionFailed Distillation pass (SRP/composability): is_connection_lost lumped terminal ConnectionFailed (reconnect exhausted) in with recoverable mid-stream loss, forcing every correct consumer to de-lump it with a preceding match arm. ConnectionFailed is produced only by reconnect() exhaustion and never reaches the transport read-error branch that calls is_connection_error, so dropping it from the predicate leaves reconnect behavior unchanged. Predicate now matches ConnectionReset + connection-kind Io only; doc reworded to match. README/example drop the now-redundant terminal arm (ConnectionFailed falls through to the generic give-up arm), restoring the clean two-arm idiom. * Update is_connection_error test: ConnectionFailed is terminal The predicate now treats ConnectionFailed (reconnect exhausted) as terminal, not a recoverable mid-stream loss. Flip the stale test case expectation from true to false to match the new contract. This test lives under the client::error_handler path, which the 'cargo test errors' filter never ran.
1 parent 312eb46 commit 5d6ddbf

8 files changed

Lines changed: 120 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- `ConnectivityStatus` enum with `ConnectivityStatus::from_code()` and `Notice::connectivity_status()` to expose data-farm connectivity sub-states (Ok / Broken / Inactive / Connecting) within the 2100–2169 warning band (#684).
13+
- `Error::is_connection_lost()` predicate so reconnect loops can branch on connection loss without matching internal error variants (#685).
1314

1415
## [3.1.0] - 2026-06-19
1516

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -809,10 +809,12 @@ fn main() {
809809
match item {
810810
Ok(SubscriptionItem::Data(bar)) => println!("bar: {bar:?}"),
811811
Ok(SubscriptionItem::Notice(note)) => eprintln!("notice: {note}"),
812-
Err(Error::ConnectionReset) => {
813-
eprintln!("Connection reset. Retrying stream...");
812+
Err(e) if e.is_connection_lost() => {
813+
eprintln!("Connection lost. Retrying stream...");
814814
continue 'outer;
815815
}
816+
// Everything else — including terminal ConnectionFailed (reconnect
817+
// exhausted) — is not recoverable here, so stop.
816818
Err(e) => {
817819
eprintln!("error: {e}");
818820
break 'outer;

docs/api-patterns.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ match client.market_data(contract).subscribe() {
511511
for result in subscription {
512512
match result {
513513
Ok(data) => process(data),
514-
Err(Error::ConnectionReset) => {
514+
Err(e) if e.is_connection_lost() => {
515515
// Resubscribe after reconnection
516516
break;
517517
},

examples/sync/stream_retry.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
99
use ibapi::client::blocking::Client;
1010
use ibapi::contracts::Contract;
11-
use ibapi::{market_data::TradingHours, Error};
11+
use ibapi::market_data::TradingHours;
1212

1313
fn main() {
1414
env_logger::init();
@@ -29,10 +29,12 @@ fn main() {
2929
for bar in subscription.iter_data() {
3030
match bar {
3131
Ok(bar) => println!("bar: {bar:?}"),
32-
Err(Error::ConnectionReset) => {
33-
eprintln!("Connection reset. Retrying stream...");
32+
Err(e) if e.is_connection_lost() => {
33+
eprintln!("Connection lost. Retrying stream...");
3434
continue 'retry;
3535
}
36+
// Everything else — including terminal ConnectionFailed (reconnect
37+
// exhausted, client shut down) — is not recoverable here, so give up.
3638
Err(e) => {
3739
eprintln!("error: {e}");
3840
break 'retry;

src/client/error_handler/mod.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,12 @@ use crate::errors::Error;
1414
/// Maximum number of retries for transient errors
1515
pub(crate) const MAX_RETRIES: u32 = 3;
1616

17-
/// Checks if the error is a connection-related IO error that should trigger reconnection
17+
/// Checks if the error is a connection-related error that should trigger reconnection.
18+
///
19+
/// Delegates to the public [`Error::is_connection_lost`] predicate so transport
20+
/// reconnect logic and downstream consumers share one definition.
1821
pub(crate) fn is_connection_error(error: &Error) -> bool {
19-
match error {
20-
Error::Io(io_err) => matches!(
21-
io_err.kind(),
22-
ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted | ErrorKind::UnexpectedEof | ErrorKind::BrokenPipe
23-
),
24-
Error::ConnectionReset | Error::ConnectionFailed => true,
25-
_ => false,
26-
}
22+
error.is_connection_lost()
2723
}
2824

2925
/// Checks if the error is a timeout that can be safely ignored

src/client/error_handler/tests.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ fn test_is_connection_error() {
3737
expected: true,
3838
},
3939
TestCase {
40-
name: "connection_failed",
40+
// ConnectionFailed is returned only after reconnection is exhausted —
41+
// terminal, not a recoverable mid-stream loss — so it is not a reconnect trigger.
42+
name: "connection_failed_is_terminal",
4143
error: Error::ConnectionFailed,
42-
expected: true,
44+
expected: false,
4345
},
4446
TestCase {
4547
name: "cancelled_not_connection",

src/errors.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,54 @@ impl Error {
183183
pub(crate) fn eof_at(i: usize, label: &str) -> Error {
184184
Error::Parse(i, String::new(), format!("expected {label} and found end of message"))
185185
}
186+
187+
/// Returns `true` if this error means the TWS/Gateway connection was lost
188+
/// mid-stream and the client should reconnect, rather than retry the in-flight
189+
/// request.
190+
///
191+
/// Matches [`Error::ConnectionReset`] and connection-kind [`Error::Io`] errors
192+
/// (broken pipe, unexpected EOF, connection reset/abort) — recoverable losses
193+
/// where re-establishing the connection is the right response.
194+
///
195+
/// Returns `false` for failures reconnecting cannot recover, so a read loop can
196+
/// branch on them separately to stop retrying: intentional teardown
197+
/// ([`Error::Shutdown`]), handshake refusal ([`Error::ConnectionRejected`]), and
198+
/// exhausted reconnection ([`Error::ConnectionFailed`], returned only after the
199+
/// transport already gave up).
200+
///
201+
/// # Examples
202+
///
203+
/// In a subscription read loop, branch on this predicate to decide whether to
204+
/// re-establish the connection or surface a request-level failure:
205+
///
206+
/// ```
207+
/// use ibapi::Error;
208+
///
209+
/// fn on_stream_error(err: Error) -> Result<(), Error> {
210+
/// if err.is_connection_lost() {
211+
/// // tear down and resubscribe, then keep going
212+
/// Ok(())
213+
/// } else {
214+
/// // a request-level failure (or terminal disconnect) — surface it
215+
/// Err(err)
216+
/// }
217+
/// }
218+
///
219+
/// assert!(on_stream_error(Error::ConnectionReset).is_ok());
220+
/// assert!(on_stream_error(Error::ConnectionFailed).is_err()); // reconnect exhausted
221+
/// assert!(on_stream_error(Error::Shutdown).is_err());
222+
/// ```
223+
pub fn is_connection_lost(&self) -> bool {
224+
use std::io::ErrorKind;
225+
match self {
226+
Error::Io(io_err) => matches!(
227+
io_err.kind(),
228+
ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted | ErrorKind::UnexpectedEof | ErrorKind::BrokenPipe
229+
),
230+
Error::ConnectionReset => true,
231+
_ => false,
232+
}
233+
}
186234
}
187235

188236
// Manual Clone because `std::io::Error` and `time::error::Parse` don't derive it.

src/errors_tests.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,55 @@ fn error_is_non_exhaustive() {
261261
fn assert_non_exhaustive<T: StdError>() {}
262262
assert_non_exhaustive::<Error>();
263263
}
264+
265+
#[test]
266+
fn is_connection_lost_true_for_connection_variants() {
267+
assert!(Error::ConnectionReset.is_connection_lost());
268+
269+
for kind in [
270+
io::ErrorKind::ConnectionReset,
271+
io::ErrorKind::ConnectionAborted,
272+
io::ErrorKind::UnexpectedEof,
273+
io::ErrorKind::BrokenPipe,
274+
] {
275+
let error = Error::Io(io::Error::new(kind, "disconnected"));
276+
assert!(error.is_connection_lost(), "{kind:?} should count as connection-lost");
277+
}
278+
}
279+
280+
#[test]
281+
fn is_connection_lost_false_for_non_connection() {
282+
let cases = [
283+
Error::Shutdown,
284+
// Reconnection exhausted — terminal, not a recoverable mid-stream loss.
285+
Error::ConnectionFailed,
286+
Error::ConnectionRejected("allow-list mismatch".to_string()),
287+
Error::Cancelled,
288+
tws_error_notice(200, "No security found"),
289+
Error::Io(io::Error::new(io::ErrorKind::NotFound, "missing")),
290+
Error::Simple("boom".to_string()),
291+
Error::Parse(1, "v".to_string(), "m".to_string()),
292+
];
293+
294+
for error in cases {
295+
assert!(!error.is_connection_lost(), "{error:?} should not count as connection-lost");
296+
}
297+
}
298+
299+
#[test]
300+
fn is_connection_error_delegates_to_is_connection_lost() {
301+
use crate::client::error_handler::is_connection_error;
302+
303+
let cases = [
304+
Error::ConnectionReset,
305+
Error::ConnectionFailed,
306+
Error::Io(io::Error::new(io::ErrorKind::BrokenPipe, "x")),
307+
Error::Shutdown,
308+
Error::Cancelled,
309+
Error::Io(io::Error::new(io::ErrorKind::NotFound, "x")),
310+
];
311+
312+
for error in cases {
313+
assert_eq!(is_connection_error(&error), error.is_connection_lost(), "diverged for {error:?}");
314+
}
315+
}

0 commit comments

Comments
 (0)