diff --git a/proto/backend.proto b/proto/backend.proto index 5466814a3..d4dfd3553 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -55,9 +55,8 @@ message BackendError { TemplateParseError template_parse = 2; StringError io_error = 3; StringError db_error = 4; - StringError network_error = 5; - Empty ankiweb_auth_failed = 6; - StringError ankiweb_misc_error = 7; + NetworkError network_error = 5; + SyncError sync_error = 6; // user interrupted operation Empty interrupted = 8; } @@ -78,6 +77,30 @@ message TemplateParseError { bool q_side = 2; } +message NetworkError { + string info = 1; + enum NetworkErrorKind { + OTHER = 0; + OFFLINE = 1; + TIMEOUT = 2; + PROXY_AUTH = 3; + } + NetworkErrorKind kind = 2; +} + +message SyncError { + string info = 1; + enum SyncErrorKind { + OTHER = 0; + CONFLICT = 1; + SERVER_ERROR = 2; + CLIENT_TOO_OLD = 3; + AUTH_FAILED = 4; + SERVER_MESSAGE = 5; + } + SyncErrorKind kind = 2; +} + message MediaSyncProgress { oneof value { uint32 downloaded_changes = 1; @@ -222,4 +245,4 @@ message SyncMediaIn { string media_folder = 2; string media_db = 3; string endpoint = 4; -} \ No newline at end of file +} diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 92b5cd9df..be18fe870 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -28,8 +28,12 @@ class StringError(Exception): return self.args[0] # pylint: disable=unsubscriptable-object +NetworkErrorKind = pb.NetworkError.NetworkErrorKind + + class NetworkError(StringError): - pass + def kind(self) -> NetworkErrorKind: + return self.args[1] class IOError(StringError): @@ -45,12 +49,12 @@ class TemplateError(StringError): return self.args[1] -class AnkiWebError(StringError): - pass +SyncErrorKind = pb.SyncError.SyncErrorKind -class AnkiWebAuthFailed(Exception): - pass +class SyncError(StringError): + def kind(self) -> SyncErrorKind: + return self.args[1] def proto_exception_to_native(err: pb.BackendError) -> Exception: @@ -58,7 +62,8 @@ def proto_exception_to_native(err: pb.BackendError) -> Exception: if val == "interrupted": return Interrupted() elif val == "network_error": - return NetworkError(err.network_error.info) + e = err.network_error + return NetworkError(e.info, e.kind) elif val == "io_error": return IOError(err.io_error.info) elif val == "db_error": @@ -67,10 +72,9 @@ def proto_exception_to_native(err: pb.BackendError) -> Exception: return TemplateError(err.template_parse.info, err.template_parse.q_side) elif val == "invalid_input": return StringError(err.invalid_input.info) - elif val == "ankiweb_auth_failed": - return AnkiWebAuthFailed() - elif val == "ankiweb_misc_error": - return AnkiWebError(err.ankiweb_misc_error.info) + elif val == "sync_error": + e2 = err.sync_error + return SyncError(e2.info, e2.kind) else: assert_impossible_literal(val) diff --git a/qt/aqt/mediasync.py b/qt/aqt/mediasync.py index b56c8bb7e..3218e1814 100644 --- a/qt/aqt/mediasync.py +++ b/qt/aqt/mediasync.py @@ -14,7 +14,6 @@ from anki import hooks from anki.lang import _ from anki.media import media_paths_from_col_path from anki.rsbackend import ( - AnkiWebAuthFailed, DBError, Interrupted, MediaSyncDownloadedChanges, @@ -23,8 +22,11 @@ from anki.rsbackend import ( MediaSyncRemovedFiles, MediaSyncUploaded, NetworkError, + NetworkErrorKind, Progress, ProgressKind, + SyncError, + SyncErrorKind, ) from anki.types import assert_impossible from anki.utils import intTime @@ -42,10 +44,11 @@ class MediaSyncState: removed_files: int = 0 +# fixme: sync.rs fixmes +# fixme: maximum size when uploading # fixme: abort when closing collection/app # fixme: concurrent modifications during upload step # fixme: mediaSanity -# fixme: corruptMediaDB # fixme: autosync # elif evt == "mediaSanity": # showWarning( @@ -155,15 +158,31 @@ class MediaSyncer: return self._log_and_notify(_("Media sync failed.")) - if isinstance(exc, AnkiWebAuthFailed): - self.mw.pm.set_sync_key(None) - showWarning(_("AnkiWeb ID or password was incorrect; please try again.")) + if isinstance(exc, SyncError): + kind = exc.kind() + if kind == SyncErrorKind.AUTH_FAILED: + self.mw.pm.set_sync_key(None) + showWarning( + _("AnkiWeb ID or password was incorrect; please try again.") + ) + elif kind == SyncErrorKind.SERVER_ERROR: + showWarning( + _( + "AnkiWeb encountered a problem. Please try again in a few minutes." + ) + ) + else: + showWarning(_("Unexpected error: {}").format(str(exc))) elif isinstance(exc, NetworkError): - showWarning( - _("Syncing failed; please check your internet connection.") - + "\n\n" - + _("Detailed error: {}").format(str(exc)) - ) + nkind = exc.kind() + if nkind in (NetworkErrorKind.OFFLINE, NetworkErrorKind.TIMEOUT): + showWarning( + _("Syncing failed; please check your internet connection.") + + "\n\n" + + _("Detailed error: {}").format(str(exc)) + ) + else: + showWarning(_("Unexpected error: {}").format(str(exc))) elif isinstance(exc, DBError): showWarning(_("Problem accessing the media database: {}").format(str(exc))) else: diff --git a/rslib/src/backend.rs b/rslib/src/backend.rs index 05770bf36..377d0c4d5 100644 --- a/rslib/src/backend.rs +++ b/rslib/src/backend.rs @@ -5,7 +5,7 @@ use crate::backend_proto as pt; use crate::backend_proto::backend_input::Value; use crate::backend_proto::{Empty, RenderedTemplateReplacement, SyncMediaIn}; use crate::cloze::expand_clozes_to_reveal_latex; -use crate::err::{AnkiError, Result}; +use crate::err::{AnkiError, NetworkErrorKind, Result, SyncErrorKind}; use crate::media::sync::{sync_media, Progress as MediaSyncProgress}; use crate::media::MediaManager; use crate::sched::{local_minutes_west_for_stamp, sched_timing_today}; @@ -41,12 +41,17 @@ impl std::convert::From for pt::BackendError { AnkiError::InvalidInput { info } => V::InvalidInput(pt::StringError { info }), AnkiError::TemplateError { info, q_side } => { V::TemplateParse(pt::TemplateParseError { info, q_side }) - }, + } AnkiError::IOError { info } => V::IoError(pt::StringError { info }), AnkiError::DBError { info } => V::DbError(pt::StringError { info }), - AnkiError::NetworkError { info } => V::NetworkError(pt::StringError { info }), - AnkiError::AnkiWebAuthenticationFailed => V::AnkiwebAuthFailed(Empty {}), - AnkiError::AnkiWebMiscError { info } => V::AnkiwebMiscError(pt::StringError { info }), + AnkiError::NetworkError { info, kind } => V::NetworkError(pt::NetworkError { + info, + kind: kind.into(), + }), + AnkiError::SyncError { info, kind } => V::SyncError(pt::SyncError { + info, + kind: kind.into(), + }), AnkiError::Interrupted => V::Interrupted(Empty {}), }; @@ -61,6 +66,32 @@ impl std::convert::From for pt::backend_output::Value { } } +impl std::convert::From for i32 { + fn from(e: NetworkErrorKind) -> Self { + use pt::network_error::NetworkErrorKind as V; + (match e { + NetworkErrorKind::Offline => V::Offline, + NetworkErrorKind::Timeout => V::Timeout, + NetworkErrorKind::ProxyAuth => V::ProxyAuth, + NetworkErrorKind::Other => V::Other, + }) as i32 + } +} + +impl std::convert::From for i32 { + fn from(e: SyncErrorKind) -> Self { + use pt::sync_error::SyncErrorKind as V; + (match e { + SyncErrorKind::Conflict => V::Conflict, + SyncErrorKind::ServerError => V::ServerError, + SyncErrorKind::ClientTooOld => V::ClientTooOld, + SyncErrorKind::AuthFailed => V::AuthFailed, + SyncErrorKind::ServerMessage => V::ServerMessage, + SyncErrorKind::Other => V::Other, + }) as i32 + } +} + pub fn init_backend(init_msg: &[u8]) -> std::result::Result { let input: pt::BackendInit = match pt::BackendInit::decode(init_msg) { Ok(req) => req, diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 36006f53f..5df5ea266 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -2,6 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub use failure::{Error, Fail}; +use reqwest::StatusCode; use std::io; pub type Result = std::result::Result; @@ -20,14 +21,14 @@ pub enum AnkiError { #[fail(display = "DB error: {}", info)] DBError { info: String }, - #[fail(display = "Network error: {}", info)] - NetworkError { info: String }, + #[fail(display = "Network error: {:?} {}", kind, info)] + NetworkError { + info: String, + kind: NetworkErrorKind, + }, - #[fail(display = "AnkiWeb authentication failed.")] - AnkiWebAuthenticationFailed, - - #[fail(display = "AnkiWeb error: {}", info)] - AnkiWebMiscError { info: String }, + #[fail(display = "Sync error: {:?}, {}", kind, info)] + SyncError { info: String, kind: SyncErrorKind }, #[fail(display = "The user interrupted the operation.")] Interrupted, @@ -38,6 +39,20 @@ impl AnkiError { pub(crate) fn invalid_input>(s: S) -> AnkiError { AnkiError::InvalidInput { info: s.into() } } + + pub(crate) fn server_message>(msg: S) -> AnkiError { + AnkiError::SyncError { + info: msg.into(), + kind: SyncErrorKind::ServerMessage, + } + } + + pub(crate) fn sync_misc>(msg: S) -> AnkiError { + AnkiError::SyncError { + info: msg.into(), + kind: SyncErrorKind::Other, + } + } } #[derive(Debug, PartialEq)] @@ -78,28 +93,93 @@ impl From for AnkiError { } } +#[derive(Debug, PartialEq)] +pub enum NetworkErrorKind { + Offline, + Timeout, + ProxyAuth, + Other, +} + impl From for AnkiError { fn from(err: reqwest::Error) -> Self { let url = err.url().map(|url| url.as_str()).unwrap_or(""); let str_err = format!("{}", err); // strip url from error to avoid exposing keys - let str_err = str_err.replace(url, ""); - AnkiError::NetworkError { info: str_err } + let info = str_err.replace(url, ""); + + if err.is_timeout() { + AnkiError::NetworkError { + info, + kind: NetworkErrorKind::Timeout, + } + } else if err.is_status() { + error_for_status_code(info, err.status().unwrap()) + } else { + guess_reqwest_error(info) + } } } +#[derive(Debug, PartialEq)] +pub enum SyncErrorKind { + Conflict, + ServerError, + ClientTooOld, + AuthFailed, + ServerMessage, + Other, +} + +fn error_for_status_code(info: String, code: StatusCode) -> AnkiError { + use reqwest::StatusCode as S; + match code { + S::PROXY_AUTHENTICATION_REQUIRED => AnkiError::NetworkError { + info, + kind: NetworkErrorKind::ProxyAuth, + }, + S::CONFLICT => AnkiError::SyncError { + info, + kind: SyncErrorKind::Conflict, + }, + S::FORBIDDEN => AnkiError::SyncError { + info, + kind: SyncErrorKind::AuthFailed, + }, + S::NOT_IMPLEMENTED => AnkiError::SyncError { + info, + kind: SyncErrorKind::ClientTooOld, + }, + S::INTERNAL_SERVER_ERROR | S::BAD_GATEWAY | S::GATEWAY_TIMEOUT | S::SERVICE_UNAVAILABLE => { + AnkiError::SyncError { + info, + kind: SyncErrorKind::ServerError, + } + } + _ => AnkiError::NetworkError { + info, + kind: NetworkErrorKind::Other, + }, + } +} + +fn guess_reqwest_error(info: String) -> AnkiError { + let kind = if info.contains("unreachable") || info.contains("dns") { + NetworkErrorKind::Offline + } else { + NetworkErrorKind::Other + }; + AnkiError::NetworkError { info, kind } +} + impl From for AnkiError { fn from(err: zip::result::ZipError) -> Self { - AnkiError::AnkiWebMiscError { - info: format!("{:?}", err), - } + AnkiError::sync_misc(err.to_string()) } } impl From for AnkiError { fn from(err: serde_json::Error) -> Self { - AnkiError::AnkiWebMiscError { - info: format!("{:?}", err), - } + AnkiError::sync_misc(err.to_string()) } } diff --git a/rslib/src/media/sync.rs b/rslib/src/media/sync.rs index 973bb0bbe..4cff14b0e 100644 --- a/rslib/src/media/sync.rs +++ b/rslib/src/media/sync.rs @@ -10,7 +10,7 @@ use crate::media::{register_changes, MediaManager}; use bytes::Bytes; use log::debug; use reqwest; -use reqwest::{multipart, Client, Response, StatusCode}; +use reqwest::{multipart, Client, Response}; use serde_derive::{Deserialize, Serialize}; use serde_tuple::Serialize_tuple; use std::borrow::Cow; @@ -79,15 +79,14 @@ where .query(&[("k", hkey), ("v", "ankidesktop,2.1.19,mac")]) .send() .await? - .error_for_status() - .map_err(rewrite_forbidden)?; + .error_for_status()?; let reply: SyncBeginResult = resp.json().await?; if let Some(data) = reply.data { Ok((data.sync_key, data.usn)) } else { - Err(AnkiError::AnkiWebMiscError { info: reply.err }) + Err(AnkiError::server_message(reply.err)) } } @@ -184,15 +183,11 @@ where if data == "OK" { Ok(()) } else { - // fixme: force resync - Err(AnkiError::AnkiWebMiscError { - info: "resync required ".into(), - }) + // fixme: force resync, handle better + Err(AnkiError::server_message("resync required")) } } else { - Err(AnkiError::AnkiWebMiscError { - info: format!("finalize failed: {}", resp.err), - }) + Err(AnkiError::server_message(resp.err)) } } @@ -214,7 +209,7 @@ where if let Some(batch) = res.data { Ok(batch) } else { - Err(AnkiError::AnkiWebMiscError { info: res.err }) + Err(AnkiError::server_message(res.err)) } } @@ -298,14 +293,6 @@ struct SyncBeginResponse { usn: i32, } -fn rewrite_forbidden(err: reqwest::Error) -> AnkiError { - if err.is_status() && err.status().unwrap() == StatusCode::FORBIDDEN { - AnkiError::AnkiWebAuthenticationFailed - } else { - err.into() - } -} - #[derive(Debug, Clone, Copy)] enum LocalState { NotInDB, @@ -462,7 +449,7 @@ async fn ankiweb_request( .send() .await? .error_for_status() - .map_err(rewrite_forbidden) + .map_err(Into::into) } #[derive(Debug, Serialize)] @@ -486,9 +473,9 @@ fn extract_into_media_folder(media_folder: &Path, zip: Bytes) -> Result Result> { let normalized = normalize_filename(&file.fname); if let Cow::Owned(_) = normalized { // fixme: non-string err, or should ignore instead - return Err(AnkiError::AnkiWebMiscError { - info: "Invalid filename found. Please use the Check Media function.".to_owned(), - }); + return Err(AnkiError::sync_misc("invalid file found")); } let file_data = data_for_file(media_folder, &file.fname)?; @@ -581,9 +566,7 @@ fn zip_files(media_folder: &Path, files: &[MediaEntry]) -> Result> { if let Some(data) = &file_data { if data.is_empty() { // fixme: should ignore these, not error - return Err(AnkiError::AnkiWebMiscError { - info: "0 byte file found".to_owned(), - }); + return Err(AnkiError::sync_misc("0 byte file found")); } accumulated_size += data.len(); zip.start_file(format!("{}", idx), options)?;