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. MessageType/Durability are deliberately left exhaustive because
19// changing them requires a WIRE_VERSION bump (a major event); that does not
20// apply here.
21#[non_exhaustive]
22pub enum NackReason {
23    /// Frame did not start with the `b"WEIR"` magic.
24    BadMagic = 0x01,
25    /// Frame's version byte did not equal the daemon's `WIRE_VERSION`. The Nack
26    /// payload's second byte carries the daemon's version (see above).
27    VersionMismatch = 0x02,
28    /// Header CRC32 did not match the header bytes.
29    BadHeaderCrc = 0x03,
30    /// Declared `payload_len` exceeded the daemon's effective cap.
31    PayloadTooLarge = 0x04,
32    /// Payload CRC32 did not match the payload bytes.
33    BadPayloadCrc = 0x05,
34    /// The daemon hit an internal error (e.g. queue saturation); transient.
35    InternalError = 0x06,
36    /// The push carried a zero-length payload, which the WAB cannot represent:
37    /// an empty record's length prefix is four zero bytes, identical to the
38    /// end-of-records sentinel, so storing one would truncate the segment.
39    /// Rejected at ingest.
40    EmptyPayload = 0x07,
41    /// The frame's header was structurally valid (magic, version, and header CRC
42    /// all passed) but carried a `message_type` or `durability` byte this daemon
43    /// does not recognise — a PERMANENT client protocol error (typically version
44    /// skew). Distinct from `InternalError` (a transient daemon-side condition
45    /// that keeps the connection open): the daemon closes the connection after
46    /// this Nack. Retrying the identical frame will not succeed (F25).
47    UnknownMessage = 0x08,
48    /// The frame's header was structurally valid but set one or more bits in the
49    /// reserved `flags` byte, which must be zero in wire v1. A daemon rejects
50    /// such a frame rather than silently ignoring a flag it does not understand
51    /// (which could mean a producer believed a semantic flag took effect when it
52    /// did not). Permanent; the daemon closes the connection (F52).
53    ReservedFlagsSet = 0x09,
54}
55
56impl NackReason {
57    /// The canonical snake_case metric-label string for this reason.
58    ///
59    /// These strings match the daemon's Prometheus `reason` label values exactly
60    /// (the `nack_total{reason=...}` series), so an ops consumer — an autoscaler,
61    /// an alerting rule, a dashboard — can map a `NackReason` it receives over the
62    /// wire to the metric label it must query, without hard-coding the strings.
63    /// Inverse of [`NackReason::from_metric_label`].
64    ///
65    /// ```
66    /// use weir_core::NackReason;
67    /// assert_eq!(NackReason::PayloadTooLarge.as_metric_label(), "payload_too_large");
68    /// ```
69    #[must_use]
70    pub fn as_metric_label(&self) -> &'static str {
71        match self {
72            NackReason::BadMagic => "bad_magic",
73            NackReason::VersionMismatch => "version_mismatch",
74            NackReason::BadHeaderCrc => "bad_header_crc",
75            NackReason::PayloadTooLarge => "payload_too_large",
76            NackReason::BadPayloadCrc => "bad_payload_crc",
77            NackReason::InternalError => "internal_error",
78            NackReason::EmptyPayload => "empty_payload",
79            NackReason::UnknownMessage => "unknown_message",
80            NackReason::ReservedFlagsSet => "reserved_flags_set",
81        }
82    }
83
84    /// Parses a snake_case metric label back into its [`NackReason`], or `None` if
85    /// the string is not a known label. Inverse of
86    /// [`NackReason::as_metric_label`].
87    ///
88    /// ```
89    /// use weir_core::NackReason;
90    /// assert_eq!(
91    ///     NackReason::from_metric_label("payload_too_large"),
92    ///     Some(NackReason::PayloadTooLarge),
93    /// );
94    /// assert_eq!(NackReason::from_metric_label("nope"), None);
95    /// ```
96    #[must_use]
97    pub fn from_metric_label(s: &str) -> Option<NackReason> {
98        match s {
99            "bad_magic" => Some(NackReason::BadMagic),
100            "version_mismatch" => Some(NackReason::VersionMismatch),
101            "bad_header_crc" => Some(NackReason::BadHeaderCrc),
102            "payload_too_large" => Some(NackReason::PayloadTooLarge),
103            "bad_payload_crc" => Some(NackReason::BadPayloadCrc),
104            "internal_error" => Some(NackReason::InternalError),
105            "empty_payload" => Some(NackReason::EmptyPayload),
106            "unknown_message" => Some(NackReason::UnknownMessage),
107            "reserved_flags_set" => Some(NackReason::ReservedFlagsSet),
108            _ => None,
109        }
110    }
111}
112
113impl From<NackReason> for u8 {
114    /// The wire byte for this reason. Inverse of [`NackReason::try_from`].
115    fn from(reason: NackReason) -> u8 {
116        reason as u8
117    }
118}
119
120impl std::fmt::Display for NackReason {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        let msg = match self {
123            NackReason::BadMagic => "bad frame magic",
124            NackReason::VersionMismatch => "wire protocol version mismatch",
125            NackReason::BadHeaderCrc => "header CRC mismatch",
126            NackReason::PayloadTooLarge => "payload exceeds the daemon's cap",
127            NackReason::BadPayloadCrc => "payload CRC mismatch",
128            NackReason::InternalError => "daemon internal error (transient)",
129            NackReason::EmptyPayload => "empty payload (not representable)",
130            NackReason::UnknownMessage => "unknown message type or durability byte",
131            NackReason::ReservedFlagsSet => "reserved flags byte was non-zero",
132        };
133        write!(f, "{msg}")
134    }
135}
136
137// A Nack reason is a legitimate error a producer can propagate, so it implements
138// the std error trait (Display + Debug are both present). Lets callers do
139// `Box<dyn Error>`/`?` with a `NackReason` directly.
140impl std::error::Error for NackReason {}
141
142/// Error returned when a `u8` does not map to a known `NackReason` variant.
143/// Preserves the raw byte so the client can log or display it.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct UnknownNackReason(pub u8);
146
147impl std::fmt::Display for UnknownNackReason {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(f, "unknown nack reason byte: {:#04x}", self.0)
150    }
151}
152
153impl std::error::Error for UnknownNackReason {}
154
155impl TryFrom<u8> for NackReason {
156    type Error = UnknownNackReason;
157
158    fn try_from(value: u8) -> Result<Self, Self::Error> {
159        match value {
160            0x01 => Ok(NackReason::BadMagic),
161            0x02 => Ok(NackReason::VersionMismatch),
162            0x03 => Ok(NackReason::BadHeaderCrc),
163            0x04 => Ok(NackReason::PayloadTooLarge),
164            0x05 => Ok(NackReason::BadPayloadCrc),
165            0x06 => Ok(NackReason::InternalError),
166            0x07 => Ok(NackReason::EmptyPayload),
167            0x08 => Ok(NackReason::UnknownMessage),
168            0x09 => Ok(NackReason::ReservedFlagsSet),
169            v => Err(UnknownNackReason(v)),
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::version::WIRE_VERSION;
178
179    #[test]
180    fn try_from_accepts_all_known_reasons() {
181        assert_eq!(NackReason::try_from(0x01).unwrap(), NackReason::BadMagic);
182        assert_eq!(
183            NackReason::try_from(0x02).unwrap(),
184            NackReason::VersionMismatch
185        );
186        assert_eq!(
187            NackReason::try_from(0x03).unwrap(),
188            NackReason::BadHeaderCrc
189        );
190        assert_eq!(
191            NackReason::try_from(0x04).unwrap(),
192            NackReason::PayloadTooLarge
193        );
194        assert_eq!(
195            NackReason::try_from(0x05).unwrap(),
196            NackReason::BadPayloadCrc
197        );
198        assert_eq!(
199            NackReason::try_from(0x06).unwrap(),
200            NackReason::InternalError
201        );
202        assert_eq!(
203            NackReason::try_from(0x07).unwrap(),
204            NackReason::EmptyPayload
205        );
206        assert_eq!(
207            NackReason::try_from(0x08).unwrap(),
208            NackReason::UnknownMessage
209        );
210        assert_eq!(
211            NackReason::try_from(0x09).unwrap(),
212            NackReason::ReservedFlagsSet
213        );
214    }
215
216    #[test]
217    fn from_for_u8_is_inverse_of_try_from_and_display_is_nonempty() {
218        for byte in 0x01u8..=0x09 {
219            let r = NackReason::try_from(byte).unwrap();
220            assert_eq!(u8::from(r), byte);
221            assert!(!r.to_string().is_empty());
222        }
223        // Usable as a std error.
224        let _: &dyn std::error::Error = &NackReason::BadMagic;
225    }
226
227    #[test]
228    fn try_from_returns_unknown_for_unrecognised_byte() {
229        // 0x0A is the first unassigned reason byte (0x01..=0x09 are known).
230        let err = NackReason::try_from(0x0A).unwrap_err();
231        assert_eq!(err.0, 0x0A);
232        let err = NackReason::try_from(0x00).unwrap_err();
233        assert_eq!(err.0, 0x00);
234        let err = NackReason::try_from(0xff).unwrap_err();
235        assert_eq!(err.0, 0xff);
236    }
237
238    #[test]
239    fn repr_values_match_wire() {
240        assert_eq!(NackReason::BadMagic as u8, 0x01);
241        assert_eq!(NackReason::VersionMismatch as u8, 0x02);
242        assert_eq!(NackReason::BadHeaderCrc as u8, 0x03);
243        assert_eq!(NackReason::PayloadTooLarge as u8, 0x04);
244        assert_eq!(NackReason::BadPayloadCrc as u8, 0x05);
245        assert_eq!(NackReason::InternalError as u8, 0x06);
246        assert_eq!(NackReason::EmptyPayload as u8, 0x07);
247        assert_eq!(NackReason::UnknownMessage as u8, 0x08);
248        assert_eq!(NackReason::ReservedFlagsSet as u8, 0x09);
249    }
250
251    #[test]
252    fn as_metric_label_pins_every_variant() {
253        // Drift guard: these strings MUST match the daemon's private metrics
254        // `NackReason` label enum (weir-server/src/metrics/mod.rs) exactly, so an
255        // ops consumer can map a wire NackReason to the metric label it queries.
256        // If a variant is added or renamed, update both this test and the server.
257        assert_eq!(NackReason::BadMagic.as_metric_label(), "bad_magic");
258        assert_eq!(
259            NackReason::VersionMismatch.as_metric_label(),
260            "version_mismatch"
261        );
262        assert_eq!(NackReason::BadHeaderCrc.as_metric_label(), "bad_header_crc");
263        assert_eq!(
264            NackReason::PayloadTooLarge.as_metric_label(),
265            "payload_too_large"
266        );
267        assert_eq!(
268            NackReason::BadPayloadCrc.as_metric_label(),
269            "bad_payload_crc"
270        );
271        assert_eq!(
272            NackReason::InternalError.as_metric_label(),
273            "internal_error"
274        );
275        assert_eq!(NackReason::EmptyPayload.as_metric_label(), "empty_payload");
276        assert_eq!(
277            NackReason::UnknownMessage.as_metric_label(),
278            "unknown_message"
279        );
280        assert_eq!(
281            NackReason::ReservedFlagsSet.as_metric_label(),
282            "reserved_flags_set"
283        );
284    }
285
286    #[test]
287    fn metric_label_round_trips_for_every_variant() {
288        for byte in 0x01u8..=0x09 {
289            let r = NackReason::try_from(byte).unwrap();
290            assert_eq!(
291                NackReason::from_metric_label(r.as_metric_label()),
292                Some(r),
293                "metric label for {r:?} did not round-trip"
294            );
295        }
296        // An unknown label maps to None.
297        assert_eq!(NackReason::from_metric_label("not_a_reason"), None);
298        assert_eq!(NackReason::from_metric_label(""), None);
299    }
300
301    /// Verifies the VersionMismatch Nack payload is [reason_byte, daemon_version_byte].
302    /// The client parses this to produce: "daemon is on vN; this client is built against vM."
303    #[test]
304    fn version_mismatch_nack_payload_encodes_daemon_version() {
305        let payload = [NackReason::VersionMismatch as u8, WIRE_VERSION];
306        assert_eq!(payload, [0x02, 0x01]);
307    }
308}