Skip to main content

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}