Skip to content

Commit e462166

Browse files
authored
fix(contracts): propagate routed errors from blocking matching_symbols (#735)
* fix(contracts): propagate routed errors from blocking matching_symbols Retires the one-shot IncomingMessages::Error arms, the other half of the class #734 proved dead. fold_one_shot runs its processor only on Some(Ok(_)), and RoutedItem::into_legacy yields that only for RoutedItem::Response, so an error frame reaches no decoder. Thirteen sites go: seven decode_*_message dispatchers (accounts x3, contracts, scanner, config x2) and six inline message_type() == Error guards in contracts::{sync,async} and market_data::historical::{sync,async}. connection/common.rs keeps its arm — it reads frames during the handshake, before a dispatcher exists. One dead arm was hiding a live bug. Blocking matching_symbols read its response with `if let Some(Ok(mut message))`, so a routed error fell through to Ok(Vec::new()) — a rejected pattern was indistinguishable from "no symbols matched". The async twin already returned Err(e). Both now match. MessageBusStub classifies error frames like the dispatcher. Removing the arms was about to cost a third batch of tests deleted for the same reason (#734 dropped 17), so the fixture was fixed instead of the tests: routed_items() runs each fixture through determine_routing/classify_error, so an error frame arrives as RoutedItem::Error/Notice as it would on the wire. Both tests that would have been deleted then passed unmodified, and the sync matching_symbols test became a real regression guard — reverting the fix reproduces Ok(vec![]). Renames two config tests that claimed the removed behaviour in their names and asserted only is_err(), which would have passed through the change silently; they now pin UnexpectedResponse. * fix(orders): surface rejected what-if orders from OrderBuilder::analyze Restores the two historical streaming-error tests deleted in #734. They pass unmodified against the classifying stub, which shows the #734 reasoning was wrong in principle: "covered at the transport layer" conflates delivery with consumption. The transport tests prove the dispatcher produces RoutedItem::Error; they say nothing about whether a given public API hands it to the caller. Sweeping every Some(Ok(..)) consumption site for that shape found one more API that did not. OrderBuilder::analyze dropped routed errors on both sides — `if let Ok(..)` inside the loop on sync, `while let Some(Ok(..))` on async — returning UnexpectedEndOfStream instead of the rejection, on the one API where rejection is a routine outcome (code 201). Every other site handles Some(Err(e)). The four existing analyze tests missed it because they exercise a re-implementation: the builder test modules define their own analyze on OrderBuilder<'a, MockOrderClient> returning Vec<PlaceOrder>, so the production methods on Client had no coverage at all. Added two tests at the real seam; reverting either fix reproduces UnexpectedEndOfStream. The rest of that mock surface is recorded as a follow-up. * refactor: /simplify pass on the routed-error changes Stub classification now covers the intercepted *set*, not just Error: `_ => Response(message)` reproduced `RoutingDecision::Error` by name, so Shutdown still reached decoders as a Response. Both types the dispatcher intercepts are handled, matching DISPATCHER_INTERCEPTED, and the fixture conversion goes through the existing From<ResponseMessage> impl. Dead code the arm removals left behind: From<ResponseMessage> for Error and From<&ResponseMessage> for Error had zero callers once the decoders stopped using them; deleted with the two tests that only exercised the impls themselves. Config decoder doc still described its deleted Error arm. Both analyze loops collapse to `?` — the three-arm match with `Ok(_) => {}` and `Err(e) => return Err(e)` is what `?` does, and the sync version was a 145-column line. The async mock shadow carried the same discard this PR fixed, so it was corrected in place. Tests now use existing helpers instead of hand-rolling: the two orders and two historical tests build their client via create_{,blocking_}test_client_with_ordered_proto_responses, and the historical pair asserts with assert_tws_error_message rather than to_string().contains(), which pins the error code too. Dropped their dead time_zone setup (no bars decoded) and unified the sync/async shapes. Three identical async stub bodies extracted to seeded_subscription, and routed_items gained a direct test — the Shutdown arm had none. Deferred as restructuring: fold_one_shot adoption at ~14 hand-rolled sites, an expect_type helper for the seven identical dispatchers, and deleting the order-builder mock shadows. All recorded in the plan.
1 parent de24381 commit e462166

27 files changed

Lines changed: 359 additions & 159 deletions

File tree

CHANGELOG.md

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

2424
### Fixed
2525

26+
- `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).
27+
28+
- `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).
29+
2630
- `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).
2731

2832
- Decimal-typed wire fields no longer fall back to `0` when the value fails to parse; a malformed value now surfaces as `Error::Parse` and fails the request or subscription instead of being silently swallowed. Covers order quantities, execution shares, positions, contract-detail sizes, bar volume/WAP, tick and market-depth sizes (#716).

docs/rules/testing/fixture-builders.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ and refactored them into named builders in `testdata/builders/market_data.rs`.
5151
A builder needs a **current test consumer**. Matching the encoder file one-to-one "for
5252
completeness" is not a consumer.
5353

54+
## What the stub does and does not simulate
55+
56+
`MessageBusStub` sits below the dispatcher, so a fixture reaches the subscription without
57+
being routed. Two consequences pull in opposite directions:
58+
59+
- **It does classify `Error` frames** (since #735). `routed_items()` runs each fixture through
60+
`determine_routing`/`classify_error`, so an error frame arrives as
61+
`RoutedItem::Error`/`Notice` exactly as it would on the wire. Before that it arrived as a
62+
`RoutedItem::Response`, which no real transport produces — that gap is what let decoders grow
63+
unreachable `IncomingMessages::Error` arms with passing tests to match. A warning or
64+
data-advisory code now becomes a `Notice` and is filtered by `iter_data()`/`filter_data`;
65+
assert on `SubscriptionItem::Notice` if that is the point of the test.
66+
- **It does not route by channel.** The stub has one channel per request, so a fixture reaches
67+
the subscription regardless of whether `determine_routing` could have addressed it there.
68+
`debug_assert_request_id_routable` covers that half; see
69+
[proto-aware accessors](../wire/proto-aware-accessors.md).
70+
5471
## Note on `server_version`
5572

5673
The version passed to `Client::stubbed` gates *outbound encoder* feature checks, not the
@@ -63,6 +80,7 @@ constant like `SIZE_RULES` in a stub test is correct, not a leftover.
6380
- #534 — field-minimal builders for deeply-nested protos.
6481
- #543 — /simplify caught fixture helpers misplaced under `<domain>/common/`.
6582
- #731 — made a mis-framed fixture fail its test instead of silently skipping.
83+
- #735 — taught the stub to classify `Error` frames like the dispatcher.
6684

6785
See also [docs/testing-patterns.md](../../testing-patterns.md) for choosing between
6886
`MessageBusStub`, `MemoryStream`, and `spawn_handshake_listener`.

docs/rules/wire/proto-only-decoding.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ triggers:
1010
- adding a decode arm for a new message type
1111
symbols: [require_proto, process_decode_result, StreamDecoder, RESPONSE_MESSAGE_IDS, Error::UnexpectedResponse, Error::UnexpectedWireFormat]
1212
related: [proto-aware-accessors, enum-typing, fixture-builders]
13-
precedents: ["#508", "#731", "#732", "#733", "#734"]
13+
precedents: ["#508", "#731", "#732", "#733", "#734", "#735"]
1414
memory: [project_protobuf_only, feedback_unreachable_regression_guards]
1515
---
1616

@@ -106,3 +106,6 @@ other two:
106106
- #734 — audited all 78 declared entries under the const's new meaning. Sixteen declared
107107
`IncomingMessages::Error`, which the dispatcher intercepts; those and their `decode` arms are
108108
gone, along with the guard exemption that had been keeping them legal.
109+
- #735 — same removal on the one-shot side, and `MessageBusStub` now classifies error frames
110+
so the input those arms handled can no longer be constructed. Surfaced a real bug behind one
111+
of them: blocking `matching_symbols` discarded routed errors and returned `Ok(vec![])`.

plans/claude-md-knowledge-graph.md

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -468,26 +468,74 @@ life.
468468
layers that actually implement it — `test_hard_error_with_request_id_terminates_subscription`
469469
and `test_subscription_hard_error_terminates_stream`, in each transport's tests.
470470

471-
- **Retire the remaining one-shot `IncomingMessages::Error` arms.** The audit above proved the
472-
whole class dead, not just the `StreamDecoder` half: `fold_one_shot` only ever calls its
473-
processor on `Some(Ok(_))`, and `RoutedItem::into_legacy` yields that only for
474-
`RoutedItem::Response`. So the `Error` arms in `accounts::decode_soft_dollar_tiers_message` /
475-
`decode_user_info_message` / `decode_replace_fa_end_message`,
476-
`contracts::decode_smart_components_message`, `scanner::decode_scanner_message`, and both
477-
`config::common::decoders` dispatchers can never fire, as can the inline
478-
`Ok(message) if message.message_type() == IncomingMessages::Error` guards in
479-
`contracts::{sync,async}` and `market_data::historical::{sync,async}`. Mechanical, but each
480-
deletion needs its test checked — the same 15-test cost pattern as #734. Left out of #734 to
481-
keep that PR's boundary at the surface the follow-up named.
482-
483-
- **Make `MessageBusStub` classify like the dispatcher.** It hands every fixture to the
484-
subscription as `RoutedItem::Response`, including `Error` frames, which the real dispatcher
485-
never does — that mismatch is why 15 decoder-level error tests existed and passed. This is
486-
the same structural blind spot #730 recorded (stub tests inject below `determine_routing`),
487-
seen from the fixture side rather than the routing side. Running fixtures through
488-
`determine_routing`/`classify_error` in `mock_request` would close it; the risk is the tail of
489-
stub tests using warning or data-advisory codes, which would become `RoutedItem::Notice` and
490-
be skipped rather than surfacing as `Err`.
471+
- ~~**Retire the remaining one-shot `IncomingMessages::Error` arms.**~~ ~~**Make
472+
`MessageBusStub` classify like the dispatcher.**~~ **Both shipped in #735, and they turned
473+
out to be one job.** Thirteen dead sites went: seven `decode_*_message` dispatchers
474+
(`accounts` ×3, `contracts`, `scanner`, `config` ×2) and six inline
475+
`message.message_type() == IncomingMessages::Error` guards in `contracts::{sync,async}` and
476+
`market_data::historical::{sync,async}`. Kept: `connection/common.rs`, which reads frames
477+
during the handshake before a dispatcher exists, and `messages/parser_registry.rs`, which is
478+
a trace-parser table.
479+
480+
**Deleting the arms was going to cost a third batch of tests, and that was the signal to stop
481+
deleting.** #734 dropped 17 tests because only `MessageBusStub` could produce their input;
482+
two more were about to go the same way. The rule of three tripped, so the stub was fixed
483+
instead: `routed_items()` runs every fixture through `determine_routing`/`classify_error`, so
484+
an error frame arrives as `RoutedItem::Error`/`Notice` exactly as on the wire. Both tests then
485+
passed unmodified. **Fixing the fixture kept the coverage that deleting the arms would have
486+
destroyed** — and the two tests #734 deleted on the same grounds would have survived it too.
487+
488+
The feared tail — warning and data-advisory codes becoming `Notice` and being filtered —
489+
never materialised: one test needed touching across both legs, and only because its assertion
490+
encoded the old wart.
491+
492+
**One dead arm was hiding a live bug.** Blocking `matching_symbols` read its response with
493+
`if let Some(Ok(mut message))`, so a routed error fell through to `Ok(Vec::new())` — a
494+
rejected pattern was indistinguishable from "no symbols matched". Its async twin already had
495+
`Some(Err(e)) => return Err(e)`. The dead `IncomingMessages::Error` arm sitting next to the
496+
hole is what makes it legible: someone meant to handle errors and wired it one layer too low,
497+
and the arm's unreachability is exactly why the omission below it stayed invisible.
498+
**A dead arm is worth reading before deleting — it marks where someone expected a case to
499+
arrive, which is where to check whether the real case is handled at all.**
500+
501+
**The #734 deletions were wrong in principle, not just in outcome.** The justification was
502+
"the mechanism is covered at the transport layer", which conflates *delivery* with
503+
*consumption*. The transport tests prove the dispatcher produces `RoutedItem::Error`; they say
504+
nothing about whether a given public API hands it to the caller — and two APIs did not. Both
505+
deleted tests were restored and passed unmodified against the classifying stub.
506+
507+
A sweep of every `Some(Ok(..))` consumption site for the same shape found one more:
508+
`OrderBuilder::analyze()` dropped routed errors on both sides (`if let Ok(..)` inside the
509+
loop on sync, `while let Some(Ok(..))` on async), returning `UnexpectedEndOfStream` instead of
510+
the rejection — on the one API where rejection is a routine outcome. Every other site handles
511+
`Some(Err(e))`. **The per-API question "does this consume `Err`?" needs asking once per
512+
public entry point; no shared layer answers it.**
513+
514+
- **Adopt `fold_one_shot` at the ~14 sites that hand-roll it.** `Some(Ok(m)) => decode`,
515+
`Some(Err(e)) => Err(e)`, `None => default` is spelled out at four sites each in
516+
`contracts::{sync,async}`, plus `news::{sync,async}` ×2 each and `scanner::{sync,async}`
517+
while `fold_one_shot` itself has no callers outside `request_helpers.rs`. Two blockers, both
518+
vestigial: `decode_contract_descriptions` and `decode_market_rule` take `&mut ResponseMessage`
519+
and an unused `server_version`, though their bodies are only `require_proto()?` (a `&self`
520+
method). The option-computation sites are genuinely blocked — they need `&mut` plus a
521+
`DecoderContext`. `request_helpers` also lacks a request-id/no-retry variant, which is exactly
522+
the slot `matching_symbols` falls into. Deferred from #735's `/simplify` as restructuring.
523+
524+
- **Collapse the seven identical `decode_*_message` dispatchers.** After #734/#735 they are all
525+
`match message.message_type() { X => decode_x(m), _ => Err(unexpected_response(m)) }` across
526+
`accounts` ×3, `contracts`, `config` ×2, `scanner`. An `expect_type(message, expected)` helper
527+
makes each a one-liner. Rule of three is well past, but it touches seven modules.
528+
529+
- **The order-builder tests re-implement the code they test.** `src/orders/builder/{sync,async}_impl/tests.rs`
530+
define their own `analyze` / `submit` on `OrderBuilder<'a, MockOrderClient>` returning
531+
`Vec<PlaceOrder>` rather than a `Subscription`, so the production methods on `Client` had zero
532+
coverage — which is why the `analyze` bug above survived four tests named for it. #735 added
533+
two tests at the real seam for `analyze` only; `submit`, `build_order`, and the bracket-order
534+
builders still have none. The async shadow carried the very discard the PR fixed
535+
(`while let Some(Ok(..))`) and was corrected in place, which is the argument for deleting the
536+
shadows rather than maintaining two copies. A textbook
537+
[exercise production code](../docs/rules/testing/exercise-production-code.md) violation, and
538+
the largest one left in the tree.
491539

