Skip to main content

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}