Skip to main content

weir_core/
nack.rs

1//! The [`NackReason`] byte that prefixes every Nack message payload.
2
3/// Reason byte carried as the first byte of every Nack message payload.
4/// Wire values are fixed and must not change without a WIRE_VERSION bump.
5///
6/// VersionMismatch Nack payload format: `[NackReason::VersionMismatch (0x02), daemon_wire_version (u8)]`.
7/// The second byte lets the client produce a specific error:
8/// "daemon is on wire protocol v1; this client is built against v2 — upgrade the daemon or downgrade the client."
9#[repr(u8)]
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11// Reason bytes 0x0A-0xFF are reserved for future use within wire v1 (see
12// wire_protocol.md): a new reason can be added in a minor release without a
13// WIRE_VERSION bump. #[non_exhaustive] makes that growth non-breaking for
14// downstream matchers (they must already carry a wildcard arm), matching the
15// rationale on its decode-side twin DecodeError. Adding it later would itself be
16// breaking, so it lands before 1.x ships. The wire stays forward-compatible
17// regardless: an unknown byte surfaces as ClientError::UnknownNack(u8), not a
18// new variant.
19//
20// `MessageType` USED to be left exhaustive here on the grounds that "changing
21// them requires a WIRE_VERSION bump (a major event)". Both halves of that were
22// wrong and are now disproved by the tree: 3.0 added PushTracked/AckTracked and
23// 4.0 added PushBatch/AckBatch, all with WIRE_VERSION still 1, because a new
24// message type is additive on the wire exactly as a new reason byte is. What it
25// was NOT is additive in the Rust API, which is what forced 3.0.0 — so
26// `MessageType` carries `#[non_exhaustive]` too as of 4.0.0. `Durability` stays
27// exhaustive on its own merits: 0x02 is retired, no tier is planned, and
28// matching a tier exhaustively against a config string is a legitimate use.
29#[non_exhaustive]
30pub enum NackReason {
31    /// Frame did not start with the `b"WEIR"` magic.
32    BadMagic = 0x01,
33    /// Frame's version byte did not equal the daemon's `WIRE_VERSION`. The Nack
34    /// payload's second byte carries the daemon's version (see above).
35    VersionMismatch = 0x02,
36    /// Header CRC32 did not match the header bytes.
37    BadHeaderCrc = 0x03,
38    /// Declared `payload_len` exceeded the daemon's effective cap.
39    PayloadTooLarge = 0x04,
40    /// Payload CRC32 did not match the payload bytes.
41    BadPayloadCrc = 0x05,
42    /// The daemon hit an internal error (e.g. queue saturation); transient.
43    InternalError = 0x06,
44    /// The push carried a zero-length payload, which the WAB cannot represent:
45    /// an empty record's length prefix is four zero bytes, identical to the
46    /// end-of-records sentinel, so storing one would truncate the segment.
47    /// Rejected at ingest.
48    EmptyPayload = 0x07,
49    /// The frame's header was structurally valid (magic, version, and header CRC
50    /// all passed) but carried a `message_type` or `durability` byte this daemon
51    /// does not recognise — a PERMANENT client protocol error (typically version
52    /// skew). Distinct from `InternalError` (a transient daemon-side condition
53    /// that keeps the connection open): the daemon closes the connection after
54    /// this Nack. Retrying the identical frame will not succeed (F25).
55    UnknownMessage = 0x08,
56    /// The frame's header was structurally valid but set one or more bits in the
57    /// reserved `flags` byte, which must be zero in wire v1. A daemon rejects
58    /// such a frame rather than silently ignoring a flag it does not understand
59    /// (which could mean a producer believed a semantic flag took effect when it
60    /// did not). Permanent; the daemon closes the connection (F52).
61    ReservedFlagsSet = 0x09,
62    /// A `PushBatch` body could not be parsed: bad batch version, zero records,
63    /// a record length that disagrees with the body, or trailing bytes.
64    ///
65    /// `0x0B`, not `0x0A`. `0x0A` is this tree's worked example of an
66    /// *unassigned* reason — a `try_from` test asserts it errors, a client test
67    /// asserts it surfaces as `UnknownNack(0x0A)`, four polyglot clients branch
68    /// on `>= 0x0A`, and the frozen vector `nack_reserved_reason` IS `0x0A`.
69    /// Assigning it would break a frozen vector, which the freeze forbids.
70    BadBatchFraming = 0x0B,
71}
72
73impl NackReason {
74    /// The canonical snake_case metric-label string for this reason.
75    ///
76    /// These strings match the daemon's Prometheus `reason` label values exactly
77    /// (the `nack_total{reason=...}` series), so an ops consumer — an autoscaler,
78    /// an alerting rule, a dashboard — can map a `NackReason` it receives over the
79    /// wire to the metric label it must query, without hard-coding the strings.
80    /// Inverse of [`NackReason::from_metric_label`].
81    ///
82    /// ```
83    /// use weir_core::NackReason;
84    /// assert_eq!(NackReason::PayloadTooLarge.as_metric_label(), "payload_too_large");
85    /// ```
86    #[must_use]
87    pub fn as_metric_label(&self) -> &'static str {
88        match self {
89            NackReason::BadMagic => "bad_magic",
90            NackReason::VersionMismatch => "version_mismatch",
91            NackReason::BadHeaderCrc => "bad_header_crc",
92            NackReason::PayloadTooLarge => "payload_too_large",
93            NackReason::BadPayloadCrc => "bad_payload_crc",
94            NackReason::InternalError => "internal_error",
95            NackReason::EmptyPayload => "empty_payload",
96            NackReason::UnknownMessage => "unknown_message",
97            NackReason::ReservedFlagsSet => "reserved_flags_set",
98            NackReason::BadBatchFraming => "bad_batch_framing",
99        }
100    }
101
102    /// Parses a snake_case metric label back into its [`NackReason`], or `None` if
103    /// the string is not a known label. Inverse of
104    /// [`NackReason::as_metric_label`].
105    ///
106    /// ```
107    /// use weir_core::NackReason;
108    /// assert_eq!(
109    ///     NackReason::from_metric_label("payload_too_large"),
110    ///     Some(NackReason::PayloadTooLarge),
111    /// );
112    /// assert_eq!(NackReason::from_metric_label("nope"), None);
113    /// ```
114    #[must_use]
115    pub fn from_metric_label(s: &str) -> Option<NackReason> {
116        match s {
117            "bad_magic" => Some(NackReason::BadMagic),
118            "version_mismatch" => Some(NackReason::VersionMismatch),
119            "bad_header_crc" => Some(NackReason::BadHeaderCrc),
120            "payload_too_large" => Some(NackReason::PayloadTooLarge),
121            "bad_payload_crc" => Some(NackReason::BadPayloadCrc),
122            "internal_error" => Some(NackReason::InternalError),
123            "empty_payload" => Some(NackReason::EmptyPayload),
124            "unknown_message" => Some(NackReason::UnknownMessage),
125            "reserved_flags_set" => Some(NackReason::ReservedFlagsSet),
126            "bad_batch_framing" => Some(NackReason::BadBatchFraming),
127            _ => None,
128        }
129    }
130}
131
132impl From<NackReason> for u8 {
133    /// The wire byte for this reason. Inverse of [`NackReason::try_from`].
134    fn from(reason: NackReason) -> u8 {
135        reason as u8
136    }
137}
138
139impl std::fmt::Display for NackReason {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        let msg = match self {
142            NackReason::BadMagic => "bad frame magic",
143            NackReason::VersionMismatch => "wire protocol version mismatch",
144            NackReason::BadHeaderCrc => "header CRC mismatch",
145            NackReason::PayloadTooLarge => "payload exceeds the daemon's cap",
146            NackReason::BadPayloadCrc => "payload CRC mismatch",
147            NackReason::InternalError => "daemon internal error (transient)",
148            NackReason::EmptyPayload => "empty payload (not representable)",
149            NackReason::UnknownMessage => "unknown message type or durability byte",
150            NackReason::ReservedFlagsSet => "reserved flags byte was non-zero",
151            NackReason::BadBatchFraming => "batch body could not be parsed",
152        };
153        write!(f, "{msg}")
154    }
155}
156
157// A Nack reason is a legitimate error a producer can propagate, so it implements
158// the std error trait (Display + Debug are both present). Lets callers do
159// `Box<dyn Error>`/`?` with a `NackReason` directly.
160impl std::error::Error for NackReason {}
161
162/// Error returned when a `u8` does not map to a known `NackReason` variant.
163/// Preserves the raw byte so the client can log or display it.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub struct UnknownNackReason(pub u8);
166
167impl std::fmt::Display for UnknownNackReason {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        write!(f, "unknown nack reason byte: {:#04x}", self.0)
170    }
171}
172
173impl std::error::Error for UnknownNackReason {}
174
175impl TryFrom<u8> for NackReason {
176    type Error = UnknownNackReason;
177
178    fn try_from(value: u8) -> Result<Self, Self::Error> {
179        match value {
180            0x01 => Ok(NackReason::BadMagic),
181            0x02 => Ok(NackReason::VersionMismatch),
182            0x03 => Ok(NackReason::BadHeaderCrc),
183            0x04 => Ok(NackReason::PayloadTooLarge),
184            0x05 => Ok(NackReason::BadPayloadCrc),
185            0x06 => Ok(NackReason::InternalError),
186            0x07 => Ok(NackReason::EmptyPayload),
187            0x08 => Ok(NackReason::UnknownMessage),
188            0x09 => Ok(NackReason::ReservedFlagsSet),
189            0x0B => Ok(NackReason::BadBatchFraming),
190            v => Err(UnknownNackReason(v)),
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::version::WIRE_VERSION;
199
200    #[test]
201    fn try_from_accepts_all_known_reasons() {
202        assert_eq!(NackReason::try_from(0x01).unwrap(), NackReason::BadMagic);
203        assert_eq!(
204            NackReason::try_from(0x02).unwrap(),
205            NackReason::VersionMismatch
206        );
207        assert_eq!(
208            NackReason::try_from(0x03).unwrap(),
209            NackReason::BadHeaderCrc
210        );
211        assert_eq!(
212            NackReason::try_from(0x04).unwrap(),
213            NackReason::PayloadTooLarge
214        );
215        assert_eq!(
216            NackReason::try_from(0x05).unwrap(),
217            NackReason::BadPayloadCrc
218        );
219        assert_eq!(
220            NackReason::try_from(0x06).unwrap(),
221            NackReason::InternalError
222        );
223        assert_eq!(
224            NackReason::try_from(0x07).unwrap(),
225            NackReason::EmptyPayload
226        );
227        assert_eq!(
228            NackReason::try_from(0x08).unwrap(),
229            NackReason::UnknownMessage
230        );
231        assert_eq!(
232            NackReason::try_from(0x09).unwrap(),
233            NackReason::ReservedFlagsSet
234        );
235    }
236
237    #[test]
238    fn from_for_u8_is_inverse_of_try_from_and_display_is_nonempty() {
239        // Swept, not ranged: `0x01..=0x09` stopped covering the type when
240        // BadBatchFraming was assigned 0x0B, and the assigned set is not
241        // contiguous (0x0A is permanently reserved as the frozen vectors'
242        // worked "unknown reason" example).
243        let mut seen = 0;
244        for byte in 0x00u8..=0xFF {
245            let Ok(r) = NackReason::try_from(byte) else {
246                continue;
247            };
248            seen += 1;
249            assert_eq!(u8::from(r), byte);
250            assert!(!r.to_string().is_empty(), "{r:?} has an empty Display");
251        }
252        assert!(seen >= 10, "the sweep decoded only {seen} reasons");
253        // Usable as a std error.
254        let _: &dyn std::error::Error = &NackReason::BadMagic;
255    }
256
257    #[test]
258    fn try_from_returns_unknown_for_unrecognised_byte() {
259        // 0x0A is the first unassigned reason byte (0x01..=0x09 are known).
260        let err = NackReason::try_from(0x0A).unwrap_err();
261        assert_eq!(err.0, 0x0A);
262        let err = NackReason::try_from(0x00).unwrap_err();
263        assert_eq!(err.0, 0x00);
264        let err = NackReason::try_from(0xff).unwrap_err();
265        assert_eq!(err.0, 0xff);
266    }
267
268    #[test]
269    fn repr_values_match_wire() {
270        assert_eq!(NackReason::BadMagic as u8, 0x01);
271        assert_eq!(NackReason::VersionMismatch as u8, 0x02);
272        assert_eq!(NackReason::BadHeaderCrc as u8, 0x03);
273        assert_eq!(NackReason::PayloadTooLarge as u8, 0x04);
274        assert_eq!(NackReason::BadPayloadCrc as u8, 0x05);
275        assert_eq!(NackReason::InternalError as u8, 0x06);
276        assert_eq!(NackReason::EmptyPayload as u8, 0x07);
277        assert_eq!(NackReason::UnknownMessage as u8, 0x08);
278        assert_eq!(NackReason::ReservedFlagsSet as u8, 0x09);
279    }
280
281    #[test]
282    fn as_metric_label_pins_every_variant() {
283        // Drift guard: these strings MUST match the daemon's private metrics
284        // `NackReason` label enum (weir-server/src/metrics/mod.rs) exactly, so an
285        // ops consumer can map a wire NackReason to the metric label it queries.
286        //
287        // This list is a spelling pin and nothing more — it asserts the exact
288        // strings an operator types into a dashboard query. It is NOT the guard
289        // that catches a missing variant: a hand-written list cannot, which is
290        // how `BadBatchFraming` was added to the enum while this test, the
291        // server's label enum and its pre-registration sweep all stayed at nine
292        // entries. `metric_label_round_trips_for_every_variant` below sweeps the
293        // byte space and is what actually holds the set closed.
294        assert_eq!(NackReason::BadMagic.as_metric_label(), "bad_magic");
295        assert_eq!(
296            NackReason::VersionMismatch.as_metric_label(),
297            "version_mismatch"
298        );
299        assert_eq!(NackReason::BadHeaderCrc.as_metric_label(), "bad_header_crc");
300        assert_eq!(
301            NackReason::PayloadTooLarge.as_metric_label(),
302            "payload_too_large"
303        );
304        assert_eq!(
305            NackReason::BadPayloadCrc.as_metric_label(),
306            "bad_payload_crc"
307        );
308        assert_eq!(
309            NackReason::InternalError.as_metric_label(),
310            "internal_error"
311        );
312        assert_eq!(NackReason::EmptyPayload.as_metric_label(), "empty_payload");
313        assert_eq!(
314            NackReason::UnknownMessage.as_metric_label(),
315            "unknown_message"
316        );
317        assert_eq!(
318            NackReason::ReservedFlagsSet.as_metric_label(),
319            "reserved_flags_set"
320        );
321        assert_eq!(
322            NackReason::BadBatchFraming.as_metric_label(),
323            "bad_batch_framing"
324        );
325    }
326
327    #[test]
328    fn metric_label_round_trips_for_every_variant() {
329        // Swept over the whole byte space, not over `0x01..=0x09`.
330        //
331        // That range was written when 0x09 was the last assigned reason, and it
332        // silently stopped covering the type the moment `BadBatchFraming`
333        // (0x0B) was assigned — so the new variant's label, Display and
334        // round-trip all shipped untested by a test whose name promises "every
335        // variant". Sweeping every byte and testing whichever ones decode means
336        // the next reason is covered the day its byte is assigned rather than
337        // the day someone remembers to widen a range.
338        //
339        // Note the gap this also documents: 0x0A is permanently unassigned,
340        // pinned as the worked "unknown reason" example by the frozen vector
341        // `nack_reserved_reason`, so the assigned set is NOT contiguous.
342        let mut seen = 0;
343        for byte in 0x00u8..=0xFF {
344            let Ok(r) = NackReason::try_from(byte) else {
345                continue;
346            };
347            seen += 1;
348            assert_eq!(
349                NackReason::from_metric_label(r.as_metric_label()),
350                Some(r),
351                "metric label for {r:?} ({byte:#04x}) did not round-trip"
352            );
353            assert!(
354                !r.as_metric_label().is_empty(),
355                "{r:?} ({byte:#04x}) has no metric label"
356            );
357        }
358        assert!(
359            seen >= 10,
360            "the sweep decoded only {seen} reasons; it is no longer reaching the \
361             assigned set"
362        );
363        assert!(
364            NackReason::try_from(0x0A).is_err(),
365            "0x0A must stay unassigned — the frozen vector nack_reserved_reason \
366             pins it as the worked example of an unknown reason byte"
367        );
368
369        // An unknown label maps to None.
370        assert_eq!(NackReason::from_metric_label("not_a_reason"), None);
371        assert_eq!(NackReason::from_metric_label(""), None);
372    }
373
374    /// Verifies the VersionMismatch Nack payload is [reason_byte, daemon_version_byte].
375    /// The client parses this to produce: "daemon is on vN; this client is built against vM."
376    #[test]
377    fn version_mismatch_nack_payload_encodes_daemon_version() {
378        let payload = [NackReason::VersionMismatch as u8, WIRE_VERSION];
379        assert_eq!(payload, [0x02, 0x01]);
380    }
381}