Skip to main content

webserver_base/telegram/
error.rs

1use std::fmt::{Debug, Formatter};
2use std::time::Duration;
3use std::{error, fmt};
4
5/// Every way a Telegram send can fail.
6///
7/// Note that [`TelegramError::Http`] always holds a [`reqwest::Error`] which has
8/// had its URL stripped via [`reqwest::Error::without_url`]. The bot token is a
9/// path segment of every request URL, and `reqwest`'s own `Debug` includes the
10/// URL, so an un-stripped error would leak the credential into any log line.
11#[derive(Debug)]
12pub enum TelegramError {
13    /// The bot token was empty or malformed.
14    InvalidToken(String),
15
16    /// The message was too large to even attempt to send.
17    ///
18    /// This is a guard against an upstream bug producing a multi-megabyte
19    /// string; it is checked before chunking, so it is never the result of
20    /// merely exceeding Telegram's per-message limit.
21    MessageTooLarge {
22        /// Size of the offending message, in bytes.
23        bytes: usize,
24        /// The maximum permitted size, in bytes.
25        max: usize,
26    },
27
28    /// The HTTP request itself failed (connection refused, TLS failure, timeout).
29    Http(reqwest::Error),
30
31    /// Telegram accepted the request but rejected its contents.
32    Api {
33        /// Telegram's `error_code` (an HTTP status code).
34        error_code: i32,
35        /// Telegram's human-readable `description`.
36        description: String,
37    },
38
39    /// Telegram returned `429` along with how long to wait.
40    ///
41    /// This is distinct from a plain [`TelegramError::Api`] `429` because it
42    /// carries an actionable delay: Telegram is telling us precisely when the
43    /// request may be repeated.
44    RateLimited {
45        /// How long Telegram asked us to wait.
46        retry_after: Duration,
47    },
48
49    /// The response body could not be deserialized.
50    Serialization(serde_json::Error),
51
52    /// An invariant inside this library was violated.
53    Internal(String),
54}
55
56impl TelegramError {
57    /// Whether this error is permanent, meaning a retry can never succeed.
58    ///
59    /// A permanent error almost always means the notifier is misconfigured
60    /// rather than that this particular message was bad: a wrong token, a chat
61    /// the bot was removed from, a chat id that does not exist. Every
62    /// subsequent send will fail the same way.
63    #[must_use]
64    pub const fn is_permanent(&self) -> bool {
65        match self {
66            Self::InvalidToken(_) | Self::MessageTooLarge { .. } | Self::Internal(_) => true,
67            Self::Api { error_code, .. } => {
68                // 429 is handled separately (it carries `retry_after`), and 5xx
69                // is transient. Everything else in the 4xx range is permanent.
70                *error_code >= 400 && *error_code < 500 && *error_code != 429
71            }
72            Self::Http(_) | Self::Serialization(_) | Self::RateLimited { .. } => false,
73        }
74    }
75
76    /// Whether this error indicates the notifier as a whole is misconfigured.
77    ///
78    /// These deserve a louder log than an ordinary send failure, because they
79    /// mean every future send is also doomed:
80    /// - `401` — the bot token is wrong.
81    /// - `403` — the bot was blocked, or removed from the chat.
82    /// - `400` with a chat-related description — the chat id is wrong.
83    #[must_use]
84    pub fn is_misconfiguration(&self) -> bool {
85        match self {
86            Self::InvalidToken(_) => true,
87            Self::Api {
88                error_code,
89                description,
90            } => {
91                *error_code == 401
92                    || *error_code == 403
93                    || (*error_code == 400 && description.to_lowercase().contains("chat not found"))
94            }
95            _ => false,
96        }
97    }
98}
99
100impl error::Error for TelegramError {}
101
102impl fmt::Display for TelegramError {
103    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::InvalidToken(reason) => write!(f, "invalid telegram bot token: {reason}"),
106            Self::MessageTooLarge { bytes, max } => {
107                write!(
108                    f,
109                    "message is {bytes} bytes, which exceeds the {max} byte maximum"
110                )
111            }
112            Self::Http(error) => write!(f, "telegram http request failed: {error}"),
113            Self::Api {
114                error_code,
115                description,
116            } => write!(
117                f,
118                "telegram rejected the request ({error_code}): {description}"
119            ),
120            Self::RateLimited { retry_after } => {
121                write!(f, "telegram rate limited us; retry after {retry_after:?}")
122            }
123            Self::Serialization(error) => {
124                write!(f, "could not deserialize telegram's response: {error}")
125            }
126            Self::Internal(reason) => write!(f, "internal telegram client error: {reason}"),
127        }
128    }
129}
130
131impl From<reqwest::Error> for TelegramError {
132    fn from(error: reqwest::Error) -> Self {
133        // Strip the URL unconditionally: it contains the bot token.
134        Self::Http(error.without_url())
135    }
136}
137
138impl From<serde_json::Error> for TelegramError {
139    fn from(error: serde_json::Error) -> Self {
140        Self::Serialization(error)
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::TelegramError;
147
148    #[test]
149    fn client_errors_are_permanent() {
150        let error: TelegramError = TelegramError::Api {
151            error_code: 400,
152            description: String::from("Bad Request: message text is empty"),
153        };
154
155        assert!(error.is_permanent());
156    }
157
158    #[test]
159    fn rate_limit_errors_are_not_permanent() {
160        let error: TelegramError = TelegramError::Api {
161            error_code: 429,
162            description: String::from("Too Many Requests: retry after 5"),
163        };
164
165        assert!(!error.is_permanent());
166    }
167
168    #[test]
169    fn server_errors_are_not_permanent() {
170        let error: TelegramError = TelegramError::Api {
171            error_code: 502,
172            description: String::from("Bad Gateway"),
173        };
174
175        assert!(!error.is_permanent());
176    }
177
178    #[test]
179    fn unauthorized_is_a_misconfiguration() {
180        let error: TelegramError = TelegramError::Api {
181            error_code: 401,
182            description: String::from("Unauthorized"),
183        };
184
185        assert!(error.is_misconfiguration());
186    }
187
188    #[test]
189    fn forbidden_is_a_misconfiguration() {
190        let error: TelegramError = TelegramError::Api {
191            error_code: 403,
192            description: String::from("Forbidden: bot was blocked by the user"),
193        };
194
195        assert!(error.is_misconfiguration());
196    }
197
198    #[test]
199    fn chat_not_found_is_a_misconfiguration() {
200        let error: TelegramError = TelegramError::Api {
201            error_code: 400,
202            description: String::from("Bad Request: chat not found"),
203        };
204
205        assert!(error.is_misconfiguration());
206    }
207
208    #[test]
209    fn an_ordinary_bad_request_is_not_a_misconfiguration() {
210        let error: TelegramError = TelegramError::Api {
211            error_code: 400,
212            description: String::from("Bad Request: message is too long"),
213        };
214
215        assert!(!error.is_misconfiguration());
216    }
217
218    #[test]
219    fn display_includes_the_error_code_and_description() {
220        let error: TelegramError = TelegramError::Api {
221            error_code: 400,
222            description: String::from("Bad Request: chat not found"),
223        };
224
225        let expected: String =
226            String::from("telegram rejected the request (400): Bad Request: chat not found");
227        let actual: String = format!("{error}");
228        assert_eq!(expected, actual);
229    }
230}