Skip to content

Commit

Permalink
Handle export email throttling
Browse files Browse the repository at this point in the history
  • Loading branch information
BijinDev committed Dec 16, 2024
1 parent 9c206df commit 86160d9
Show file tree
Hide file tree
Showing 5 changed files with 46 additions and 33 deletions.
11 changes: 10 additions & 1 deletion src/common/api/common/error/SuspensionError.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
//@bundleInto:common-min

import { TutanotaError } from "@tutao/tutanota-error"
import { filterInt } from "@tutao/tutanota-utils"

export class SuspensionError extends TutanotaError {
constructor(message: string) {
// milliseconds to wait
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class MailExportTokenFacade {
}

this.currentExportToken = null
this.currentExportTokenRequest = this.serviceExecutor.post(MailExportTokenService, null, { suspensionBehavior: SuspensionBehavior.Suspend }).then(
this.currentExportTokenRequest = this.serviceExecutor.post(MailExportTokenService, null, { suspensionBehavior: SuspensionBehavior.Throw }).then(
(result) => {
this.currentExportToken = result.mailExportToken as MailExportToken
this.currentExportTokenRequest = null
Expand Down
7 changes: 6 additions & 1 deletion src/common/api/worker/rest/RestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,12 @@ 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 && (parseInt(suspensionTime) * 1000).toString(),
),
)
} else if (isSuspensionResponse(xhr.status, suspensionTime)) {
this.suspensionHandler.activateSuspensionIfInactive(Number(suspensionTime), resourceURL)

Expand Down
32 changes: 16 additions & 16 deletions src/mail-app/native/main/MailExportController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ 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 } 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 { CancelledError } from "../../../common/api/common/error/CancelledError.js"
import { FileOpenError } from "../../../common/api/common/error/FileOpenError.js"
import { isOfflineError } from "../../../common/api/common/utils/ErrorUtils.js"
import { ServiceUnavailableError, TooManyRequestsError } 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" }
Expand Down Expand Up @@ -88,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)
Expand Down Expand Up @@ -125,20 +126,10 @@ export class MailExportController {
}

private async runExport(mailboxDetail: MailboxDetail, mailBags: MailBag[], mailId: Id) {
const startTime = assertNotNull(this._lastExport)
for (const mailBag of mailBags) {
try {
await this.exportMailBag(mailBag, mailId)
if (this._state().type !== "exporting") {
return
}
} catch (e) {
if (e instanceof TooManyRequestsError) {
this._state({ type: "error", message: "exportErrorTooManyRequests_label" })
} else if (e instanceof ServiceUnavailableError) {
this._state({ type: "error", message: "exportErrorServiceUnavailable_label" })
} else {
throw e
}
await this.exportMailBag(mailBag, mailId)
if (this._state().type !== "exporting" || this._lastExport !== startTime) {
return
}
}
Expand Down Expand Up @@ -198,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} ms: ${e.message}`)

const currentExportTime = this._lastExport
await delay(timeToWait)
if (this._state().type !== "exporting" || this._lastExport !== currentExportTime) {
return
}
} else {
throw e
}
console.log(TAG, "Trying to continue with export")
}
}
}
Expand Down
27 changes: 13 additions & 14 deletions test/tests/native/main/MailExportControllerTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ 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"
import { SuspensionError } from "../../../../src/common/api/common/error/SuspensionError"

o.spec("MailExportController", function () {
const userId = "userId"
Expand Down Expand Up @@ -185,19 +184,19 @@ o.spec("MailExportController", function () {
})

o.spec("handle errors", function () {
o.test("TooManyRequestsError", async () => {
when(mailExportFacade.loadFixedNumberOfMailsWithCache(matchers.anything(), matchers.anything())).thenReject(new TooManyRequestsError(":("))
o.test("SuspensionError", async () => {
let wasThrown = false
when(mailExportFacade.loadFixedNumberOfMailsWithCache(matchers.anything(), matchers.anything())).thenDo(() => {
if (wasThrown) {
return Promise.resolve([])
} else {
wasThrown = true
return Promise.reject(new SuspensionError(":(", "10"))
}
})
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")
verify(mailExportFacade.loadFixedNumberOfMailsWithCache(matchers.anything(), matchers.anything()), { times: 3 + 1 })
o(wasThrown).equals(true)
})
})
})

0 comments on commit 86160d9

Please sign in to comment.