diff --git a/src/common/api/common/error/SuspensionError.ts b/src/common/api/common/error/SuspensionError.ts index bc2e84f3f0e7..6d21ed067e85 100644 --- a/src/common/api/common/error/SuspensionError.ts +++ b/src/common/api/common/error/SuspensionError.ts @@ -1,9 +1,17 @@ //@bundleInto:common-min import { TutanotaError } from "@tutao/tutanota-error" +import { filterInt } from "@tutao/tutanota-utils" export class SuspensionError extends TutanotaError { - constructor(message: string) { + readonly data: string | null + constructor(message: string, suspensionTime: string | null) { super("SuspensionError", message) + + if (suspensionTime != null && Number.isNaN(filterInt(suspensionTime))) { + throw new Error("invalid suspension time value (NaN)") + } + + this.data = suspensionTime } } diff --git a/src/common/api/worker/facades/lazy/MailExportTokenFacade.ts b/src/common/api/worker/facades/lazy/MailExportTokenFacade.ts index 39daed6ff5e2..06f8393d4fab 100644 --- a/src/common/api/worker/facades/lazy/MailExportTokenFacade.ts +++ b/src/common/api/worker/facades/lazy/MailExportTokenFacade.ts @@ -1,6 +1,7 @@ import { AccessExpiredError } from "../../../common/error/RestError.js" import { MailExportTokenService } from "../../../entities/tutanota/Services.js" import { IServiceExecutor } from "../../../common/ServiceRequest.js" +import { SuspensionBehavior } from "../../rest/RestClient" const TAG = "[MailExportTokenFacade]" @@ -65,7 +66,7 @@ export class MailExportTokenFacade { } this.currentExportToken = null - this.currentExportTokenRequest = this.serviceExecutor.post(MailExportTokenService, null).then( + this.currentExportTokenRequest = this.serviceExecutor.post(MailExportTokenService, null, { suspensionBehavior: SuspensionBehavior.Throw }).then( (result) => { this.currentExportToken = result.mailExportToken as MailExportToken this.currentExportTokenRequest = null diff --git a/src/common/api/worker/rest/RestClient.ts b/src/common/api/worker/rest/RestClient.ts index 3a336cb8c1ea..efba6c3e365e 100644 --- a/src/common/api/worker/rest/RestClient.ts +++ b/src/common/api/worker/rest/RestClient.ts @@ -127,7 +127,7 @@ export class RestClient { const suspensionTime = xhr.getResponseHeader("Retry-After") || xhr.getResponseHeader("Suspension-Time") if (isSuspensionResponse(xhr.status, suspensionTime) && options.suspensionBehavior === SuspensionBehavior.Throw) { - reject(new SuspensionError(`blocked for ${suspensionTime}, not suspending`)) + reject(new SuspensionError(`blocked for ${suspensionTime}, not suspending (${xhr.status})`, suspensionTime)) } else if (isSuspensionResponse(xhr.status, suspensionTime)) { this.suspensionHandler.activateSuspensionIfInactive(Number(suspensionTime), resourceURL) diff --git a/src/common/misc/TranslationKey.ts b/src/common/misc/TranslationKey.ts index 95945787e443..bee7a4567f12 100644 --- a/src/common/misc/TranslationKey.ts +++ b/src/common/misc/TranslationKey.ts @@ -1817,3 +1817,5 @@ export type TranslationKeyType = | "you_label" | "emptyString_msg" | "exportFinished_label" + | "exportErrorTooManyRequests_label" + | "exportErrorServiceUnavailable_label" diff --git a/src/mail-app/native/main/MailExportController.ts b/src/mail-app/native/main/MailExportController.ts index 2049bc08c8cd..8c788cd31436 100644 --- a/src/mail-app/native/main/MailExportController.ts +++ b/src/mail-app/native/main/MailExportController.ts @@ -3,22 +3,21 @@ import Stream from "mithril/stream" import stream from "mithril/stream" import { MailBag } from "../../../common/api/entities/tutanota/TypeRefs.js" import { GENERATED_MAX_ID, getElementId, isSameId } from "../../../common/api/common/utils/EntityUtils.js" -import { assertNotNull, delay, isNotNull, lastThrow, ofClass, promiseMap } from "@tutao/tutanota-utils" +import { assertNotNull, delay, filterInt, isNotNull, lastThrow } from "@tutao/tutanota-utils" import { HtmlSanitizer } from "../../../common/misc/HtmlSanitizer.js" import { ExportFacade } from "../../../common/native/common/generatedipc/ExportFacade.js" import { LoginController } from "../../../common/api/main/LoginController.js" -import { FileController } from "../../../common/file/FileController.js" import { CancelledError } from "../../../common/api/common/error/CancelledError.js" -import { BulkMailLoader } from "../../workerUtils/index/BulkMailLoader.js" import { FileOpenError } from "../../../common/api/common/error/FileOpenError.js" import { isOfflineError } from "../../../common/api/common/utils/ErrorUtils.js" -import { NotFoundError } from "../../../common/api/common/error/RestError.js" import { MailExportFacade } from "../../../common/api/worker/facades/lazy/MailExportFacade.js" +import type { TranslationText } from "../../../common/misc/LanguageViewModel" +import { SuspensionError } from "../../../common/api/common/error/SuspensionError" export type MailExportState = | { type: "idle" } | { type: "exporting"; mailboxDetail: MailboxDetail; progress: number; exportedMails: number } - | { type: "error"; message: string } + | { type: "error"; message: TranslationText } | { type: "finished" mailboxDetail: MailboxDetail @@ -89,6 +88,7 @@ export class MailExportController { return } this._state({ type: "exporting", mailboxDetail: mailboxDetail, progress: 0, exportedMails: exportState.exportedMails }) + this._lastExport = new Date() await this.resumeExport(mailboxDetail, exportState.mailBagId, exportState.mailId) } else if (exportState.type === "finished") { const mailboxDetail = await this.mailboxModel.getMailboxDetailByMailboxId(exportState.mailboxId) @@ -126,9 +126,10 @@ export class MailExportController { } private async runExport(mailboxDetail: MailboxDetail, mailBags: MailBag[], mailId: Id) { + const startTime = assertNotNull(this._lastExport) for (const mailBag of mailBags) { await this.exportMailBag(mailBag, mailId) - if (this._state().type !== "exporting") { + if (this._state().type !== "exporting" || this._lastExport !== startTime) { return } } @@ -171,7 +172,7 @@ export class MailExportController { await this.exportFacade.saveMailboxExport(mailBundle, this.userId, mailBag._id, getElementId(mail)) } catch (e) { if (e instanceof FileOpenError) { - this._state({ type: "error", message: e.message }) + this._state({ type: "error", message: () => e.message }) return } else { throw e @@ -188,10 +189,19 @@ export class MailExportController { if (isOfflineError(e)) { console.log(TAG, "Offline, will retry later") await delay(1000 * 60) // 1 min - console.log(TAG, "Trying to continue with export") + } else if (e instanceof SuspensionError) { + const timeToWait = Math.max(filterInt(assertNotNull(e.data)), 1) + console.log(TAG, `Suspended for ${timeToWait} sec: ${e.message}`) + + const currentExportTime = this._lastExport + await delay(timeToWait * 1000) + if (this._state().type !== "exporting" || this._lastExport !== currentExportTime) { + return + } } else { throw e } + console.log(TAG, "Trying to continue with export") } } } diff --git a/src/mail-app/settings/MailExportSettings.ts b/src/mail-app/settings/MailExportSettings.ts index c4ce25324f11..3b11da036b3e 100644 --- a/src/mail-app/settings/MailExportSettings.ts +++ b/src/mail-app/settings/MailExportSettings.ts @@ -114,7 +114,7 @@ export class MailExportSettings implements Component { case "error": return [ m(".flex-space-between.items-center.mt.mb-s", [ - m("small.noselect", state.message), + m("small.noselect", lang.getMaybeLazy(state.message)), m(Button, { label: "retry_action", click: () => { diff --git a/src/mail-app/translations/de.ts b/src/mail-app/translations/de.ts index 2395b30d7470..276357ad1430 100644 --- a/src/mail-app/translations/de.ts +++ b/src/mail-app/translations/de.ts @@ -1837,5 +1837,7 @@ export default { "yourMessage_label": "Deine Nachricht", "you_label": "Du", "exportFinished_label": "Export finished", + "exportErrorTooManyRequests_label": "Too many exports were requested recently", + "exportErrorServiceUnavailable_label": "Exporting is temporarily unavailable; please try again later" } } diff --git a/src/mail-app/translations/de_sie.ts b/src/mail-app/translations/de_sie.ts index d65b521a4699..29ef584e46be 100644 --- a/src/mail-app/translations/de_sie.ts +++ b/src/mail-app/translations/de_sie.ts @@ -1837,5 +1837,7 @@ export default { "yourMessage_label": "Ihre Nachricht", "you_label": "Sie", "exportFinished_label": "Export finished", + "exportErrorTooManyRequests_label": "Too many exports were requested recently", + "exportErrorServiceUnavailable_label": "Exporting is temporarily unavailable; please try again later" } } diff --git a/src/mail-app/translations/en.ts b/src/mail-app/translations/en.ts index 5f83347e7d91..8955c1b09f45 100644 --- a/src/mail-app/translations/en.ts +++ b/src/mail-app/translations/en.ts @@ -1833,5 +1833,7 @@ export default { "yourMessage_label": "Your message", "you_label": "You", "exportFinished_label": "Export finished", + "exportErrorTooManyRequests_label": "Too many exports were requested recently", + "exportErrorServiceUnavailable_label": "Exporting is temporarily unavailable; please try again later" } } diff --git a/test/tests/api/worker/facades/MailExportTokenFacadeTest.ts b/test/tests/api/worker/facades/MailExportTokenFacadeTest.ts index d2c73a355cfd..ba110a9321b7 100644 --- a/test/tests/api/worker/facades/MailExportTokenFacadeTest.ts +++ b/test/tests/api/worker/facades/MailExportTokenFacadeTest.ts @@ -1,5 +1,5 @@ import o from "@tutao/otest" -import { func, object, when } from "testdouble" +import { func, matchers, object, when } from "testdouble" import { createMailExportTokenServicePostOut } from "../../../../../src/common/api/entities/tutanota/TypeRefs" import { MailExportTokenService } from "../../../../../src/common/api/entities/tutanota/Services" import { AccessExpiredError, TooManyRequestsError } from "../../../../../src/common/api/common/error/RestError" @@ -23,7 +23,9 @@ o.spec("MailExportTokenFacade", () => { const expected = "result" const cb = func<(token: string) => Promise>() when(cb(validToken)).thenResolve(expected) - when(serviceExecutor.post(MailExportTokenService, null)).thenResolve(createMailExportTokenServicePostOut({ mailExportToken: validToken })) + when(serviceExecutor.post(MailExportTokenService, null, matchers.anything())).thenResolve( + createMailExportTokenServicePostOut({ mailExportToken: validToken }), + ) const result = await facade.loadWithToken(cb) @@ -47,7 +49,9 @@ o.spec("MailExportTokenFacade", () => { when(cb(validToken)).thenResolve(expected) when(cb(expiredToken)).thenReject(new AccessExpiredError("token expired")) facade._setCurrentExportToken(expiredToken) - when(serviceExecutor.post(MailExportTokenService, null)).thenResolve(createMailExportTokenServicePostOut({ mailExportToken: validToken })) + when(serviceExecutor.post(MailExportTokenService, null, matchers.anything())).thenResolve( + createMailExportTokenServicePostOut({ mailExportToken: validToken }), + ) const result = await facade.loadWithToken(cb) @@ -57,7 +61,7 @@ o.spec("MailExportTokenFacade", () => { o.test("when requesting token fails none are stored", async () => { const cb = func<(token: string) => Promise>() when(cb(expiredToken)).thenReject(new AccessExpiredError("token expired")) - when(serviceExecutor.post(MailExportTokenService, null)).thenReject(new TooManyRequestsError("no more tokens :(")) + when(serviceExecutor.post(MailExportTokenService, null, matchers.anything())).thenReject(new TooManyRequestsError("no more tokens :(")) await o(() => facade.loadWithToken(cb)).asyncThrows(TooManyRequestsError) diff --git a/test/tests/native/main/MailExportControllerTest.ts b/test/tests/native/main/MailExportControllerTest.ts index 7363bededf78..7042e8472136 100644 --- a/test/tests/native/main/MailExportControllerTest.ts +++ b/test/tests/native/main/MailExportControllerTest.ts @@ -26,6 +26,8 @@ import { createDataFile } from "../../../../src/common/api/common/DataFile.js" import { makeMailBundle } from "../../../../src/mail-app/mail/export/Bundler.js" import { MailboxExportState } from "../../../../src/common/desktop/export/MailboxExportPersistence.js" import { MailExportFacade } from "../../../../src/common/api/worker/facades/lazy/MailExportFacade.js" +import { ServiceUnavailableError, TooManyRequestsError } from "../../../../src/common/api/common/error/RestError" +import type { TranslationText } from "../../../../src/common/misc/LanguageViewModel" o.spec("MailExportController", function () { const userId = "userId" @@ -181,4 +183,21 @@ o.spec("MailExportController", function () { verify(exportFacade.endMailboxExport(userId)) }) }) + + o.spec("handle errors", function () { + o.test("TooManyRequestsError", async () => { + when(mailExportFacade.loadFixedNumberOfMailsWithCache(matchers.anything(), matchers.anything())).thenReject(new TooManyRequestsError(":(")) + await controller.startExport(mailboxDetail) + const currentState = controller.state() as { type: string; message: TranslationText } + o(currentState.type).equals("error") + o(currentState.message).equals("exportErrorTooManyRequests_label") + }) + o.test("ServiceUnavailableError", async () => { + when(mailExportFacade.loadFixedNumberOfMailsWithCache(matchers.anything(), matchers.anything())).thenReject(new ServiceUnavailableError(":(")) + await controller.startExport(mailboxDetail) + const currentState = controller.state() as { type: string; message: TranslationText } + o(currentState.type).equals("error") + o(currentState.message).equals("exportErrorServiceUnavailable_label") + }) + }) })