492540
- **Cache the message discriminant on `ResponseMessage`.** `message_type()` re-parses
493541
`fields[0]` with `i32::from_str` on every call, and it is called 4–6 times per inbound

src/accounts/common/decoders/mod.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,6 @@ pub(crate) fn decode_soft_dollar_tiers_proto(bytes: &[u8]) -> Result<Vec<SoftDol
209209
pub(in crate::accounts) fn decode_soft_dollar_tiers_message(message: &ResponseMessage) -> Result<Vec<SoftDollarTier>, Error> {
210210
match message.message_type() {
211211
IncomingMessages::SoftDollarTier => decode_soft_dollar_tiers(message),
212-
IncomingMessages::Error => Err(Error::from(message)),
213212
_ => Err(Error::unexpected_response(message)),
214213
}
215214
}
@@ -228,7 +227,6 @@ pub(crate) fn decode_user_info_proto(bytes: &[u8]) -> Result<UserInfo, Error> {
228227
pub(in crate::accounts) fn decode_user_info_message(message: &ResponseMessage) -> Result<UserInfo, Error> {
229228
match message.message_type() {
230229
IncomingMessages::UserInfo => decode_user_info(message),
231-
IncomingMessages::Error => Err(Error::from(message)),
232230
_ => Err(Error::unexpected_response(message)),
233231
}
234232
}
@@ -263,7 +261,6 @@ pub(crate) fn decode_replace_fa_end_proto(bytes: &[u8]) -> Result<ReplaceFaResul
263261
pub(in crate::accounts) fn decode_replace_fa_end_message(message: &ResponseMessage) -> Result<ReplaceFaResult, Error> {
264262
match message.message_type() {
265263
IncomingMessages::ReplaceFAEnd => decode_replace_fa_end(message),
266-
IncomingMessages::Error => Err(Error::from(message)),
267264
_ => Err(Error::unexpected_response(message)),
268265
}
269266
}

src/common/request_helpers.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
/// fanned out to one-shot shared channels — instead of masking it as a
77
/// default value (#694). `on_none` decides what a closed stream means for
88
/// the caller (a default value, or `Error::UnexpectedEndOfStream`).
9+
///
10+
/// `processor` therefore never sees an `IncomingMessages::Error` frame. The
11+
/// dispatcher classifies those into `RoutedItem::Error`/`Notice`, and
12+
/// `RoutedItem::into_legacy` turns them into the `Some(Err)` arm above — so a
13+
/// `decode_*_message` dispatcher that matches on `IncomingMessages::Error` is
14+
/// writing an arm that cannot fire. See
15+
/// `docs/rules/wire/proto-only-decoding.md`.
916
pub(crate) fn fold_one_shot<R>(
1017
response: Option<Result<crate::messages::ResponseMessage, crate::Error>>,
1118
processor: impl FnOnce(&crate::messages::ResponseMessage) -> Result<R, crate::Error>,

src/config/common/decoders.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ use crate::messages::{IncomingMessages, ResponseMessage};
1111
use crate::proto;
1212
use crate::Error;
1313

14-
/// Dispatch on the incoming message type and forward to the typed decoder.
15-
/// Routes `Error` frames into `Error::from` and any other variant into
16-
/// `Error::UnexpectedResponse`.
14+
/// Dispatch on the incoming message type and forward to the typed decoder. Any
15+
/// other variant becomes `Error::UnexpectedResponse`. There is deliberately no
16+
/// `IncomingMessages::Error` arm — the dispatcher classifies error frames, so
17+
/// one never reaches a decoder; see `docs/rules/wire/proto-only-decoding.md`.
1718
pub(in crate::config) fn decode_config_message(message: &ResponseMessage) -> Result<Config, Error> {
1819
match message.message_type() {
1920
IncomingMessages::ConfigResponse => decode_config_proto(message.require_proto()?),
20-
IncomingMessages::Error => Err(Error::from(message)),
2121
_ => Err(Error::unexpected_response(message)),
2222
}
2323
}
@@ -133,7 +133,6 @@ fn convert_smart_routing(p: proto::OrdersSmartRoutingConfig) -> OrdersSmartRouti
133133
pub(in crate::config) fn decode_update_config_message(message: &ResponseMessage) -> Result<UpdateConfigResponse, Error> {
134134
match message.message_type() {
135135
IncomingMessages::UpdateConfigResponse => decode_update_config_proto(message.require_proto()?),
136-
IncomingMessages::Error => Err(Error::from(message)),
137136
_ => Err(Error::unexpected_response(message)),
138137
}
139138
}

src/config/common/decoders_tests.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,15 @@ fn test_decode_config_message_dispatches_config_response() {
6060
}
6161

6262
#[test]
63-
fn test_decode_config_message_routes_error() {
64-
// IncomingMessages::Error == 4; a text-framed error surfaces as an Err.
63+
fn test_decode_config_message_rejects_error_frames() {
64+
// IncomingMessages::Error == 4. Regression guard for the arm removed in
65+
// #735: the dispatcher classifies error frames, so one never reaches a
66+
// decoder and the `_` backstop must treat it like any other foreign type.
6567
let message = ResponseMessage::from("4\09000\0322\0error text\0");
66-
assert!(decode_config_message(&message).is_err());
68+
match decode_config_message(&message) {
69+
Err(Error::UnexpectedResponse(_)) => {}
70+
other => panic!("expected UnexpectedResponse, got {other:?}"),
71+
}
6772
}
6873

6974
#[test]
@@ -109,9 +114,13 @@ fn test_decode_update_config_message_populated() {
109114
}
110115

111116
#[test]
112-
fn test_decode_update_config_message_routes_error() {
117+
fn test_decode_update_config_message_rejects_error_frames() {
118+
// Mirrors test_decode_config_message_rejects_error_frames.
113119
let message = ResponseMessage::from("4\09000\0322\0error text\0");
114-
assert!(decode_update_config_message(&message).is_err());
120+
match decode_update_config_message(&message) {
121+
Err(Error::UnexpectedResponse(_)) => {}
122+
other => panic!("expected UnexpectedResponse, got {other:?}"),
123+
}
115124
}
116125

117126
#[test]

src/contracts/async.rs

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::messages::{IncomingMessages, OutgoingMessages};
88
use crate::protocol::{check_version, Features};
99
use crate::subscriptions::{StreamDecoder, Subscription};
1010
use crate::{Client, Error};
11-
use log::{error, info};
11+
use log::info;
1212

1313
impl Client {
1414
/// Requests contract information.
@@ -57,7 +57,6 @@ impl Client {
5757
contract_details.push(decoded);
5858
}
5959
IncomingMessages::ContractDataEnd => return Ok(contract_details),
60-
IncomingMessages::Error => return Err(Error::from(response)),
6160
_ => return Err(Error::unexpected_response(&response)),
6261
}
6362
}
@@ -99,23 +98,15 @@ impl Client {
9998

10099
match subscription.next().await {
101100
Some(Ok(mut message)) => match message.message_type() {
102-
IncomingMessages::SymbolSamples => {
103-
return decoders::decode_contract_descriptions(self.server_version(), &mut message);
104-
}
105-
IncomingMessages::Error => {
106-
error!("unexpected error: {message:?}");
107-
return Err(Error::unexpected_response(&message));
108-
}
101+
IncomingMessages::SymbolSamples => decoders::decode_contract_descriptions(self.server_version(), &mut message),
109102
_ => {
110103
info!("unexpected message: {message:?}");
111-
return Err(Error::unexpected_response(&message));
104+
Err(Error::unexpected_response(&message))
112105
}
113106
},
114-
Some(Err(e)) => return Err(e),
115-
None => {}
107+
Some(Err(e)) => Err(e),
108+
None => Ok(Vec::new()),
116109
}
117-
118-
Ok(Vec::default())
119110
}
120111

121112
/// Requests details about a given market rule.

src/contracts/async_tests.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -575,13 +575,17 @@ async fn contract_details_propagates_verify_failure() {
575575

576576
#[tokio::test]
577577
async fn matching_symbols_returns_server_error() {
578-
let message_bus = Arc::new(MessageBusStub::with_ordered_responses(vec![text_response(
579-
"4|2|9000|321|invalid pattern|",
578+
let message_bus = Arc::new(MessageBusStub::with_ordered_responses(vec![proto_error_response(
579+
9000,
580+
321,
581+
"invalid pattern",
580582
)]));
581583
let client = Client::stubbed(message_bus, server_versions::BOND_ISSUERID);
582584

585+
// The TWS error itself, not a generic UnexpectedResponse: the dispatcher
586+
// classifies error frames, so `matching_symbols` receives `Some(Err(_))`.
583587
let err = client.matching_symbols("???").await.unwrap_err();
584-
assert!(matches!(err, crate::Error::UnexpectedResponse(_)), "got {err:?}");
588+
assert_tws_error_message(err, 321, "invalid pattern");
585589
}
586590

587591
#[tokio::test]

0 commit comments

Comments
 (0)