ironfix_core/error.rs
1/******************************************************************************
2 Author: Joaquín Béjar García
3 Email: jb@taunais.com
4 Date: 27/1/26
5******************************************************************************/
6
7//! Error types for the IronFix FIX protocol engine.
8//!
9//! This module provides a unified error hierarchy using `thiserror` for typed,
10//! domain-specific errors across all IronFix operations.
11
12use thiserror::Error;
13
14/// Result type alias using [`FixError`] as the error type.
15pub type Result<T> = std::result::Result<T, FixError>;
16
17/// Top-level error type for all IronFix operations.
18#[derive(Debug, Error)]
19#[non_exhaustive]
20pub enum FixError {
21 /// Error during message decoding.
22 #[error("decode error: {0}")]
23 Decode(#[from] DecodeError),
24
25 /// Error during message encoding.
26 #[error("encode error: {0}")]
27 Encode(#[from] EncodeError),
28
29 /// Error in session layer operations.
30 #[error("session error: {0}")]
31 Session(#[from] SessionError),
32
33 /// Error in message store operations.
34 #[error("store error: {0}")]
35 Store(#[from] StoreError),
36
37 /// I/O error from underlying transport.
38 #[error("io error: {0}")]
39 Io(#[from] std::io::Error),
40}
41
42/// Errors that occur during FIX message decoding.
43#[derive(Debug, Error, Clone, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum DecodeError {
46 /// Message buffer is incomplete, need more data.
47 #[error("incomplete message, need more data")]
48 Incomplete,
49
50 /// Invalid BeginString field (tag 8).
51 #[error("invalid begin string: expected 8=FIX.x.y")]
52 InvalidBeginString,
53
54 /// Missing BodyLength field (tag 9).
55 #[error("missing body length field (tag 9)")]
56 MissingBodyLength,
57
58 /// Invalid BodyLength value.
59 #[error("invalid body length value")]
60 InvalidBodyLength,
61
62 /// Missing MsgType field (tag 35).
63 #[error("missing msg type field (tag 35)")]
64 MissingMsgType,
65
66 /// Checksum mismatch between calculated and declared values.
67 #[error("checksum mismatch: calculated {calculated}, declared {declared}")]
68 ChecksumMismatch {
69 /// Calculated checksum value.
70 calculated: u8,
71 /// Declared checksum value in message.
72 declared: u8,
73 },
74
75 /// Invalid tag format (not a valid integer).
76 #[error("invalid tag format: {0}")]
77 InvalidTag(String),
78
79 /// Missing required field.
80 #[error("missing required field: tag {tag}")]
81 MissingRequiredField {
82 /// The tag number of the missing field.
83 tag: u32,
84 },
85
86 /// Invalid field value for the expected type.
87 #[error("invalid field value for tag {tag}: {reason}")]
88 InvalidFieldValue {
89 /// The tag number of the field.
90 tag: u32,
91 /// Description of why the value is invalid.
92 reason: String,
93 },
94
95 /// Invalid UTF-8 in string field.
96 #[error("invalid utf-8 in field: {0}")]
97 InvalidUtf8(#[from] std::str::Utf8Error),
98
99 /// A field is not terminated by the SOH delimiter.
100 ///
101 /// Distinct from [`DecodeError::Incomplete`]: the tag was well formed, so
102 /// the bytes are structurally a field, but its value never ends.
103 #[error("unterminated field for tag {tag}: missing SOH delimiter")]
104 UnterminatedField {
105 /// The tag whose value is not terminated.
106 tag: u32,
107 },
108
109 /// A Length/Data field pair declares a byte count the frame cannot satisfy.
110 ///
111 /// Raised when the count declared by a `LENGTH` field (for example
112 /// `RawDataLength`, tag 95) runs past what the field may consume, or when
113 /// the byte at the declared end of the `DATA` field is not the SOH
114 /// delimiter.
115 #[error(
116 "data field {data_tag} declares {declared} bytes not terminated by SOH within {available} remaining bytes"
117 )]
118 InvalidDataLength {
119 /// The tag of the `DATA` field being framed.
120 data_tag: u32,
121 /// The byte count declared by the paired `LENGTH` field.
122 declared: usize,
123 /// Bytes the field was allowed to consume: what remains after the `=`
124 /// delimiter, bounded by the frame's declared body end when decoding a
125 /// whole message. The terminating SOH must fall inside this.
126 available: usize,
127 },
128
129 /// A stored byte range does not lie within the message buffer.
130 ///
131 /// Guards the offset bookkeeping in [`crate::message::RawMessage`], whose
132 /// ranges are buffer-relative.
133 #[error("range {start}..{end} is out of bounds for a {buffer_len}-byte buffer")]
134 RangeOutOfBounds {
135 /// Start offset of the offending range.
136 start: usize,
137 /// End offset of the offending range.
138 end: usize,
139 /// Length of the buffer the range was applied to.
140 buffer_len: usize,
141 },
142
143 /// The MsgType value (tag 35) is not a representable message type.
144 ///
145 /// Raised when the bytes of tag 35 are empty, longer than
146 /// [`crate::message::MSG_TYPE_MAX_LEN`], or contain a byte that cannot
147 /// appear in a MsgType. The frame is rejected rather than routed under a
148 /// truncated or defaulted message type.
149 #[error("invalid msg type (tag 35): {0}")]
150 InvalidMsgType(#[from] MsgTypeError),
151}
152
153/// Errors that occur during FIX message encoding.
154#[derive(Debug, Error, Clone, PartialEq, Eq)]
155#[non_exhaustive]
156pub enum EncodeError {
157 /// Buffer capacity exceeded during encoding.
158 #[error("buffer overflow: need {needed} bytes, have {available}")]
159 BufferOverflow {
160 /// Bytes needed to complete encoding.
161 needed: usize,
162 /// Bytes available in buffer.
163 available: usize,
164 },
165
166 /// Missing required field during encoding.
167 #[error("missing required field: tag {tag}")]
168 MissingRequiredField {
169 /// The tag number of the missing field.
170 tag: u32,
171 },
172
173 /// Invalid field value for encoding.
174 #[error("invalid field value for tag {tag}: {reason}")]
175 InvalidFieldValue {
176 /// The tag number of the field.
177 tag: u32,
178 /// Description of why the value is invalid.
179 reason: String,
180 },
181
182 /// Field value exceeds maximum length.
183 #[error("field value too long for tag {tag}: {length} exceeds max {max_length}")]
184 FieldTooLong {
185 /// The tag number of the field.
186 tag: u32,
187 /// Actual length of the value.
188 length: usize,
189 /// Maximum allowed length.
190 max_length: usize,
191 },
192}
193
194/// Errors in FIX session layer operations.
195#[derive(Debug, Error, Clone, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum SessionError {
198 /// Session is not in the correct state for the operation.
199 #[error("invalid session state: expected {expected}, current {current}")]
200 InvalidState {
201 /// Expected state for the operation.
202 expected: String,
203 /// Current session state.
204 current: String,
205 },
206
207 /// Logon was rejected by counterparty.
208 #[error("logon rejected: {reason}")]
209 LogonRejected {
210 /// Reason for rejection.
211 reason: String,
212 },
213
214 /// Heartbeat timeout - no response to TestRequest.
215 #[error("heartbeat timeout after {elapsed_ms} milliseconds")]
216 HeartbeatTimeout {
217 /// Elapsed time in milliseconds since last message.
218 elapsed_ms: u64,
219 },
220
221 /// Sequence number gap detected.
222 #[error("sequence gap detected: expected {expected}, received {received}")]
223 SequenceGap {
224 /// Expected sequence number.
225 expected: u64,
226 /// Received sequence number.
227 received: u64,
228 },
229
230 /// Sequence number too low (possible duplicate).
231 #[error("sequence too low: expected >= {expected}, received {received}")]
232 SequenceTooLow {
233 /// Minimum expected sequence number.
234 expected: u64,
235 /// Received sequence number.
236 received: u64,
237 },
238
239 /// Message rejected by counterparty.
240 #[error("message rejected: ref_seq={ref_seq_num}, reason={reason}")]
241 MessageRejected {
242 /// Reference sequence number of rejected message.
243 ref_seq_num: u64,
244 /// Rejection reason.
245 reason: String,
246 },
247
248 /// Resend request for unavailable messages.
249 #[error("resend request for unavailable range: {begin}..{end}")]
250 ResendUnavailable {
251 /// Begin sequence number of requested range.
252 begin: u64,
253 /// End sequence number of requested range.
254 end: u64,
255 },
256
257 /// Session configuration error.
258 #[error("configuration error: {0}")]
259 Configuration(String),
260
261 /// Connection error.
262 #[error("connection error: {0}")]
263 Connection(String),
264}
265
266/// Errors in message store operations.
267#[derive(Debug, Error, Clone, PartialEq, Eq)]
268#[non_exhaustive]
269pub enum StoreError {
270 /// Failed to store message.
271 #[error("failed to store message seq={seq_num}: {reason}")]
272 StoreFailed {
273 /// Sequence number of the message.
274 seq_num: u64,
275 /// Reason for failure.
276 reason: String,
277 },
278
279 /// Failed to retrieve message.
280 #[error("failed to retrieve message seq={seq_num}: {reason}")]
281 RetrieveFailed {
282 /// Sequence number of the message.
283 seq_num: u64,
284 /// Reason for failure.
285 reason: String,
286 },
287
288 /// Message not found in store.
289 #[error("message not found: seq={seq_num}")]
290 NotFound {
291 /// Sequence number of the missing message.
292 seq_num: u64,
293 },
294
295 /// No message is available anywhere in the requested range.
296 ///
297 /// The bounds are **inclusive**, which is how a FIX `ResendRequest`
298 /// expresses them. A half-open `Range<u64>` cannot represent an upper
299 /// bound of `u64::MAX` — the "to infinity" case a `ResendRequest` with
300 /// `EndSeqNo` (16) = 0 asks for — without an `end + 1` that overflows, so
301 /// the bounds are carried as two numbers and no arithmetic is performed on
302 /// them.
303 #[error("messages not available for range: {begin}..={end}")]
304 RangeNotAvailable {
305 /// First requested sequence number, inclusive.
306 begin: u64,
307 /// Last requested sequence number, inclusive.
308 end: u64,
309 },
310
311 /// The requested range is inverted: `begin` is above `end`.
312 ///
313 /// This is a caller error, not an empty result. It is reported rather than
314 /// normalised because a store cannot tell an inverted range apart from a
315 /// range that happens to hold nothing, and silently answering "empty"
316 /// hides the bug that produced it.
317 #[error("invalid range: begin {begin} is above end {end}")]
318 InvalidRange {
319 /// First requested sequence number, inclusive.
320 begin: u64,
321 /// Last requested sequence number, inclusive.
322 end: u64,
323 },
324
325 /// Store is corrupted.
326 #[error("store corrupted: {reason}")]
327 Corrupted {
328 /// Description of the corruption.
329 reason: String,
330 },
331
332 /// I/O error in persistent store.
333 #[error("store i/o error: {0}")]
334 Io(String),
335}
336
337/// Rejection reasons for [`crate::types::CompId`] construction.
338///
339/// A CompID is written verbatim into SenderCompID (49) and TargetCompID (56)
340/// on every outbound message, so an empty value or one carrying SOH or another
341/// control byte would produce a malformed or injectable frame. Construction is
342/// the chokepoint that makes that unrepresentable.
343#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
344#[non_exhaustive]
345pub enum CompIdError {
346 /// The value is empty.
347 ///
348 /// An empty CompID would encode as `49=<SOH>` (or `56=<SOH>`), a field
349 /// with no value that FIX TagValue treats as malformed.
350 #[error("comp id is empty")]
351 Empty,
352
353 /// The value does not fit in the fixed inline storage.
354 #[error("comp id is {len} bytes, exceeding the {max_len}-byte inline storage bound")]
355 TooLong {
356 /// Length of the offered value in bytes.
357 len: usize,
358 /// Maximum length in bytes, [`crate::types::COMP_ID_MAX_LEN`].
359 max_len: usize,
360 },
361
362 /// The value contains a byte outside printable ASCII (`0x20..=0x7e`).
363 #[error(
364 "comp id contains illegal byte {byte:#04x} at offset {position}: \
365 only printable ASCII (0x20..=0x7e) is allowed"
366 )]
367 IllegalByte {
368 /// The offending byte.
369 byte: u8,
370 /// Zero-based offset of the offending byte within the value.
371 position: usize,
372 },
373}
374
375/// Rejection reasons for [`crate::message::MsgType`] construction.
376///
377/// A MsgType decides which handler a message reaches and is written verbatim
378/// into tag 35 of every outbound message, so a value that cannot be
379/// represented exactly is rejected at construction. Truncating it would
380/// silently reroute the message to the handler for a different, shorter code.
381#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
382#[non_exhaustive]
383pub enum MsgTypeError {
384 /// The value is empty; tag 35 always carries at least one byte.
385 #[error("msg type is empty")]
386 Empty,
387
388 /// The value does not fit in the fixed inline storage.
389 #[error("msg type is {len} bytes, exceeding the {max_len}-byte inline storage bound")]
390 TooLong {
391 /// Length of the offered value in bytes.
392 len: usize,
393 /// Maximum length in bytes, [`crate::message::MSG_TYPE_MAX_LEN`].
394 max_len: usize,
395 },
396
397 /// The value contains a byte outside printable ASCII — a control byte
398 /// (SOH included) or a non-ASCII byte.
399 ///
400 /// `=` and space are **not** illegal here: both are legal inside a FIX
401 /// field value, so a bilaterally agreed MsgType carrying either is accepted.
402 #[error(
403 "msg type contains illegal byte {byte:#04x} at offset {position}: \
404 only printable ASCII (0x20..=0x7e) is allowed"
405 )]
406 IllegalByte {
407 /// The offending byte.
408 byte: u8,
409 /// Zero-based offset of the offending byte within the value.
410 position: usize,
411 },
412}
413
414/// Rejection reasons for [`crate::types::Timestamp`] construction.
415///
416/// A `Timestamp` counts nanoseconds since the Unix epoch as an unsigned value
417/// bounded by [`crate::types::Timestamp::MAX_NANOS`], so instants before
418/// 1970-01-01 and after 2262-04-11 are not representable.
419#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
420#[non_exhaustive]
421pub enum TimestampError {
422 /// The nanosecond count exceeds the representable range.
423 #[error("{nanos} nanoseconds since the epoch exceeds the maximum {max_nanos}")]
424 NanosOutOfRange {
425 /// The offered nanosecond count.
426 nanos: u64,
427 /// The largest representable nanosecond count.
428 max_nanos: u64,
429 },
430
431 /// The millisecond count overflows when scaled to nanoseconds.
432 #[error("{millis} milliseconds since the epoch is not representable in nanoseconds")]
433 MillisOutOfRange {
434 /// The offered millisecond count.
435 millis: u64,
436 },
437
438 /// A calendar instant falls outside the representable range — before the
439 /// Unix epoch, or past the nanosecond ceiling.
440 #[error("instant at {seconds} seconds from the epoch is outside the representable range")]
441 InstantOutOfRange {
442 /// Whole seconds from the Unix epoch, negative before 1970.
443 seconds: i64,
444 },
445}
446
447/// A number that is not a legal FIX field tag.
448///
449/// FIX tags are positive integers; `0` is neither a standard nor a
450/// user-defined tag.
451#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
452#[error("{tag} is not a legal FIX field tag: tags are positive integers starting at 1")]
453pub struct InvalidFieldTag {
454 tag: u32,
455}
456
457impl InvalidFieldTag {
458 /// Creates the error for an offending tag number.
459 #[inline]
460 #[must_use]
461 pub const fn new(tag: u32) -> Self {
462 Self { tag }
463 }
464
465 /// Returns the offending tag number.
466 #[inline]
467 #[must_use]
468 pub const fn tag(self) -> u32 {
469 self.tag
470 }
471}
472
473/// A byte that is not a legal Side (tag 54) code.
474#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
475#[error("{value:#04x} is not a FIX Side (tag 54) code")]
476pub struct InvalidSide {
477 value: u8,
478}
479
480impl InvalidSide {
481 /// Creates the error for an offending byte.
482 #[inline]
483 #[must_use]
484 pub const fn new(value: u8) -> Self {
485 Self { value }
486 }
487
488 /// Returns the offending byte.
489 #[inline]
490 #[must_use]
491 pub const fn value(self) -> u8 {
492 self.value
493 }
494}
495
496/// A string that names no [`FixVersion`](crate::version::FixVersion).
497///
498/// An unrecognised version cannot be framed: its `BeginString` (8) and, for a
499/// FIXT session, its `DefaultApplVerID` (1137) are unknown. Callers must
500/// surface this rather than substitute a default, which would put a fabricated
501/// version on the wire.
502#[derive(Debug, Error, Clone, PartialEq, Eq)]
503#[error("'{value}' is not a known FIX version")]
504pub struct UnknownFixVersion {
505 value: String,
506}
507
508impl UnknownFixVersion {
509 /// Creates the error for an offending version string.
510 #[inline]
511 #[must_use]
512 pub fn new(value: &str) -> Self {
513 Self {
514 value: value.to_owned(),
515 }
516 }
517
518 /// Returns the offending version string.
519 #[inline]
520 #[must_use]
521 pub fn value(&self) -> &str {
522 &self.value
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn test_decode_error_display() {
532 let err = DecodeError::ChecksumMismatch {
533 calculated: 100,
534 declared: 200,
535 };
536 assert_eq!(
537 err.to_string(),
538 "checksum mismatch: calculated 100, declared 200"
539 );
540 }
541
542 #[test]
543 fn test_fix_error_from_decode() {
544 let decode_err = DecodeError::Incomplete;
545 let fix_err: FixError = decode_err.into();
546 assert!(matches!(fix_err, FixError::Decode(DecodeError::Incomplete)));
547 }
548
549 #[test]
550 fn test_session_error_display() {
551 let err = SessionError::SequenceGap {
552 expected: 5,
553 received: 10,
554 };
555 assert_eq!(
556 err.to_string(),
557 "sequence gap detected: expected 5, received 10"
558 );
559 }
560
561 #[test]
562 fn test_store_error_display() {
563 let err = StoreError::NotFound { seq_num: 42 };
564 assert_eq!(err.to_string(), "message not found: seq=42");
565 }
566}