ibapi/messages/notice.rs
1//! TWS notices: the [`Notice`] type, its [`NoticeCategory`] classification,
2//! [`ConnectivityStatus`], the notice code constants and ranges, and the
3//! builders for client-synthesized notices.
4
5use std::fmt::Display;
6
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9
10use super::ResponseMessage;
11
12#[cfg(test)]
13#[path = "notice_tests.rs"]
14mod tests;
15
16/// An error message from the TWS API.
17#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct Notice {
20 /// Request or order ID that originated the notice.
21 /// `None` for request-less notices.
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub request_id: Option<i32>,
24 /// Error code reported by TWS.
25 pub code: i32,
26 /// Human-readable error message text.
27 pub message: String,
28 /// Timestamp when the error occurred.
29 /// Only present for server versions >= ERROR_TIME (194).
30 pub error_time: Option<OffsetDateTime>,
31 /// Advanced order-reject JSON payload, present on hard order-rejection
32 /// notices for server versions >= ADVANCED_ORDER_REJECT. Empty otherwise.
33 #[serde(default, skip_serializing_if = "String::is_empty")]
34 pub advanced_order_reject_json: String,
35}
36
37/// Error code indicating an order was cancelled (confirmation, not an error).
38pub const ORDER_CANCELLED_CODE: i32 = 202;
39
40/// Generic order-message code whose text determines whether TWS reports a warning or an error.
41pub const ORDER_MESSAGE_CODE: i32 = 399;
42
43/// "Market depth data has been RESET". A [`DATA_ADVISORY_CODES`] entry; a
44/// depth subscription yields it as [`MarketDepths::Reset`](crate::market_data::realtime::MarketDepths::Reset).
45pub(crate) const MARKET_DEPTH_RESET_CODE: i32 = 317;
46
47/// "Historical Market Data Service query message". Classified `Error`, except
48/// the [`HMDS_CONNECTED_TEXT`] form, which is a `Warning`; a scanner
49/// subscription also keeps its "no items retrieved" form nonterminal. See
50/// [`classify`].
51pub(crate) const HMDS_QUERY_MESSAGE_CODE: i32 = 165;
52
53/// Text of the [`HMDS_QUERY_MESSAGE_CODE`] frame TWS sends, bound to a
54/// historical request, when the HMDS farm connects on demand. The data follows
55/// it, so it is a warning, not a failed query (#1136).
56pub(crate) const HMDS_CONNECTED_TEXT: &str = "HMDS server connection was successful";
57
58/// "No security definition has been found for the request".
59pub(crate) const NO_SECURITY_DEFINITION_CODE: i32 = 200;
60
61/// "Historical Market Data Service error message" (e.g. a disabled scanner
62/// type, or no data for a historical query).
63pub(crate) const HMDS_ERROR_CODE: i32 = 162;
64
65/// "Error validating request" (e.g. an invalid account code).
66pub(crate) const REQUEST_VALIDATION_CODE: i32 = 321;
67
68/// Range of error codes that are considered warnings: the whole `21xx` band.
69///
70/// IB's published table stops at 2169, but the gateway keeps adding codes above
71/// it (2176, the fractional-share size-rule warning; 2187, generic ticks
72/// unavailable on delayed-data fallback), and a ceiling of 2169 turned each new
73/// one into a hard error that failed in-flight one-shots and ended
74/// subscriptions.
75pub const WARNING_CODE_RANGE: std::ops::RangeInclusive<i32> = 2100..=2199;
76
77/// Code 0 is a code-less frame: IB Gateway omits `error_code` on informational
78/// notices (e.g. "Warning: Approaching max rate of 50 messages per second"),
79/// and the absent field decodes to 0. An error frame that fails proto decode
80/// falls back to the same default, so code 0 never carries a hard error.
81///
82/// Code 165 is a warning only in its [`HMDS_CONNECTED_TEXT`] form; its other
83/// texts can be real query failures.
84pub(crate) fn is_warning_message(code: i32, message: &str) -> bool {
85 code == 0
86 || WARNING_CODE_RANGE.contains(&code)
87 || (code == ORDER_MESSAGE_CODE && message.lines().any(|line| line.trim_start().starts_with("Warning:")))
88 || (code == HMDS_QUERY_MESSAGE_CODE && message.contains(HMDS_CONNECTED_TEXT))
89}
90
91/// Classify a raw error frame into a disjoint [`NoticeCategory`].
92///
93/// The single owner of the precedence chain documented on [`NoticeCategory`];
94/// [`Notice::category`] and [`is_informational_code`] both derive from it, so
95/// routing and the public partition cannot disagree.
96///
97/// One exception sits downstream of routing:
98/// [`StreamDecoder::is_nonterminal_notice`](crate::subscriptions::common::StreamDecoder::is_nonterminal_notice)
99/// lets a subscription deliver a request-bound `Error` notice as
100/// `SubscriptionItem::Notice` instead of ending. The notice keeps its `Error`
101/// category. Only the scanner uses it, for [`HMDS_QUERY_MESSAGE_CODE`] "no items
102/// retrieved" (#886); making that form informational here would also reach
103/// one-shot requests, which would wait for an answer that never comes. The
104/// [`HMDS_CONNECTED_TEXT`] form is a `Warning` here instead: the data follows it.
105pub(crate) fn classify(code: i32, message: &str) -> NoticeCategory {
106 if code == ORDER_CANCELLED_CODE {
107 NoticeCategory::Cancellation
108 } else if DATA_ADVISORY_CODES.contains(&code) {
109 NoticeCategory::DataAdvisory
110 } else if is_warning_message(code, message) {
111 NoticeCategory::Warning
112 } else if SYSTEM_MESSAGE_CODES.contains(&code) {
113 NoticeCategory::SystemMessage
114 } else if REQUEST_ERROR_CODES.contains(&code) {
115 NoticeCategory::RequestError
116 } else if ORDER_REJECTION_CODE_RANGE.contains(&code) {
117 NoticeCategory::OrderRejection
118 } else {
119 NoticeCategory::Error
120 }
121}
122
123/// Check if an error code is informational: every [`NoticeCategory`] except
124/// `RequestError`, `OrderRejection` and `Error`.
125///
126/// For these TWS proceeds with the request, the frame confirms an outcome the
127/// caller asked for, or the frame reports a connection-wide state change
128/// rather than a failed request — so they are routed as a `Notice` rather
129/// than terminating the subscription as an `Error`, and, when request-less,
130/// they do not fail the pending one-shots.
131///
132/// System messages never stand in for a per-request answer: after 1100 TWS
133/// still answers or rejects each request itself, and after 1300 the socket is
134/// dropped, so pending one-shots see `Error::ConnectionReset` from the
135/// transport reset and retry. Note 202 and the system codes are deliberately
136/// *not* in [`is_warning_message`]: `Notice::is_warning()` stays false for
137/// them; only the routing disposition treats them like warnings.
138pub(crate) fn is_informational_code(code: i32, message: &str) -> bool {
139 // Exhaustive on purpose: a new `NoticeCategory` variant must decide its
140 // routing disposition here, not inherit one from a wildcard.
141 match classify(code, message) {
142 NoticeCategory::Cancellation | NoticeCategory::DataAdvisory | NoticeCategory::Warning | NoticeCategory::SystemMessage => true,
143 NoticeCategory::RequestError | NoticeCategory::OrderRejection | NoticeCategory::Error => false,
144 }
145}
146
147/// Connectivity between IB and TWS has been lost.
148pub(crate) const CONNECTIVITY_LOST_CODE: i32 = 1100;
149/// Connectivity restored, but market data was lost; resubscription is required.
150pub(crate) const CONNECTIVITY_RESTORED_DATA_LOST_CODE: i32 = 1101;
151/// Connectivity restored with market data maintained (nothing lost).
152pub(crate) const CONNECTIVITY_RESTORED_DATA_MAINTAINED_CODE: i32 = 1102;
153/// Socket port was reset during an active connection; the connection is dropped.
154pub(crate) const SOCKET_PORT_RESET_CODE: i32 = 1300;
155
156/// System message codes indicating connectivity status.
157/// - 1100: Connectivity lost
158/// - 1101: Connectivity restored, market data lost (resubscribe needed)
159/// - 1102: Connectivity restored, market data maintained
160/// - 1300: Socket port reset during active connection
161pub const SYSTEM_MESSAGE_CODES: [i32; 4] = [
162 CONNECTIVITY_LOST_CODE,
163 CONNECTIVITY_RESTORED_DATA_LOST_CODE,
164 CONNECTIVITY_RESTORED_DATA_MAINTAINED_CODE,
165 SOCKET_PORT_RESET_CODE,
166];
167
168/// Data-advisory codes: frames that look like errors but reject nothing — the
169/// request stands and data keeps flowing. Each one describes what will arrive
170/// (a fallback, a partial entitlement, or a reset of what was already
171/// delivered), so they are informational notices, not errors. Classifying them
172/// as errors would terminate the subscription before its data arrives.
173/// - 317: Market depth data has been RESET. Please empty deep book contents
174/// before applying any new entries. A depth subscription yields it as
175/// [`MarketDepths::Reset`](crate::market_data::realtime::MarketDepths::Reset).
176/// - 2188: Up-to-the-second historical data requires additional subscription for the API.
177/// - 10089: Requested market data requires additional subscription for API; delayed market data is available.
178/// - 10090: Part of requested market data is not subscribed. Subscription-independent ticks are still active.
179/// - 10091: Part of requested market data requires additional subscription for API.
180/// - 10167: Requested market data is not subscribed. Displaying delayed market data.
181///
182/// A slice rather than an array so that adding a code is not a type change
183/// for callers that bind the constant explicitly.
184pub const DATA_ADVISORY_CODES: &[i32] = &[MARKET_DEPTH_RESET_CODE, 2188, 10089, 10090, 10091, 10167];
185
186/// Request-error codes inside [`ORDER_REJECTION_CODE_RANGE`]: a request other
187/// than an order failed (market data, depth, historical data, scanner, session
188/// setup). Classified [`NoticeCategory::RequestError`], ahead of the band.
189///
190/// Only codes that never answer an order are listed. Codes that answer both
191/// orders and other requests stay [`NoticeCategory::OrderRejection`], so
192/// [`Notice::is_order_rejection`] keeps matching them on order streams: 200 (no
193/// security definition) and 320-323 (server error reading, validating or
194/// processing a request; 320 answers an invalid attached order, #842).
195/// - 300: Can't find EId with ticker Id (cancelling unknown market data).
196/// - 301, 302: Invalid ticker action; error parsing stop ticker string.
197/// - 309: Max number of market depth requests has been reached.
198/// - 310: Can't find the subscribed market depth.
199/// - 316: Market depth data has been HALTED. Please re-subscribe.
200/// - 319: Invalid log level.
201/// - 326: Client id already in use.
202/// - 327: Only clientId 0 can set the auto bind TWS orders property.
203/// - 330, 331: Managed accounts list needs an FA or STL account with managed accounts.
204/// - 354: Not subscribed to requested market data.
205/// - 357: Client version out of date.
206/// - 365, 366: No scanner subscription / historical data query for the ticker id.
207/// - 385, 386: Duplicate ticker id for scanner subscription / historical data query.
208///
209/// A slice rather than an array so that adding a code is not a type change
210/// for callers that bind the constant explicitly.
211pub const REQUEST_ERROR_CODES: &[i32] = &[300, 301, 302, 309, 310, 316, 319, 326, 327, 330, 331, 354, 357, 365, 366, 385, 386];
212
213/// TWS refused the session: another connection holds this client id. Sent
214/// once after `start_api`, then TWS closes the socket; the handshake turns it
215/// into [`Error::ClientIdInUse`](crate::Error::ClientIdInUse).
216pub(crate) const CLIENT_ID_IN_USE_CODE: i32 = 326;
217
218/// Data-farm codes reporting a healthy connection ("…connection is OK").
219/// Subset of [`WARNING_CODE_RANGE`]; classified [`ConnectivityStatus::Ok`].
220pub(crate) const FARM_OK_CODES: [i32; 3] = [2104, 2106, 2158];
221
222/// Data-farm codes reporting a broken connection ("…connection is broken").
223/// Subset of [`WARNING_CODE_RANGE`]; classified [`ConnectivityStatus::Broken`].
224pub(crate) const FARM_BROKEN_CODES: [i32; 3] = [2103, 2105, 2157];
225
226/// Data-farm codes reporting a dormant-but-available connection
227/// ("…inactive but should be available upon demand").
228/// Subset of [`WARNING_CODE_RANGE`]; classified [`ConnectivityStatus::Inactive`].
229pub(crate) const FARM_INACTIVE_CODES: [i32; 2] = [2107, 2108];
230
231/// Data-farm codes reporting a connection in progress ("…farm is connecting").
232/// Subset of [`WARNING_CODE_RANGE`]; classified [`ConnectivityStatus::Connecting`].
233pub(crate) const FARM_CONNECTING_CODES: [i32; 1] = [2119];
234
235/// The order-rejection band (200-399): order parameter validation, margin and
236/// risk-check rejections.
237///
238/// [`Notice::category`] classifies a code in this band as
239/// [`NoticeCategory::OrderRejection`] unless an earlier rule claims it; see
240/// [`NoticeCategory`] for the precedence chain.
241pub const ORDER_REJECTION_CODE_RANGE: std::ops::RangeInclusive<i32> = 200..=399;
242
243/// Synthesized notice code emitted when a handshake-time frame's recognized
244/// [`IncomingMessages`](super::IncomingMessages) kind has no typed `StartupMessage` variant. A frame
245/// whose id maps to no kind at all raises [`UNKNOWN_MESSAGE_TYPE_CODE`]
246/// instead, as it does after the handshake. Negative
247/// (TWS uses 0+); the other client-synthesized codes are
248/// [`HANDSHAKE_DECODE_FAILURE_CODE`], [`UNKNOWN_MESSAGE_TYPE_CODE`],
249/// [`SUBSCRIPTION_LAG_CODE`], [`NOTICE_STREAM_LAG_CODE`], and
250/// [`TRANSPORT_RECONNECT_CODE`]. See
251/// [`Notice::is_handshake_synthetic`].
252pub const HANDSHAKE_UNKNOWN_FRAME_CODE: i32 = -3;
253
254/// Synthesized notice code emitted when a typed handshake decoder fails for
255/// a known [`IncomingMessages`](super::IncomingMessages) kind (`OpenOrder`, `OrderStatus`,
256/// `AccountValue`/`PortfolioValue`/`AccountUpdateTime`/`AccountDownloadEnd`,
257/// `ExecutionData`, `CommissionsReport`, `CompletedOrder`). Distinct from
258/// [`HANDSHAKE_UNKNOWN_FRAME_CODE`] so consumers can separate TWS schema
259/// drift from rust-ibapi decoder bugs. See
260/// [`Notice::is_handshake_synthetic`].
261pub const HANDSHAKE_DECODE_FAILURE_CODE: i32 = -4;
262
263/// Synthesized notice code emitted when a frame arrives whose 4-byte message
264/// id maps to no known [`IncomingMessages`](super::IncomingMessages) kind, so nothing can route it.
265///
266/// Negative, like the other client-synthesized notice codes
267/// ([`HANDSHAKE_UNKNOWN_FRAME_CODE`], [`HANDSHAKE_DECODE_FAILURE_CODE`],
268/// [`SUBSCRIPTION_LAG_CODE`], [`NOTICE_STREAM_LAG_CODE`],
269/// [`TRANSPORT_RECONNECT_CODE`]); TWS itself only
270/// uses codes 0 and up.
271///
272/// This is the observable form of a framing desynchronization: the length
273/// prefix is positional, so once a read starts at the wrong offset every
274/// subsequent message id is garbage — usually unrecognized (this notice), but
275/// occasionally colliding with a real kind, in which case the payload decodes
276/// without error into plausible-looking wrong values.
277///
278/// The notice text names the offending id, which is what separates the two
279/// explanations: a slipped stream yields *scattered* ids, while a message type
280/// IBKR has added repeats one. A burst of distinct ids on a previously healthy
281/// connection means the framing slipped.
282///
283/// Raised the same way during the connection handshake, which is the window a
284/// reconnect runs through; [`HANDSHAKE_UNKNOWN_FRAME_CODE`] is reserved for
285/// recognized kinds with no typed startup variant.
286pub const UNKNOWN_MESSAGE_TYPE_CODE: i32 = -5;
287
288/// Build the [`UNKNOWN_MESSAGE_TYPE_CODE`] notice for `message`, and emit the
289/// matching `warn!`. The single owner of the wording, shared by steady-state
290/// routing and the handshake.
291pub(crate) fn unknown_message_type_notice(message: &ResponseMessage) -> Notice {
292 // The Debug dump already carries the id; the notice has no such fallback,
293 // so it interpolates.
294 log::warn!("unroutable frame: message id maps to no known type — the stream may be desynchronized: {message:?}");
295 let id = match message.message_id() {
296 Some(id) => format!("message id {id}"),
297 None => "no message id".to_string(),
298 };
299 Notice::synthesized(
300 UNKNOWN_MESSAGE_TYPE_CODE,
301 format!("received a frame with {id}, which maps to no known type; the stream may be desynchronized"),
302 )
303}
304
305/// Synthesized notice code emitted in-band on an async subscription whose
306/// consumer fell behind its broadcast channel: the channel evicted the oldest
307/// frames, and this notice — carrying the dropped count in its message — is
308/// what the consumer sees in their place.
309///
310/// Negative, like the other client-synthesized notice codes; TWS itself only
311/// uses codes 0 and up.
312///
313/// On receiving this notice the stream is still live, but frames are gone:
314/// reconcile the same way as after a reconnect gap.
315///
316/// Readers that assemble a complete result fail instead of returning rows
317/// with a gap: `collect_to_end_within`, `next_batch` and what is built on
318/// them (`contract_details`, account-summary snapshots) return
319/// `Error::Notice` carrying this code. `collect_for` and `collect_until`
320/// stop at the gap and return the rows read before it.
321///
322/// - **Market data and other non-order streams** (ticks, bars, depth,
323/// positions, account updates, PnL, news, contract details): bounded and
324/// lossy by design — freshness beats completeness, and a feed
325/// self-corrects with the next tick. Channels hold
326/// `ClientBuilder::channel_capacity` frames; a consumer that lags
327/// persistently should raise it or consume faster.
328/// - **Orders** (`place_order`, `cancel_order`, `exercise_options`,
329/// `executions`, `order_update_stream`, the open/completed-order streams):
330/// `channel_capacity` can raise these channels but not shrink them, so
331/// this notice there means order state was lost and the client logs it as
332/// an error. Resynchronize with `open_orders()` / `executions()` before
333/// acting on recorded state.
334///
335/// The sync client never drops: its queues are unbounded and log a warning
336/// at every 10,000 unread messages instead. Both transports surface falling
337/// behind; only async trades completeness for bounded memory.
338pub const SUBSCRIPTION_LAG_CODE: i32 = -6;
339
340/// Build the in-band notice for a subscription that fell behind its broadcast
341/// channel and had `skipped` frames evicted, and emit the matching `warn!`.
342/// The single owner of the non-order lag wording; see [`SUBSCRIPTION_LAG_CODE`].
343#[cfg(feature = "async")]
344pub(crate) fn subscription_lag_notice(skipped: u64) -> Notice {
345 let message = format!("subscription fell behind; {skipped} frames dropped (consumer lagged broadcast channel)");
346 log::warn!("{message}");
347 Notice::synthesized(SUBSCRIPTION_LAG_CODE, message)
348}
349
350/// [`subscription_lag_notice`] for an order-class channel, where a gap means
351/// lost order state: logged as an error, and the message says to resync.
352/// The single owner of the order lag wording.
353#[cfg(feature = "async")]
354pub(crate) fn order_lag_notice(skipped: u64) -> Notice {
355 let message = format!("order stream fell behind; {skipped} frames dropped — order state unknown, resync with open_orders() / executions()");
356 log::error!("{message}");
357 Notice::synthesized(SUBSCRIPTION_LAG_CODE, message)
358}
359
360/// Synthesized notice code delivered in-band on the notice stream when its
361/// consumer fell behind the notice fan-out: the broadcast channel evicted the
362/// oldest notices, and this notice — carrying the dropped count in its
363/// message — is what the consumer sees in their place.
364///
365/// Negative, like the other client-synthesized notice codes; TWS itself only
366/// uses codes 0 and up.
367///
368/// The notice stream is how a stateful consumer receives the unrouted
369/// connection-status notices (1100 lost, 1101/1102 restored) it derives
370/// durable conclusions from, so a silent skip is not survivable: losing a
371/// 1101 voids every market-data request server-side yet leaves the client's
372/// subscriptions looking healthy forever, and losing a restoration notice
373/// after a recorded 1100 holds every pending reopen forever. On receiving
374/// this notice the stream's conclusions are unknown — any of them may rest
375/// on notices that were dropped — so the consumer must resynchronize rather
376/// than resume: treat recorded state as describing an unknown moment between
377/// the eviction and now, and re-derive it (a connection-state authority
378/// re-baselines its link state and re-establishes subscriptions). This is
379/// the notice-stream instance of [`SUBSCRIPTION_LAG_CODE`], closing the
380/// step-1 leftover of #779. The sync
381/// notice fan-out is unbounded and cannot lag; it logs a warning at every
382/// 10,000 unread notices instead.
383pub const NOTICE_STREAM_LAG_CODE: i32 = -7;
384
385/// Build the in-band notice delivered when the notice stream's consumer fell
386/// behind the notice fan-out and `skipped` notices were evicted, and emit the
387/// matching `warn!`. The single owner of the notice-stream lag wording; see
388/// [`NOTICE_STREAM_LAG_CODE`].
389#[cfg(feature = "async")]
390pub(crate) fn notice_stream_lag_notice(skipped: u64) -> Notice {
391 let message = format!("notice stream fell behind; {skipped} notices dropped (consumer lagged the notice fan-out)");
392 log::warn!("{message}");
393 Notice::synthesized(NOTICE_STREAM_LAG_CODE, message)
394}
395
396/// Synthesized notice code published to the notice stream (sync and async)
397/// after the transport finishes reconnecting its socket to TWS/Gateway: a new
398/// connection whose server-side state starts empty, so every request
399/// subscription made on the previous connection is gone, and unrouted-notice
400/// state — notably the 1100/1101/1102 connection-status sequence — describes
401/// the previous connection only.
402///
403/// Negative, like the other client-synthesized notice codes; TWS itself only
404/// uses codes 0 and up.
405///
406/// TWS never frames this event itself: 1101/1102 announce the restoration
407/// *transition* to clients connected at that moment and are not replayed to a
408/// client that connects afterwards, so a consumer that recorded 1100 ("TWS
409/// lost its IB-server link") before a socket drop cannot learn from the new
410/// connection that the link is back. On receiving this notice, treat every
411/// fact gathered from the previous connection as describing that connection
412/// alone: re-establish what the new session must provide (resubscribe
413/// requests), and reset connection-state conclusions — a recorded 1100 — to
414/// the fresh-connection baseline a new client starts from, then let the new
415/// connection's own notices re-derive the link state. The market data type
416/// set with `switch_market_data_type` needs no action: the reconnect re-sends
417/// it before this notice.
418///
419/// `Client::resubscribing` also delivers it in-band, as a
420/// [`SubscriptionItem::Notice`](crate::subscriptions::SubscriptionItem::Notice) before the first item
421/// of each subscription it reopens.
422///
423/// Published once the reconnected session is live: the channel reset runs
424/// before the reconnect, so a consumer may resubscribe from inside its handler
425/// and land on the new session without racing either. Like the other
426/// client-synthesized codes this classifies as [`NoticeCategory::Error`]
427/// ("everything else"); consumers match the constant itself rather than the
428/// category.
429pub const TRANSPORT_RECONNECT_CODE: i32 = -8;
430
431/// Build the [`TRANSPORT_RECONNECT_CODE`] notice. Emits no log line: the
432/// transport already logs the reconnect. The single owner of the wording.
433pub(crate) fn transport_reconnect_notice() -> Notice {
434 Notice::synthesized(
435 TRANSPORT_RECONNECT_CODE,
436 "transport reconnected to TWS/Gateway; this is a new connection whose server-side state starts empty, so every request subscription from the previous connection is gone".to_string(),
437 )
438}
439
440/// Build the in-band [`TRANSPORT_RECONNECT_CODE`] notice `Client::resubscribing`
441/// yields before a resubscribed stream's first item.
442pub(crate) fn resubscribed_notice() -> Notice {
443 Notice::synthesized(
444 TRANSPORT_RECONNECT_CODE,
445 "resubscribed after a transport reconnect; data from the outage is missing and the new subscription may repeat its initial state".to_string(),
446 )
447}
448
449/// Typed classification of a [`Notice`] by TWS error-code range.
450///
451/// Returned by [`Notice::category`]. Forms a disjoint partition over all
452/// possible notices; when classifications overlap on the wire, the classifier
453/// resolves overlap by **precedence**:
454///
455/// 1. [`Cancellation`](Self::Cancellation) — exact code 202.
456/// 2. [`DataAdvisory`](Self::DataAdvisory) — [`DATA_ADVISORY_CODES`]. Ahead of
457/// the ranges: 317 is inside [`ORDER_REJECTION_CODE_RANGE`] and 2188 inside
458/// [`WARNING_CODE_RANGE`].
459/// 3. [`Warning`](Self::Warning) — [`WARNING_CODE_RANGE`], code 399 with a `Warning:`
460/// line, or code 0 (a frame whose `error_code` field was absent on the wire).
461/// 4. [`SystemMessage`](Self::SystemMessage) — 1100, 1101, 1102, 1300.
462/// 5. [`RequestError`](Self::RequestError) — [`REQUEST_ERROR_CODES`]. Ahead of
463/// [`ORDER_REJECTION_CODE_RANGE`], which they sit inside.
464/// 6. [`OrderRejection`](Self::OrderRejection) — [`ORDER_REJECTION_CODE_RANGE`],
465/// excluding the cases above.
466/// 7. [`Error`](Self::Error) — everything else.
467///
468/// Each `Notice::is_*` category predicate ([`Notice::is_cancellation`],
469/// [`Notice::is_warning`], ...) is `category() == X` for its variant `X`, so the
470/// predicates are disjoint too.
471///
472/// Marked `#[non_exhaustive]` so IBKR can introduce new code ranges without a
473/// breaking release.
474///
475/// # Examples
476///
477/// ```no_run
478/// use ibapi::{Notice, NoticeCategory};
479/// # let notice: Notice = unimplemented!();
480/// match notice.category() {
481/// NoticeCategory::OrderRejection => eprintln!("rejected: {}", notice),
482/// NoticeCategory::RequestError => eprintln!("failed: {}", notice),
483/// NoticeCategory::Warning => eprintln!("warn: {}", notice),
484/// NoticeCategory::Error => eprintln!("error: {}", notice),
485/// _ => {}
486/// }
487/// ```
488#[non_exhaustive]
489#[derive(Clone, Copy, Debug, PartialEq, Eq)]
490pub enum NoticeCategory {
491 /// Order cancellation confirmation (exact code 202).
492 Cancellation,
493 /// Informational warning ([`WARNING_CODE_RANGE`], code 399 with a `Warning:`
494 /// line, code 165 "HMDS server connection was successful", or code 0 — a
495 /// code-less frame).
496 Warning,
497 /// Connectivity / system status (codes 1100, 1101, 1102, 1300).
498 SystemMessage,
499 /// Order rejection ([`ORDER_REJECTION_CODE_RANGE`], excluding the cases
500 /// above it in the precedence chain).
501 OrderRejection,
502 /// A request other than an order failed ([`REQUEST_ERROR_CODES`]): market
503 /// data, depth, historical data, scanner, session setup. Terminal.
504 RequestError,
505 /// Data advisory ([`DATA_ADVISORY_CODES`]): the request is not rejected and
506 /// data follows — a fallback, a partial entitlement, or a depth-book reset.
507 /// Informational.
508 DataAdvisory,
509 /// Any other error code.
510 Error,
511}
512
513/// Connectivity sub-state of a data-farm notice within [`WARNING_CODE_RANGE`].
514///
515/// Returned by [`Notice::connectivity_status`] (and [`ConnectivityStatus::from_code`]
516/// for a raw code) for the data-farm status codes; `None` for every other notice.
517/// Lets reconnect/health logic tell "farm came back online" from "farm went
518/// inactive" without re-parsing codes. Additive to [`NoticeCategory`], which still
519/// classifies all of [`WARNING_CODE_RANGE`] as [`NoticeCategory::Warning`]
520/// (bar the advisory 2188).
521///
522/// The code→state vocabulary follows IB's published Message Codes table:
523///
524/// | State | Codes | Wire meaning |
525/// |---|---|---|
526/// | [`Ok`](Self::Ok) | 2104, 2106, 2158 | …data farm connection is OK |
527/// | [`Broken`](Self::Broken) | 2103, 2105, 2157 | …data farm connection is broken |
528/// | [`Inactive`](Self::Inactive) | 2107, 2108 | …connection is inactive but should be available upon demand |
529/// | [`Connecting`](Self::Connecting) | 2119 | …data farm is connecting |
530///
531/// The variant set is closed: an unrecognized code classifies as `None`
532/// (via [`ConnectivityStatus::from_code`]), not a new variant — so callers can
533/// match exhaustively and handle unknowns through the `Option`.
534///
535/// # Examples
536///
537/// ```no_run
538/// use ibapi::{Notice, ConnectivityStatus};
539/// # let notice: Notice = unimplemented!();
540/// match notice.connectivity_status() {
541/// Some(ConnectivityStatus::Ok) => {/* farm healthy */}
542/// Some(ConnectivityStatus::Broken) => {/* link down */}
543/// Some(ConnectivityStatus::Inactive) => {/* dormant, available on demand */}
544/// Some(ConnectivityStatus::Connecting) => {/* reconnecting */}
545/// None => {/* not a data-farm notice */}
546/// }
547/// ```
548#[derive(Clone, Copy, Debug, PartialEq, Eq)]
549pub enum ConnectivityStatus {
550 /// Data-farm connection is OK.
551 Ok,
552 /// Data-farm connection is broken.
553 Broken,
554 /// Connection inactive but available upon demand.
555 Inactive,
556 /// Connection is in the process of connecting.
557 Connecting,
558}
559
560impl ConnectivityStatus {
561 /// Classify a raw TWS error code into a data-farm connectivity sub-state.
562 ///
563 /// Returns `Some(..)` for the data-farm status codes inside
564 /// [`WARNING_CODE_RANGE`]; `None` for every other code. Use this when you
565 /// hold a raw code; use [`Notice::connectivity_status`] when you hold a
566 /// [`Notice`].
567 ///
568 /// # Examples
569 ///
570 /// ```
571 /// use ibapi::ConnectivityStatus;
572 /// assert_eq!(ConnectivityStatus::from_code(2104), Some(ConnectivityStatus::Ok));
573 /// assert_eq!(ConnectivityStatus::from_code(2105), Some(ConnectivityStatus::Broken));
574 /// assert_eq!(ConnectivityStatus::from_code(500), None);
575 /// ```
576 pub fn from_code(code: i32) -> Option<ConnectivityStatus> {
577 if FARM_OK_CODES.contains(&code) {
578 Some(ConnectivityStatus::Ok)
579 } else if FARM_BROKEN_CODES.contains(&code) {
580 Some(ConnectivityStatus::Broken)
581 } else if FARM_INACTIVE_CODES.contains(&code) {
582 Some(ConnectivityStatus::Inactive)
583 } else if FARM_CONNECTING_CODES.contains(&code) {
584 Some(ConnectivityStatus::Connecting)
585 } else {
586 None
587 }
588 }
589}
590
591impl From<&ResponseMessage> for Notice {
592 /// Build a Notice from a protobuf Error frame; at floor 213 every Error
593 /// payload arrives proto-encoded. Returns `Notice::from(DecodedError::default())`
594 /// (empty / code 0) if the proto bytes are absent or undecodable.
595 fn from(message: &ResponseMessage) -> Notice {
596 let payload = message
597 .raw_bytes()
598 .and_then(crate::transport::routing::decode_error_envelope)
599 .unwrap_or_default();
600 Notice::from(payload)
601 }
602}
603
604impl Notice {
605 /// Build a client-synthesized notice with no wire timestamp and no
606 /// advanced-order-reject JSON. Used by the client-side observability
607 /// codes (see [`HANDSHAKE_UNKNOWN_FRAME_CODE`],
608 /// [`HANDSHAKE_DECODE_FAILURE_CODE`], [`UNKNOWN_MESSAGE_TYPE_CODE`],
609 /// [`SUBSCRIPTION_LAG_CODE`], [`NOTICE_STREAM_LAG_CODE`],
610 /// [`TRANSPORT_RECONNECT_CODE`]).
611 pub(crate) fn synthesized(code: i32, message: String) -> Notice {
612 Notice {
613 request_id: None,
614 code,
615 message,
616 error_time: None,
617 advanced_order_reject_json: String::new(),
618 }
619 }
620
621 /// Returns `true` if this notice indicates an order was cancelled (code 202).
622 ///
623 /// Code 202 is sent by TWS to confirm an order cancellation. This is an
624 /// informational message, not an error.
625 pub fn is_cancellation(&self) -> bool {
626 self.category() == NoticeCategory::Cancellation
627 }
628
629 /// Returns `true` if this is a warning message.
630 ///
631 /// Warnings are [`WARNING_CODE_RANGE`], code 399 with a `Warning:` line, and
632 /// code 0 — a frame whose `error_code` field was absent on the wire, which
633 /// IB Gateway sends for informational notices. Same as
634 /// `category() == NoticeCategory::Warning`.
635 pub fn is_warning(&self) -> bool {
636 self.category() == NoticeCategory::Warning
637 }
638
639 /// Returns `true` if this is a system/connectivity message (codes 1100-1102, 1300).
640 ///
641 /// System messages indicate connectivity status changes:
642 /// - 1100: Connectivity between IB and TWS lost
643 /// - 1101: Connectivity restored, market data lost (resubscribe needed)
644 /// - 1102: Connectivity restored, market data maintained
645 /// - 1300: Socket port reset during active connection
646 pub fn is_system_message(&self) -> bool {
647 self.category() == NoticeCategory::SystemMessage
648 }
649
650 /// Returns `true` if this is a data advisory ([`DATA_ADVISORY_CODES`]).
651 ///
652 /// Data advisories reject nothing: the request stands and data follows —
653 /// a fallback, a partial entitlement, or a depth-book reset. The
654 /// subscription stays open and the notice is informational, not an error.
655 /// This does not guarantee that every requested field will arrive.
656 pub fn is_data_advisory(&self) -> bool {
657 self.category() == NoticeCategory::DataAdvisory
658 }
659
660 /// Returns `true` if this is an informational notice (not an error).
661 ///
662 /// Informational notices include cancellation confirmations, warnings,
663 /// system/connectivity messages, and data advisories.
664 ///
665 /// A scanner subscription also yields TWS code 165 "no items retrieved"
666 /// (an empty scan) as a `SubscriptionItem::Notice` and stays open, although
667 /// that notice is not informational; don't end a scanner loop on
668 /// `!is_informational()` alone.
669 pub fn is_informational(&self) -> bool {
670 is_informational_code(self.code, &self.message)
671 }
672
673 /// Returns `true` if this is an error requiring attention.
674 ///
675 /// Returns `false` for informational messages like cancellation confirmations,
676 /// warnings, and system messages.
677 pub fn is_error(&self) -> bool {
678 !self.is_informational()
679 }
680
681 /// Returns `true` if this notice is an order rejection
682 /// ([`NoticeCategory::OrderRejection`]).
683 ///
684 /// # Examples
685 ///
686 /// ```no_run
687 /// use ibapi::Notice;
688 /// # let notice: Notice = unimplemented!();
689 /// if notice.is_order_rejection() {
690 /// eprintln!("rejection: {}", notice);
691 /// }
692 /// ```
693 pub fn is_order_rejection(&self) -> bool {
694 self.category() == NoticeCategory::OrderRejection
695 }
696
697 /// Returns `true` if a request other than an order failed
698 /// ([`REQUEST_ERROR_CODES`], [`NoticeCategory::RequestError`]).
699 ///
700 /// # Examples
701 ///
702 /// ```no_run
703 /// use ibapi::Notice;
704 /// # let notice: Notice = unimplemented!();
705 /// if notice.is_request_error() {
706 /// eprintln!("request failed: {}", notice);
707 /// }
708 /// ```
709 pub fn is_request_error(&self) -> bool {
710 self.category() == NoticeCategory::RequestError
711 }
712
713 /// Returns `true` if this notice was synthesized client-side during the
714 /// connection handshake — i.e. carries either
715 /// [`HANDSHAKE_UNKNOWN_FRAME_CODE`] (a recognized `IncomingMessages` kind with no
716 /// typed `StartupMessage` variant) or [`HANDSHAKE_DECODE_FAILURE_CODE`]
717 /// (a typed decoder failed on a known kind).
718 ///
719 /// Subscribers to [`Client::notice_stream`](crate::Client::notice_stream)
720 /// can use this to log + investigate without conflating with TWS-emitted
721 /// notices.
722 ///
723 /// # Examples
724 ///
725 /// ```no_run
726 /// use ibapi::Notice;
727 /// # let notice: Notice = unimplemented!();
728 /// if notice.is_handshake_synthetic() {
729 /// eprintln!("handshake observability: code={} {}", notice.code, notice);
730 /// }
731 /// ```
732 pub fn is_handshake_synthetic(&self) -> bool {
733 self.code == HANDSHAKE_UNKNOWN_FRAME_CODE || self.code == HANDSHAKE_DECODE_FAILURE_CODE
734 }
735
736 /// Returns `true` if rust-ibapi synthesized this notice rather than
737 /// receiving it from TWS: any negative code. TWS uses codes 0 and up.
738 ///
739 /// Covers [`HANDSHAKE_UNKNOWN_FRAME_CODE`], [`HANDSHAKE_DECODE_FAILURE_CODE`],
740 /// [`UNKNOWN_MESSAGE_TYPE_CODE`], [`SUBSCRIPTION_LAG_CODE`],
741 /// [`NOTICE_STREAM_LAG_CODE`] and [`TRANSPORT_RECONNECT_CODE`], and any
742 /// code added later. These all classify as [`NoticeCategory::Error`], so
743 /// use this predicate or the constants, not [`Notice::category`], to tell
744 /// them apart from TWS errors.
745 ///
746 /// # Examples
747 ///
748 /// ```no_run
749 /// use ibapi::Notice;
750 /// # let notice: Notice = unimplemented!();
751 /// if notice.is_client_synthesized() {
752 /// eprintln!("client-side condition: code={} {}", notice.code, notice);
753 /// }
754 /// ```
755 pub fn is_client_synthesized(&self) -> bool {
756 self.code < 0
757 }
758
759 /// Classify this notice into a disjoint [`NoticeCategory`].
760 ///
761 /// See [`NoticeCategory`] for the precedence chain.
762 ///
763 /// # Examples
764 ///
765 /// ```no_run
766 /// use ibapi::{Notice, NoticeCategory};
767 /// # let notice: Notice = unimplemented!();
768 /// let kind = match notice.category() {
769 /// NoticeCategory::Cancellation
770 /// | NoticeCategory::Warning
771 /// | NoticeCategory::DataAdvisory
772 /// | NoticeCategory::SystemMessage => "informational",
773 /// NoticeCategory::RequestError | NoticeCategory::OrderRejection | NoticeCategory::Error => "error",
774 /// _ => "unknown",
775 /// };
776 /// # let _ = kind;
777 /// ```
778 pub fn category(&self) -> NoticeCategory {
779 classify(self.code, &self.message)
780 }
781
782 /// Classify the data-farm connectivity sub-state of this notice.
783 ///
784 /// Returns `Some(..)` for the data-farm status codes inside
785 /// [`WARNING_CODE_RANGE`]; `None` for every other notice. Additive to
786 /// [`Notice::category`], which still classifies the farm codes as
787 /// [`NoticeCategory::Warning`]. Thin wrapper over
788 /// [`ConnectivityStatus::from_code`].
789 ///
790 /// # Examples
791 ///
792 /// ```no_run
793 /// use ibapi::{Notice, ConnectivityStatus};
794 /// # let notice: Notice = unimplemented!();
795 /// if notice.connectivity_status() == Some(ConnectivityStatus::Broken) {
796 /// eprintln!("data farm down: {notice}");
797 /// }
798 /// ```
799 pub fn connectivity_status(&self) -> Option<ConnectivityStatus> {
800 ConnectivityStatus::from_code(self.code)
801 }
802
803 /// Log severity for this notice, derived from [`Notice::category`] so that
804 /// every code in a category logs alike regardless of which numeric band it
805 /// sits in.
806 ///
807 /// Informational categories log at `warn` (the caller may want to act:
808 /// a fallback engaged, a book must be cleared), with three exceptions at
809 /// `info`: the cancellation confirmation, data-farm notices that need no
810 /// action, and 165 "HMDS server connection was successful". A farm that is
811 /// OK, `Inactive` ("…available upon demand") or `Connecting` is routine on
812 /// nearly every connect; only `Broken` warns.
813 /// System connectivity codes are graded by how much they matter: 1102
814 /// (restored, data maintained) → info; 1101 (restored, data lost —
815 /// resubscribe required) → warn; 1100 (connectivity lost) and 1300 (socket
816 /// reset) → error. Request errors, order rejections and errors log at `error`.
817 fn log_level(&self) -> log::Level {
818 use log::Level;
819 match self.category() {
820 NoticeCategory::Cancellation => Level::Info,
821 NoticeCategory::Warning
822 if matches!(
823 self.connectivity_status(),
824 Some(ConnectivityStatus::Ok | ConnectivityStatus::Inactive | ConnectivityStatus::Connecting)
825 ) =>
826 {
827 Level::Info
828 }
829 // A 165 classifies as Warning only in its HMDS-connected form.
830 NoticeCategory::Warning if self.code == HMDS_QUERY_MESSAGE_CODE => Level::Info,
831 NoticeCategory::Warning | NoticeCategory::DataAdvisory => Level::Warn,
832 NoticeCategory::SystemMessage => match self.code {
833 CONNECTIVITY_RESTORED_DATA_MAINTAINED_CODE => Level::Info,
834 CONNECTIVITY_RESTORED_DATA_LOST_CODE => Level::Warn,
835 _ => Level::Error,
836 },
837 NoticeCategory::RequestError | NoticeCategory::OrderRejection | NoticeCategory::Error => Level::Error,
838 }
839 }
840
841 /// Log this notice at [`Notice::log_level`].
842 pub(crate) fn log(&self) {
843 log::log!(self.log_level(), "{self}");
844 }
845}
846
847impl Display for Notice {
848 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
849 write!(f, "[{}] {}", self.code, self.message)
850 }
851}
852
853impl From<crate::transport::routing::DecodedError> for Notice {
854 /// Build a Notice from a dispatcher-decoded error payload, moving the
855 /// `error_message` and `advanced_order_reject_json` strings and converting
856 /// `error_time` (millis-since-epoch) to `OffsetDateTime`.
857 fn from(payload: crate::transport::routing::DecodedError) -> Notice {
858 let error_time = payload
859 .error_time
860 .and_then(|millis| OffsetDateTime::from_unix_timestamp_nanos(millis as i128 * 1_000_000).ok());
861 Notice {
862 request_id: (payload.request_id != crate::transport::routing::UNSPECIFIED_REQUEST_ID).then_some(payload.request_id),
863 code: payload.error_code,
864 message: payload.error_message,
865 error_time,
866 advanced_order_reject_json: payload.advanced_order_reject_json,
867 }
868 }
869}