Skip to main content

wavyzon_plugin/
failure.rs

1//! Typed failures with severities, reported to clients in `TrackExceptionEvent`
2//! and `loadType: "error"` results.
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum Severity {
9    /// Expected in normal operation (not found, timed out).
10    Common,
11    /// The source refused us (401/403/429) — possibly rate limited or banned.
12    Suspicious,
13    /// A bug or an unrecoverable node-side problem.
14    Fault,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct Failure {
20    pub severity: Severity,
21    pub message: String,
22    #[serde(default, skip_serializing_if = "String::is_empty")]
23    pub cause: String,
24    #[serde(default, skip_serializing_if = "String::is_empty")]
25    pub source: String,
26}
27
28impl Failure {
29    pub fn new(
30        severity: Severity,
31        source: impl Into<String>,
32        message: impl Into<String>,
33        cause: Option<String>,
34    ) -> Self {
35        Self {
36            severity,
37            message: message.into(),
38            cause: cause.unwrap_or_default(),
39            source: source.into(),
40        }
41    }
42    pub fn common(source: impl Into<String>, message: impl Into<String>, cause: Option<String>) -> Self {
43        Self::new(Severity::Common, source, message, cause)
44    }
45    pub fn suspicious(source: impl Into<String>, message: impl Into<String>, cause: Option<String>) -> Self {
46        Self::new(Severity::Suspicious, source, message, cause)
47    }
48    pub fn fault(source: impl Into<String>, message: impl Into<String>, cause: Option<String>) -> Self {
49        Self::new(Severity::Fault, source, message, cause)
50    }
51
52    /// Classify an HTTP status code returned by a source.
53    pub fn from_status(source: &str, status: u16, cause: impl Into<String>) -> Self {
54        let cause = Some(cause.into());
55        match status {
56            401 | 403 | 429 => Self::suspicious(source, "refused by source", cause),
57            404 => Self::common(source, "not found at source", cause),
58            _ => Self::common(source, format!("{source} failed"), cause),
59        }
60    }
61}
62
63impl std::fmt::Display for Failure {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        if self.cause.is_empty() {
66            f.write_str(&self.message)
67        } else {
68            write!(f, "{}: {}", self.message, self.cause)
69        }
70    }
71}
72
73impl std::error::Error for Failure {}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn status_classification() {
81        assert_eq!(Failure::from_status("x", 429, "").severity, Severity::Suspicious);
82        assert_eq!(Failure::from_status("x", 404, "").message, "not found at source");
83        assert_eq!(
84            Failure::common("x", "boom", Some("why".into())).to_string(),
85            "boom: why"
86        );
87    }
88}