[download] Consider that MirrorChooser can auto-resume downloads as well

Also try next mirror when you need to resume, but the current one doesn't support it.
This commit is contained in:
Torsten Grote 2023-02-01 14:02:34 -03:00 committed by Hans-Christoph Steiner
parent ee2fba58b0
commit a70f89a255
5 changed files with 87 additions and 2 deletions

View File

@ -157,7 +157,6 @@ public class HttpDownloader constructor(
try {
downloadFromBytesReceiver(resumable)
} catch (e: NoResumeException) {
require(resumable) { "Got $e even though download was not resumable" }
if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" }
downloadFromBytesReceiver(false)
}

View File

@ -74,7 +74,6 @@ public class HttpDownloaderV2 constructor(
try {
downloadFromBytesReceiver(resumable)
} catch (e: NoResumeException) {
require(resumable) { "Got $e even though download was not resumable" }
if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" }
downloadFromBytesReceiver(false)
}

View File

@ -5,15 +5,20 @@ import io.ktor.client.engine.config
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondOk
import io.ktor.client.network.sockets.SocketTimeoutException
import io.ktor.client.utils.buildHeaders
import io.ktor.http.HttpHeaders.ContentLength
import io.ktor.http.HttpHeaders.ETag
import io.ktor.http.HttpHeaders.LastModified
import io.ktor.http.HttpHeaders.Range
import io.ktor.http.HttpMethod.Companion.Get
import io.ktor.http.HttpMethod.Companion.Head
import io.ktor.http.HttpStatusCode.Companion.OK
import io.ktor.http.HttpStatusCode.Companion.PartialContent
import io.ktor.http.headersOf
import io.ktor.utils.io.core.internal.ChunkBuffer
import io.ktor.utils.io.core.writeFully
import org.fdroid.TestByteReadChannel
import org.fdroid.get
import org.fdroid.getIndexFile
import org.fdroid.getRandomString
@ -122,6 +127,71 @@ internal class HttpDownloaderTest {
assertEquals(2, mockEngine.responseHistory.size)
}
/**
* Tests that a failed download in one mirror will be automatically resumed
* with the next mirror and then restarted if that mirror doesn't support [PartialContent].
*/
@Test
fun testMirrorNoResume() = runSuspend {
// we need at least two mirrors
val mirror2 = Mirror("http://example.net")
val mirrors = listOf(mirror1, mirror2)
val downloadRequest = DownloadRequest(getIndexFile("foo/bar"), mirrors)
val file = folder.newFile()
val firstBytes = Random.nextBytes(DEFAULT_BUFFER_SIZE)
val secondBytes = Random.nextBytes(1024)
val totalSize = firstBytes.size + secondBytes.size
val readChannel = object : TestByteReadChannel() {
var wasRead = 0
override val availableForRead: Int = DEFAULT_BUFFER_SIZE / 2
override suspend fun readAvailable(dst: ChunkBuffer): Int {
// We allow three reads. Only the first two give us the firstBytes.
// While the third seems to be required for throwing an exception,
// it isn't filling the buffer when we finally throw,
// so it isn't considered as transferred bytes.
if (wasRead == 3) throw SocketTimeoutException("boom!")
dst.writeFully(
source = firstBytes + Random.nextBytes(availableForRead),
offset = wasRead * availableForRead,
length = availableForRead,
)
wasRead++
return availableForRead
}
}
val mockEngine = MockEngine.config {
reuseHandlers = false
// first response reads from channel that errors after sending firstBytes
addHandler {
respond(readChannel, OK, headers = headersOf(ContentLength, "$totalSize"))
}
// second request tries to resume, but doesn't get PartialContent response
addHandler {
assertEquals("bytes=$DEFAULT_BUFFER_SIZE-", it.headers[Range])
respond(
content = firstBytes + secondBytes,
status = OK,
headers = headersOf(ContentLength, "$totalSize"),
)
}
// download is tried again without resuming
addHandler {
respond(
content = firstBytes + secondBytes,
status = OK,
headers = headersOf(ContentLength, "$totalSize"),
)
}
}
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine)
val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file)
httpDownloader.download()
assertContentEquals(firstBytes + secondBytes, file.readBytes())
}
/**
* Tests resuming a download with hash verification.
* This can fail if the hashing doesn't take the already downloaded bytes into account.

View File

@ -62,6 +62,9 @@ internal abstract class MirrorChooserImpl : MirrorChooser {
throwOnLastMirror(e, index == mirrors.size - 1)
} catch (e: SocketTimeoutException) {
throwOnLastMirror(e, index == mirrors.size - 1)
} catch (e: NoResumeException) {
// continue to next mirror, if we need to resume, but this one doesn't support it
throwOnLastMirror(e, index == mirrors.size - 1)
}
}
error("Reached code that was thought to be unreachable.")

View File

@ -58,6 +58,20 @@ class MirrorChooserTest {
assertEquals(expectedResult, result)
}
@Test
fun testFallbackToNextMirrorWithNoResumeException() = runSuspend {
val mirrorChooser = MirrorChooserRandom()
val expectedResult = Random.nextInt()
val result = mirrorChooser.mirrorRequest(downloadRequest) { mirror, url ->
assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url)
// fails with all except last mirror
if (mirror != downloadRequest.mirrors.last()) throw NoResumeException()
expectedResult
}
assertEquals(expectedResult, result)
}
@Test
fun testMirrorChooserRandom() {
val mirrorChooser = MirrorChooserRandom()