Skip to main content

pulse_client/
error.rs

1//! Typed error hierarchy for the Pulse client.
2//!
3//! Every HTTP error from the Pulse server is translated into a variant of
4//! [`PulseError`]. Callers match precisely with `match` or check categories
5//! via the convenience methods (`is_auth_error`, `is_not_found`, etc.).
6
7use std::fmt;
8
9use serde_json::Value;
10
11/// The single error type returned by every `PulseClient` method.
12#[derive(Debug)]
13pub enum PulseError {
14    /// 401 — invalid / missing / expired JWT.
15    Auth { path: String, body: Option<Value> },
16    /// 404 — the resource does not exist.
17    NotFound { path: String, body: Option<Value> },
18    /// 400 — the request body is malformed.
19    Validation { path: String, body: Option<Value> },
20    /// 429 — per-user or per-IP rate limit hit. Carries the server's advised
21    /// wait time, parsed from either the `retryAfterSeconds` JSON field or the
22    /// `Retry-After` HTTP header. `None` means the server gave no hint.
23    RateLimit {
24        path: String,
25        body: Option<Value>,
26        retry_after_seconds: Option<u32>,
27    },
28    /// Any other non-2xx status code (5xx, unexpected 4xx).
29    Api {
30        status: u16,
31        path: String,
32        body: Option<Value>,
33    },
34    /// The HTTP transport itself failed (connection refused, timeout, DNS,
35    /// TLS handshake, etc.). Wraps the underlying reqwest error.
36    Transport(reqwest::Error),
37    /// JSON serialisation / deserialisation failure. The wire format the
38    /// server returned doesn't match what the client expected.
39    Json(serde_json::Error),
40    /// The caller invoked an authenticated endpoint without setting a token
41    /// first. Surfaces before any network call, so it has no `body`.
42    NoToken { path: String },
43    /// The supplied configuration is invalid (e.g. empty `base_url`).
44    InvalidConfig(String),
45    /// B-114 — a duplex WebSocket channel failed (handshake, framing, or the
46    /// connection dropped). Carries a human-readable description of the cause.
47    Duplex(String),
48}
49
50impl PulseError {
51    pub fn is_auth_error(&self) -> bool {
52        matches!(self, PulseError::Auth { .. } | PulseError::NoToken { .. })
53    }
54
55    pub fn is_not_found(&self) -> bool {
56        matches!(self, PulseError::NotFound { .. })
57    }
58
59    pub fn is_validation_error(&self) -> bool {
60        matches!(self, PulseError::Validation { .. })
61    }
62
63    pub fn is_rate_limited(&self) -> bool {
64        matches!(self, PulseError::RateLimit { .. })
65    }
66
67    /// HTTP status code, if the error carries one. `None` for transport /
68    /// JSON / no-token / config errors.
69    pub fn status_code(&self) -> Option<u16> {
70        match self {
71            PulseError::Auth { .. } | PulseError::NoToken { .. } => Some(401),
72            PulseError::NotFound { .. } => Some(404),
73            PulseError::Validation { .. } => Some(400),
74            PulseError::RateLimit { .. } => Some(429),
75            PulseError::Api { status, .. } => Some(*status),
76            PulseError::Transport(_)
77            | PulseError::Json(_)
78            | PulseError::InvalidConfig(_)
79            | PulseError::Duplex(_) => None,
80        }
81    }
82
83    /// The parsed JSON error body the server returned, if any.
84    pub fn body(&self) -> Option<&Value> {
85        match self {
86            PulseError::Auth { body, .. }
87            | PulseError::NotFound { body, .. }
88            | PulseError::Validation { body, .. }
89            | PulseError::RateLimit { body, .. }
90            | PulseError::Api { body, .. } => body.as_ref(),
91            _ => None,
92        }
93    }
94
95    pub fn path(&self) -> Option<&str> {
96        match self {
97            PulseError::Auth { path, .. }
98            | PulseError::NotFound { path, .. }
99            | PulseError::Validation { path, .. }
100            | PulseError::RateLimit { path, .. }
101            | PulseError::Api { path, .. }
102            | PulseError::NoToken { path } => Some(path),
103            _ => None,
104        }
105    }
106}
107
108impl fmt::Display for PulseError {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        let summary = match self {
111            PulseError::Auth { path, body } => format_http(401, path, body.as_ref()),
112            PulseError::NotFound { path, body } => format_http(404, path, body.as_ref()),
113            PulseError::Validation { path, body } => format_http(400, path, body.as_ref()),
114            PulseError::RateLimit { path, body, .. } => format_http(429, path, body.as_ref()),
115            PulseError::Api { status, path, body } => format_http(*status, path, body.as_ref()),
116            PulseError::Transport(e) => return write!(f, "pulse: HTTP transport failure — {e}"),
117            PulseError::Json(e) => return write!(f, "pulse: JSON encode/decode failure — {e}"),
118            PulseError::NoToken { path } => {
119                return write!(
120                    f,
121                    "pulse: no token set for {path} — call client.auth().login(...).await first \
122                     or pass .token(...) to the builder"
123                );
124            }
125            PulseError::InvalidConfig(msg) => return write!(f, "pulse: invalid config — {msg}"),
126            PulseError::Duplex(msg) => return write!(f, "pulse: duplex channel failure — {msg}"),
127        };
128        write!(f, "{summary}")
129    }
130}
131
132fn format_http(status: u16, path: &str, body: Option<&Value>) -> String {
133    let mut msg = format!("pulse: HTTP {status} from {path}");
134    if let Some(v) = body {
135        if let Some(err) = v
136            .get("error")
137            .and_then(Value::as_str)
138            .or_else(|| v.get("errorMessage").and_then(Value::as_str))
139            .or_else(|| v.get("message").and_then(Value::as_str))
140        {
141            msg.push_str(" — ");
142            msg.push_str(err);
143        }
144    }
145    msg
146}
147
148impl std::error::Error for PulseError {
149    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
150        match self {
151            PulseError::Transport(e) => Some(e),
152            PulseError::Json(e) => Some(e),
153            _ => None,
154        }
155    }
156}
157
158impl From<reqwest::Error> for PulseError {
159    fn from(e: reqwest::Error) -> Self {
160        PulseError::Transport(e)
161    }
162}
163
164impl From<serde_json::Error> for PulseError {
165    fn from(e: serde_json::Error) -> Self {
166        PulseError::Json(e)
167    }
168}