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}