Skip to main content

diavlos_core/
error.rs

1//! One error type for the whole of Diavlos.
2//!
3//! The exit codes (CLI) and error codes (MCP) mean the same thing:
4//! 2 = not in room, 3 = reached nobody, 4 = timed out, 5 = name already
5//! taken, 6 = denied, 7 = room paused. Anything else is 1.
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// Exit code for "not in room".
11pub const CODE_NOT_IN_ROOM: i32 = 2;
12/// Exit code for "reached nobody".
13pub const CODE_REACHED_NOBODY: i32 = 3;
14/// Exit code for "timed out".
15pub const CODE_TIMED_OUT: i32 = 4;
16/// Exit code for "name already taken".
17pub const CODE_NAME_TAKEN: i32 = 5;
18/// Exit code for "denied".
19pub const CODE_DENIED: i32 = 6;
20/// Exit code for "room paused".
21pub const CODE_ROOM_PAUSED: i32 = 7;
22
23#[derive(Debug, Error)]
24pub enum Error {
25    #[error("not in room: {0}")]
26    NotInRoom(String),
27    #[error("reached nobody: {0}")]
28    ReachedNobody(String),
29    #[error("timed out")]
30    TimedOut,
31    #[error("name already taken: {0}")]
32    NameTaken(String),
33    #[error("denied: {0}")]
34    Denied(String),
35    #[error("room paused: {0}")]
36    RoomPaused(String),
37    #[error("invalid: {0}")]
38    Invalid(String),
39    #[error("bad signature: {0}")]
40    BadSignature(String),
41    /// Over a rate limit or the daily budget. The number is how many
42    /// seconds until the limit lets the next message through, when known.
43    #[error("over budget: {0}")]
44    OverBudget(String, Option<u64>),
45    #[error("storage: {0}")]
46    Db(#[from] rusqlite::Error),
47    #[error("migration: {0}")]
48    Migration(#[from] rusqlite_migration::Error),
49    #[error("io: {0}")]
50    Io(#[from] std::io::Error),
51    #[error("json: {0}")]
52    Json(#[from] serde_json::Error),
53    #[error("{0}")]
54    Other(String),
55}
56
57impl Error {
58    /// The exit code (CLI) or error code (MCP) for this error.
59    pub fn code(&self) -> i32 {
60        match self {
61            Error::NotInRoom(_) => CODE_NOT_IN_ROOM,
62            Error::ReachedNobody(_) => CODE_REACHED_NOBODY,
63            Error::TimedOut => CODE_TIMED_OUT,
64            Error::NameTaken(_) => CODE_NAME_TAKEN,
65            Error::Denied(_) | Error::BadSignature(_) => CODE_DENIED,
66            Error::RoomPaused(_) => CODE_ROOM_PAUSED,
67            _ => 1,
68        }
69    }
70
71    /// Should a sender try the same message again later? `None` when that
72    /// is not known: the caller keeps it and retries, and only gives up
73    /// when the same unknown answer has come back for a long time.
74    ///
75    /// Temporary: the other side was not reached, is paused, over budget,
76    /// or failed to store it. Definitive: retrying the same signed bytes
77    /// cannot succeed.
78    pub fn fate(&self) -> Option<Fate> {
79        match self {
80            Error::ReachedNobody(_)
81            | Error::TimedOut
82            | Error::RoomPaused(_)
83            | Error::OverBudget(..)
84            | Error::Io(_)
85            | Error::Db(_)
86            | Error::Migration(_) => Some(Fate::Temporary),
87            Error::NotInRoom(_)
88            | Error::NameTaken(_)
89            | Error::Denied(_)
90            | Error::BadSignature(_)
91            | Error::Invalid(_) => Some(Fate::Definitive),
92            Error::Json(_) | Error::Other(_) => None,
93        }
94    }
95
96    /// Seconds until trying again can work, when the error knows.
97    pub fn retry_after(&self) -> Option<u64> {
98        match self {
99            Error::OverBudget(_, secs) => *secs,
100            _ => None,
101        }
102    }
103
104    /// Rebuild an error from a code and a message that crossed a wire.
105    /// Strips the label an earlier hop's `Display` put in front, so the
106    /// text does not stack up as it travels.
107    pub fn from_code(code: i32, msg: &str) -> Self {
108        let msg = strip_labels(msg);
109        let msg = msg.as_str();
110        match code {
111            CODE_NOT_IN_ROOM => Error::NotInRoom(msg.to_string()),
112            CODE_REACHED_NOBODY => Error::ReachedNobody(msg.to_string()),
113            CODE_TIMED_OUT => Error::TimedOut,
114            CODE_NAME_TAKEN => Error::NameTaken(msg.to_string()),
115            CODE_DENIED => Error::Denied(msg.to_string()),
116            CODE_ROOM_PAUSED => Error::RoomPaused(msg.to_string()),
117            _ => Error::Other(msg.to_string()),
118        }
119    }
120}
121
122const LABELS: &[&str] = &[
123    "not in room: ",
124    "reached nobody: ",
125    "name already taken: ",
126    "denied: ",
127    "room paused: ",
128    "invalid: ",
129    "bad signature: ",
130    "over budget: ",
131    "storage: ",
132    "io: ",
133    "json: ",
134];
135
136fn strip_labels(msg: &str) -> String {
137    let mut m = msg;
138    loop {
139        let mut stripped = false;
140        for label in LABELS {
141            if let Some(rest) = m.strip_prefix(label) {
142                m = rest;
143                stripped = true;
144            }
145        }
146        if !stripped {
147            return m.to_string();
148        }
149    }
150}
151
152/// What to do with a message the other side did not take.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum Fate {
156    /// Keep it and try again later.
157    Temporary,
158    /// Stop: the same message will never be taken.
159    Definitive,
160}
161
162pub type Result<T> = std::result::Result<T, Error>;
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn codes_roundtrip_without_stacking() {
170        let e = Error::NameTaken("bob".into());
171        let hop1 = Error::from_code(e.code(), &e.to_string());
172        let hop2 = Error::from_code(hop1.code(), &hop1.to_string());
173        assert_eq!(hop2.to_string(), "name already taken: bob");
174        assert_eq!(hop2.code(), CODE_NAME_TAKEN);
175        assert_eq!(Error::from_code(4, "timed out").code(), CODE_TIMED_OUT);
176    }
177}