ironfix_engine/error.rs
1/******************************************************************************
2 Author: Joaquín Béjar García
3 Email: jb@taunais.com
4 Date: 14/7/26
5******************************************************************************/
6
7//! Engine error types.
8
9use ironfix_core::error::{DecodeError, EncodeError, StoreError};
10use ironfix_session::config::SessionConfigError;
11use ironfix_session::sequence::{SequenceCounter, SequenceExhausted};
12use ironfix_transport::CodecError;
13use std::time::Duration;
14
15/// Errors produced by the engine transport layer.
16#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum EngineError {
19 /// Underlying I/O failure.
20 #[error("io error: {0}")]
21 Io(#[from] std::io::Error),
22
23 /// Framing or checksum failure at the codec layer.
24 #[error("codec error: {0}")]
25 Codec(#[from] CodecError),
26
27 /// Failure decoding a framed FIX message.
28 #[error("decode error: {0}")]
29 Decode(#[from] DecodeError),
30
31 /// A message could not be encoded into a legal frame.
32 ///
33 /// Raised when a field value has no on-the-wire form — a value carrying the
34 /// SOH delimiter, or an empty one. The encoder refuses to stamp such a
35 /// frame rather than emit one whose `BodyLength` and `CheckSum` are correct
36 /// for corrupted bytes.
37 #[error("encode error: {0}")]
38 Encode(#[from] EncodeError),
39
40 /// The session configuration is not usable.
41 ///
42 /// Checked before the socket is dialled: an out-of-range knob — a
43 /// fractional `HeartBtInt`, an identity string carrying SOH or `=`, a zero
44 /// timeout — would otherwise corrupt the session's own messages.
45 /// [`ironfix_session::SessionConfigBuilder`] reports the same errors at
46 /// configuration time.
47 #[error("invalid session configuration: {0}")]
48 Config(#[from] SessionConfigError),
49
50 /// TCP connect did not complete within the configured timeout.
51 #[error("connect timed out after {0:?}")]
52 ConnectTimeout(Duration),
53
54 /// The Logon acknowledgement did not arrive within the logon timeout.
55 #[error("logon timed out after {0:?}")]
56 LogonTimeout(Duration),
57
58 /// The counterparty rejected the Logon.
59 #[error("logon rejected: {reason}")]
60 LogonRejected {
61 /// Text supplied by the counterparty, or a generic description.
62 reason: String,
63 },
64
65 /// The `HeartBtInt` (108) on the Logon acknowledgement could not be adopted
66 /// as the session's heartbeat interval.
67 ///
68 /// Raised when the ack omits the required field, carries a non-numeric
69 /// value, or confirms an interval above
70 /// [`ironfix_session::heartbeat::MAX_HEARTBEAT_INTERVAL_SECS`] — the value
71 /// drives every liveness timer in the session and is counterparty
72 /// controlled, so an unbounded one is refused. `108=0` is legal and never
73 /// raises this — it means "do not heartbeat".
74 #[error("unsupported heartbeat interval: {detail}")]
75 HeartbeatInterval {
76 /// Why the confirmed `HeartBtInt` was refused.
77 detail: String,
78 },
79
80 /// An unexpected message type arrived while awaiting the Logon
81 /// acknowledgement.
82 #[error("unexpected message during logon: 35={msg_type}")]
83 UnexpectedMessage {
84 /// The received MsgType (tag 35) value.
85 msg_type: String,
86 },
87
88 /// A sequence number violation that is fatal for the session.
89 #[error("sequence error: {0}")]
90 Sequence(String),
91
92 /// A sequence counter reached `u64::MAX`. No further messages can be
93 /// numbered until the session performs a sequence reset.
94 #[error(transparent)]
95 SequenceExhausted(#[from] SequenceExhausted),
96
97 /// A seeded initial sequence number was zero.
98 ///
99 /// FIX numbers messages from 1; a seeded `MsgSeqNum` (34) of 0 would be
100 /// rejected by every conforming counterparty. Checked before the socket is
101 /// dialled. Set through
102 /// [`Initiator::with_initial_sequences`](crate::Initiator::with_initial_sequences).
103 #[error("initial {counter} sequence number must be at least 1, was 0")]
104 InvalidInitialSequence {
105 /// Which seeded counter was zero.
106 counter: SequenceCounter,
107 },
108
109 /// The counterparty's identity fields (49/56, and 50/57 when
110 /// configured) did not match the session configuration.
111 #[error("identity mismatch: {detail}")]
112 IdentityMismatch {
113 /// Which field mismatched, with the expected and received values.
114 detail: String,
115 },
116
117 /// The Logon acknowledgement carried a `BeginString` (8) that does not
118 /// match the configured session version.
119 ///
120 /// For a FIX 5.0 / FIXT.1.1 session the configured transport
121 /// `BeginString` is `FIXT.1.1`, so an ack tagged `FIX.5.0*` — or any
122 /// other version — is not this session's acknowledgement and aborts the
123 /// handshake.
124 #[error("begin string mismatch: expected {expected}, received {received}")]
125 BeginStringMismatch {
126 /// The configured transport `BeginString`.
127 expected: String,
128 /// The `BeginString` the counterparty sent on the Logon ack.
129 received: String,
130 },
131
132 /// The counterparty's `SendingTime` (52) failed validation: absent,
133 /// unparseable, or further from the local clock than
134 /// `SessionConfig::sending_time_tolerance` allows.
135 #[error("SendingTime problem: {detail}")]
136 SendingTime {
137 /// Which check failed, with the offending value or the measured skew.
138 detail: String,
139 },
140
141 /// The configured `BeginString` cannot be framed conformantly.
142 ///
143 /// An unknown version, or `FIXT.1.1` on its own — which names the
144 /// transport version but no application version for the required
145 /// `DefaultApplVerID` (1137).
146 #[error("unsupported FIX version {version}: {detail}")]
147 UnsupportedVersion {
148 /// The configured version string.
149 version: String,
150 /// Why it cannot be framed.
151 detail: String,
152 },
153
154 /// A message-store operation the session depends on failed during setup.
155 ///
156 /// Raised by [`Initiator::connect`](crate::Initiator::connect) when the
157 /// store cannot be reset for a `ResetSeqNumFlag` (141) Logon, or refreshed to
158 /// recover its counters, before the session starts. Continuing anyway would
159 /// either file a new stream on top of a previous one's numbers or reuse a
160 /// `MsgSeqNum` the counterparty has already seen, so the session is refused
161 /// rather than started from a counter the store could not vouch for.
162 #[error("store error: {0}")]
163 Store(#[from] StoreError),
164
165 /// A write to the counterparty did not complete within the write timeout.
166 ///
167 /// A peer that stops reading parks a socket write forever once its receive
168 /// window closes, which would take the reactor's liveness timers with it.
169 /// The write is therefore bounded and its expiry closes the session, which
170 /// is the same verdict heartbeat detection would have reached.
171 #[error("write timed out after {0:?}")]
172 WriteTimeout(Duration),
173
174 /// An administrative MsgType was offered on the application send path.
175 ///
176 /// Logon (A), Logout (5), SequenceReset (4) and the rest of the
177 /// administrative set belong to the session state machine. One emitted
178 /// through [`Connection::send`](crate::Connection::send) would bypass the
179 /// typestate and the engine's phase tracking — a Logout sent that way, for
180 /// instance, never arms the logout timeout.
181 #[error("MsgType {msg_type} is administrative and belongs to the session layer")]
182 ReservedMsgType {
183 /// The offered MsgType (tag 35) value.
184 msg_type: String,
185 },
186
187 /// An outbound body carried a tag the engine stamps itself.
188 ///
189 /// See [`crate::outbound::RESERVED_TAGS`]. The frame would carry two
190 /// occurrences of the tag, which a conforming counterparty rejects or
191 /// misparses.
192 #[error(
193 "tag {tag} is stamped by the engine's standard header or trailer and must not be set on \
194 an outbound message"
195 )]
196 ReservedTag {
197 /// The offending tag.
198 tag: u32,
199 },
200
201 /// An outbound field value has no legal wire form.
202 ///
203 /// The reason never quotes the value: an outbound Logon body carries
204 /// `Password` (554) and `NewPassword` (925).
205 #[error("invalid outbound field {tag}: {reason}")]
206 InvalidField {
207 /// The offending tag.
208 tag: u32,
209 /// Why the value cannot be framed.
210 reason: String,
211 },
212
213 /// An administrative message lost a field its MsgType cannot go out without.
214 ///
215 /// A `to_admin` callback removed a required body field — `HeartBtInt` (108)
216 /// from a Logon, `TestReqID` (112) from a TestRequest, `NewSeqNo` (36) from
217 /// a SequenceReset, and the like. Emitting the message anyway would put a
218 /// malformed administrative frame on the wire that a conforming
219 /// counterparty rejects; the session refuses it instead.
220 #[error("administrative message {msg_type} is missing required field {tag}")]
221 MissingRequiredField {
222 /// The administrative MsgType (tag 35) value.
223 msg_type: String,
224 /// The required tag that is absent.
225 tag: u32,
226 },
227
228 /// The connection is closed; no more messages can be sent.
229 #[error("connection closed")]
230 Closed,
231
232 /// The builder was asked to produce an engine it was not configured for —
233 /// for example a terminal method called with no session, or with more than
234 /// one where a single-session engine is required.
235 #[error("engine configuration error: {0}")]
236 Configuration(String),
237}