mwbot/error/
mod.rs

1/*
2Copyright (C) 2021-2023 Kunal Mehta <legoktm@debian.org>
3
4This program is free software: you can redistribute it and/or modify
5it under the terms of the GNU General Public License as published by
6the Free Software Foundation, either version 3 of the License, or
7(at your option) any later version.
8
9This program is distributed in the hope that it will be useful,
10but WITHOUT ANY WARRANTY; without even the implied warranty of
11MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12GNU General Public License for more details.
13
14You should have received a copy of the GNU General Public License
15along with this program.  If not, see <https://www.gnu.org/licenses/>.
16 */
17//! MediaWiki API error types
18//!
19//! The MediaWiki API is rather dynamic and has quite a few possible errors
20//! that you can run into. This module aims to have dedicated types for each
21//! possible case as well as a conversion map between the API's error codes
22//! and Rust types.
23//!
24//! The `ApiError` type is serde-deserializable, and can be converted into
25//! a specific `Error` type using the API response code.
26
27pub(crate) mod config;
28mod upload;
29
30pub use mwapi::ApiError;
31use serde::Deserialize;
32use serde_json::Value;
33use std::io;
34use thiserror::Error as ThisError;
35pub use upload::UploadWarning;
36
37/// Primary error class
38#[non_exhaustive]
39#[derive(ThisError, Debug)]
40pub enum Error {
41    /* Request related errors */
42    /// A HTTP error like a 4XX or 5XX status code
43    #[error("HTTP error: {0}")]
44    HttpError(#[from] reqwest::Error),
45    /// Invalid header value, likely if the provided OAuth2 token
46    /// or User-agent are invalid
47    #[error("Invalid header value: {0}")]
48    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
49    /// Error when decoding the JSON response from the API
50    #[error("JSON error: {0}")]
51    InvalidJson(#[from] serde_json::Error),
52    /// Error if unable to get request concurrency lock
53    #[error("Unable to get request lock: {0}")]
54    LockFailure(#[from] tokio::sync::AcquireError),
55    #[error("Invalid title: {0}")]
56    InvalidTitle(#[from] mwtitle::Error),
57    #[error("I/O error: {0}")]
58    IoError(io::Error),
59    /// A HTTP error with 429 status code
60    #[error("HTTP 429 Too Many Requests")]
61    TooManyRequests { retry_after: Option<u64> },
62
63    /// etag header is invalid/missing
64    #[error("The etag for this request is missing or invalid")]
65    InvalidEtag,
66
67    /* Token issues */
68    /// Token invalid or expired
69    #[error("Invalid CSRF token")]
70    BadToken,
71    /// Unable to fetch a CSRF token
72    #[error("Unable to get token `{0}`")]
73    TokenFailure(String),
74
75    /* Wikitext/markup issues */
76    #[error("Heading levels must be between 1 and 6, '{0}' was provided")]
77    InvalidHeadingLevel(u32),
78
79    /* User-related issues */
80    /// When expected to be logged in but aren't
81    #[error("You're not logged in")]
82    NotLoggedIn,
83    /// When expected to be logged in but aren't
84    #[error("You're not logged in as a bot account")]
85    NotLoggedInAsBot,
86    #[error("Missing permission: {0}")]
87    PermissionDenied(String),
88    #[error("Blocked sitewide: {info}")]
89    Blocked { info: String, details: BlockDetails },
90    #[error("Partially blocked: {info}")]
91    PartiallyBlocked { info: String, details: BlockDetails },
92    #[error("Globally blocked: {0}")]
93    GloballyBlocked(String),
94    #[error("Globally range blocked: {0}")]
95    GloballyRangeBlocked(String),
96    #[error("Globally XFF blocked: {0}")]
97    GloballyXFFBlocked(String),
98    /// When we can't group it into a more specific block
99    #[error("Blocked: {0}")]
100    UnknownBlock(String),
101
102    /* Login-related issues */
103    #[error("You've made too many recent login attempts.")]
104    LoginThrottled,
105    #[error("Incorrect username or password entered.")]
106    WrongPassword,
107
108    /* Page-related issues */
109    #[error("The specified title is not a valid page")]
110    InvalidPage,
111    /// When {{nobots}} matches
112    #[error("{{{{nobots}}}} prevents editing this page")]
113    Nobots,
114    /// Page does not exist
115    #[error("Page does not exist: {0}")]
116    PageDoesNotExist(String),
117    /// Page is protected
118    #[error("Page is protected")]
119    ProtectedPage,
120    /// Edit conflict
121    #[error("Edit conflict")]
122    EditConflict,
123    #[error("Content too big: {0}")]
124    ContentTooBig(String),
125    /// Tripped the spam filter (aka SpamBlacklist)
126    #[error("{info}")]
127    SpamFilter { info: String, matches: Vec<String> },
128    /// If the edit could not be undone
129    #[error("Undo failure: {0}")]
130    UndoFailure(String),
131    /// Some save failure happened, but we don't know what it is
132    #[error("Unknown save failure: {0}")]
133    UnknownSaveFailure(Value),
134
135    /* File-related issues */
136    #[error("Upload warnings: {0:?}")]
137    UploadWarning(Vec<UploadWarning>),
138
139    /* MediaWiki-side issues */
140    #[error("maxlag tripped: {info}")]
141    Maxlag {
142        info: String,
143        retry_after: Option<u64>,
144    },
145    /// When MediaWiki is in readonly mode
146    #[error("MediaWiki is readonly: {info}")]
147    Readonly {
148        info: String,
149        retry_after: Option<u64>,
150    },
151    /// An internal MediaWiki exception
152    #[error("Internal MediaWiki exception: {0}")]
153    InternalException(ApiError),
154
155    /* Catchall/generic issues */
156    /// Any arbitrary error returned by the MediaWiki API
157    #[error("API error: {0}")]
158    ApiError(ApiError),
159    /// An error where we don't know what to do nor have
160    /// information to report back
161    #[error("Unknown error: {0}")]
162    Unknown(String),
163}
164
165impl Error {
166    /// Whether the issue is related to a specific page
167    /// rather than a global issue
168    pub fn is_page_related(&self) -> bool {
169        matches!(
170            self,
171            Error::InvalidPage
172                | Error::ProtectedPage
173                | Error::Nobots
174                | Error::PartiallyBlocked { .. }
175                | Error::EditConflict
176                | Error::SpamFilter { .. }
177                | Error::ContentTooBig(_)
178                | Error::UndoFailure(_)
179        )
180    }
181
182    /// Whether the issue is related to a sitewide block
183    pub fn is_sitewide_block(&self) -> bool {
184        matches!(
185            self,
186            Error::Blocked { .. }
187                | Error::GloballyBlocked(_)
188                | Error::GloballyRangeBlocked(_)
189                | Error::GloballyXFFBlocked(_)
190                // We don't know 100% this is a sitewide block, but let's
191                // err on the cautious side
192                | Error::UnknownBlock(_)
193        )
194    }
195
196    /// If the error merits a retry, how long should we wait?
197    pub fn retry_after(&self) -> Option<u64> {
198        match self {
199            Error::TooManyRequests { retry_after, .. } => {
200                Some((*retry_after).unwrap_or(1))
201            }
202            Error::Maxlag { retry_after, .. } => {
203                Some((*retry_after).unwrap_or(1))
204            }
205            Error::Readonly { retry_after, .. } => {
206                Some((*retry_after).unwrap_or(1))
207            }
208            _ => None,
209        }
210    }
211}
212
213#[derive(Deserialize)]
214struct SpamFilterData {
215    matches: Vec<String>,
216}
217
218// Various fields are commented out to keep
219// the size of Error down
220#[derive(Deserialize, Debug, Clone)]
221pub struct BlockDetails {
222    // pub blockid: u32,
223    pub blockedby: String,
224    // pub blockedbyid: u32,
225    pub blockreason: String,
226    // TODO timestamp type
227    // pub blockedtimestamp: String,
228    pub blockpartial: bool,
229    // pub blocknocreate: bool,
230    // pub blockanononly: bool,
231    // pub systemblocktype: Option<String>,
232}
233
234impl From<ApiError> for Error {
235    fn from(apierr: ApiError) -> Self {
236        match apierr.code.as_str() {
237            "assertuserfailed" => Self::NotLoggedIn,
238            "assertbotfailed" => Self::NotLoggedInAsBot,
239            "badtoken" => Self::BadToken,
240            "blocked" => {
241                let details = if let Some(data) = apierr.data {
242                    serde_json::from_value::<BlockDetails>(
243                        data["blockinfo"].clone(),
244                    )
245                    .ok()
246                } else {
247                    None
248                };
249                match details {
250                    Some(details) => {
251                        if details.blockpartial {
252                            Self::PartiallyBlocked {
253                                info: apierr.text,
254                                details,
255                            }
256                        } else {
257                            Self::Blocked {
258                                info: apierr.text,
259                                details,
260                            }
261                        }
262                    }
263                    None => Self::UnknownBlock(apierr.text),
264                }
265            }
266            "contenttoobig" => Self::ContentTooBig(apierr.text),
267            "editconflict" => Self::EditConflict,
268            "globalblocking-ipblocked"
269            | "wikimedia-globalblocking-ipblocked"
270            | "globalblocking-blockedtext-ip"
271            | "wikimedia-globalblocking-blockedtext-ip" => {
272                Self::GloballyBlocked(apierr.text)
273            }
274            "globalblocking-ipblocked-range"
275            | "wikimedia-globalblocking-ipblocked-range"
276            | "globalblocking-blockedtext-range"
277            | "wikimedia-globalblocking-blockedtext-range" => {
278                Self::GloballyRangeBlocked(apierr.text)
279            }
280            "globalblocking-ipblocked-xff"
281            | "wikimedia-globalblocking-ipblocked-xff"
282            | "globalblocking-blockedtext-xff"
283            | "wikimedia-globalblocking-blockedtext-xff" => {
284                Self::GloballyXFFBlocked(apierr.text)
285            }
286            "globalblocking-blockedtext-user"
287            | "wikimedia-globalblocking-blockedtext-user" => {
288                Self::GloballyBlocked(apierr.text)
289            }
290            "login-throttled" => Self::LoginThrottled,
291            "maxlag" => Self::Maxlag {
292                info: apierr.text,
293                retry_after: None,
294            },
295            "protectedpage" => Self::ProtectedPage,
296            "readonly" => Self::Readonly {
297                info: apierr.text,
298                retry_after: None,
299            },
300            "spamblacklist" => {
301                let matches = if let Some(data) = apierr.data {
302                    match serde_json::from_value::<SpamFilterData>(
303                        data["spamblacklist"].clone(),
304                    ) {
305                        Ok(data) => data.matches,
306                        // Not worth raising an error over this
307                        Err(_) => vec![],
308                    }
309                } else {
310                    vec![]
311                };
312                Self::SpamFilter {
313                    info: apierr.text,
314                    matches,
315                }
316            }
317            "undofailure" => Self::UndoFailure(apierr.text),
318            "wrongpassword" => Self::WrongPassword,
319            code => {
320                if code.starts_with("internal_api_error_") {
321                    Self::InternalException(apierr)
322                } else {
323                    Self::ApiError(apierr)
324                }
325            }
326        }
327    }
328}
329
330impl From<mwapi::Error> for Error {
331    fn from(value: mwapi::Error) -> Self {
332        match value {
333            mwapi::Error::HttpError(err) => Error::HttpError(err),
334            mwapi::Error::InvalidHeaderValue(err) => {
335                Error::InvalidHeaderValue(err)
336            }
337            mwapi::Error::InvalidJson(err) => Error::InvalidJson(err),
338            mwapi::Error::LockFailure(err) => Error::LockFailure(err),
339            mwapi::Error::IoError(err) => Error::IoError(err),
340            mwapi::Error::TooManyRequests { retry_after } => {
341                Error::TooManyRequests { retry_after }
342            }
343            mwapi::Error::BadToken => Error::BadToken,
344            mwapi::Error::TokenFailure(token) => Error::TokenFailure(token),
345            mwapi::Error::NotLoggedIn => Error::NotLoggedIn,
346            mwapi::Error::NotLoggedInAsBot => Error::NotLoggedInAsBot,
347            mwapi::Error::UploadWarning(warnings) => Error::UploadWarning(
348                warnings.into_iter().map(UploadWarning::from_key).collect(),
349            ),
350            mwapi::Error::Maxlag { info, retry_after } => {
351                Error::Maxlag { info, retry_after }
352            }
353            mwapi::Error::Readonly { info, retry_after } => {
354                Error::Readonly { info, retry_after }
355            }
356            mwapi::Error::InternalException(err) => {
357                Error::InternalException(err)
358            }
359            mwapi::Error::ApiError(apierr) => Error::from(apierr),
360            mwapi::Error::Unknown(err) => Error::Unknown(err),
361            err => Error::Unknown(err.to_string()),
362        }
363    }
364}
365
366impl From<parsoid::Error> for Error {
367    fn from(value: parsoid::Error) -> Self {
368        match value {
369            parsoid::Error::Http(err) => Error::HttpError(err),
370            parsoid::Error::InvalidHeaderValue(err) => {
371                Error::InvalidHeaderValue(err)
372            }
373            parsoid::Error::InvalidJson(err) => Error::InvalidJson(err),
374            parsoid::Error::LockFailure(err) => Error::LockFailure(err),
375            parsoid::Error::PageDoesNotExist(title) => {
376                Error::PageDoesNotExist(title)
377            }
378            parsoid::Error::InvalidEtag => Error::InvalidEtag,
379            parsoid::Error::InvalidHeadingLevel(level) => {
380                Error::InvalidHeadingLevel(level)
381            }
382            err => Error::Unknown(err.to_string()),
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_is_page_related() {
393        assert!(Error::EditConflict.is_page_related());
394        assert!(!Error::Unknown("bar".to_string()).is_page_related());
395    }
396
397    #[test]
398    fn test_from_apierror() {
399        let apierr = ApiError {
400            code: "assertbotfailed".to_string(),
401            text: "Something something".to_string(),
402            data: None,
403        };
404        let err = Error::from(apierr);
405        assert!(matches!(err, Error::NotLoggedInAsBot));
406    }
407
408    #[test]
409    fn test_to_string() {
410        let apierr = ApiError {
411            code: "errorcode".to_string(),
412            text: "Some description".to_string(),
413            data: None,
414        };
415        assert_eq!(&apierr.to_string(), "(code: errorcode): Some description");
416    }
417
418    #[test]
419    fn test_spamfilter() {
420        let apierr = ApiError {
421            code: "spamblacklist".to_string(),
422            text: "blah blah".to_string(),
423            data: Some(serde_json::json!({
424                "spamblacklist": {
425                    "matches": [
426                        "example.org"
427                    ]
428                }
429            })),
430        };
431        let err = Error::from(apierr);
432        if let Error::SpamFilter { matches, .. } = err {
433            assert_eq!(matches, vec!["example.org".to_string()]);
434        } else {
435            panic!("Unexpected error: {err:?}");
436        }
437    }
438}