Skip to content

Commit 581c124

Browse files
committed
Fix redirect handling in KtorImageFetcher (#859)
LandscapistImage failed to load images whose URL performs a redirect, reporting `SendCountExceedException: Max send count 20 exceeded` even though only one redirect was involved. Two issues in KtorImageFetcher caused this: - No cookie store was installed. Some CDNs set a cookie on the first response and redirect to a target that requires it. Without retaining the cookie the target keeps redirecting, looping until the send-count limit is hit. Browsers retain cookies automatically, which is why the same URL loads in a browser. Install HttpCookies to match that. - maxSendCount was set to maxRedirects, conflating the total number of request dispatches (original + redirects + retries) with the redirect count. That both under-allowed redirects (off-by-one) and, before the setting existed, left Ktor's default of 20. Set it to maxRedirects + 1 so the original send plus maxRedirects hops are permitted. The shared client configuration is extracted into configureForImageLoading so create() and tests build an identical client. Adds MockEngine-based regression tests covering the cookie-gated redirect, the maxRedirects boundary, and failure beyond the limit.
1 parent 2d44e2a commit 581c124

4 files changed

Lines changed: 161 additions & 8 deletions

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ ktor-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "k
8484
ktor-engine-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
8585
ktor-engine-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
8686
ktor-engine-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
87+
ktor-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
8788
okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" }
8889
atomicfu = { group = "org.jetbrains.kotlinx", name = "atomicfu", version.ref = "atomicfu" }
8990
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }

landscapist-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ kotlin {
7070
dependencies {
7171
implementation(kotlin("test"))
7272
implementation(libs.kotlinx.coroutines.test)
73+
implementation(libs.ktor.mock)
7374
}
7475
}
7576

landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/network/KtorImageFetcher.kt

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import com.skydoves.landscapist.core.ImageRequest
1919
import com.skydoves.landscapist.core.NetworkConfig
2020
import com.skydoves.landscapist.core.model.DataSource
2121
import io.ktor.client.HttpClient
22+
import io.ktor.client.HttpClientConfig
2223
import io.ktor.client.plugins.HttpSend
2324
import io.ktor.client.plugins.HttpTimeout
25+
import io.ktor.client.plugins.cookies.HttpCookies
2426
import io.ktor.client.request.get
2527
import io.ktor.client.request.headers
2628
import io.ktor.client.statement.bodyAsBytes
@@ -113,20 +115,44 @@ public class KtorImageFetcher(
113115
*/
114116
public fun create(networkConfig: NetworkConfig = NetworkConfig()): KtorImageFetcher {
115117
val httpClient = HttpClient {
116-
install(HttpTimeout) {
117-
connectTimeoutMillis = networkConfig.connectTimeout.inWholeMilliseconds
118-
requestTimeoutMillis = networkConfig.readTimeout.inWholeMilliseconds
119-
}
120-
install(HttpSend) {
121-
maxSendCount = networkConfig.maxRedirects
122-
}
123-
followRedirects = networkConfig.followRedirects
118+
configureForImageLoading(networkConfig)
124119
}
125120
return KtorImageFetcher(httpClient, networkConfig)
126121
}
127122
}
128123
}
129124

125+
/**
126+
* Applies the shared [HttpClient] configuration used for image loading.
127+
*
128+
* Kept as a standalone extension so both [KtorImageFetcher.create] and tests configure the client
129+
* identically, regardless of the underlying engine.
130+
*
131+
* @param networkConfig Network configuration settings.
132+
*/
133+
internal fun HttpClientConfig<*>.configureForImageLoading(networkConfig: NetworkConfig) {
134+
install(HttpTimeout) {
135+
connectTimeoutMillis = networkConfig.connectTimeout.inWholeMilliseconds
136+
requestTimeoutMillis = networkConfig.readTimeout.inWholeMilliseconds
137+
}
138+
139+
// Persist cookies across redirect hops. Some CDNs set a cookie on the first response and
140+
// redirect to a target that requires it; without a cookie store the target keeps redirecting,
141+
// producing an endless loop that only ends when the send-count limit is hit. Browsers avoid this
142+
// because they retain cookies automatically.
143+
// See https://github.com/skydoves/landscapist/issues/859
144+
install(HttpCookies)
145+
146+
install(HttpSend) {
147+
// maxSendCount is the total number of times a request may be dispatched (the original request
148+
// plus every redirect and retry), not the number of redirects. Allow the original send plus
149+
// maxRedirects redirect hops so a request that legitimately redirects never trips the limit.
150+
maxSendCount = networkConfig.maxRedirects + 1
151+
}
152+
153+
followRedirects = networkConfig.followRedirects
154+
}
155+
130156
/**
131157
* Exception for HTTP errors.
132158
*
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Designed and developed by 2020-2023 skydoves (Jaewoong Eum)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.skydoves.landscapist.core.network
17+
18+
import com.skydoves.landscapist.core.ImageRequest
19+
import com.skydoves.landscapist.core.NetworkConfig
20+
import io.ktor.client.HttpClient
21+
import io.ktor.client.engine.mock.MockEngine
22+
import io.ktor.client.engine.mock.respond
23+
import io.ktor.client.plugins.SendCountExceedException
24+
import io.ktor.http.HttpHeaders
25+
import io.ktor.http.HttpStatusCode
26+
import io.ktor.http.headersOf
27+
import kotlinx.coroutines.Dispatchers
28+
import kotlinx.coroutines.test.runTest
29+
import kotlinx.coroutines.withContext
30+
import kotlin.test.Test
31+
import kotlin.test.assertContentEquals
32+
import kotlin.test.assertTrue
33+
34+
class KtorImageFetcherTest {
35+
36+
/**
37+
* Fetches on a real dispatcher so Ktor's [io.ktor.client.plugins.HttpTimeout] uses wall-clock
38+
* time. Under runTest's virtual clock the timeout delay would otherwise be advanced and fire
39+
* before the (instant) mock response arrives.
40+
*/
41+
private suspend fun fetchImage(
42+
engine: MockEngine,
43+
config: NetworkConfig,
44+
url: String,
45+
): FetchResult = withContext(Dispatchers.Default) {
46+
val client = HttpClient(engine) { configureForImageLoading(config) }
47+
KtorImageFetcher(client, config).fetch(ImageRequest(model = url))
48+
}
49+
50+
/**
51+
* Regression test for https://github.com/skydoves/landscapist/issues/859.
52+
*
53+
* The endpoint only serves the image once a cookie set by its own redirect is present. Without a
54+
* cookie store the redirect target keeps redirecting, exhausting the send-count limit and failing
55+
* with SendCountExceedException. Installing [io.ktor.client.plugins.cookies.HttpCookies] retains
56+
* the cookie across the redirect hop, matching browser behavior, so the image resolves.
57+
*/
58+
@Test
59+
fun cookieGatedRedirectResolvesInsteadOfLooping() = runTest {
60+
val imageBytes = byteArrayOf(0x1, 0x2, 0x3, 0x4)
61+
val engine = MockEngine { request ->
62+
if (request.headers[HttpHeaders.Cookie]?.contains("session=granted") == true) {
63+
respond(
64+
content = imageBytes,
65+
status = HttpStatusCode.OK,
66+
headers = headersOf(HttpHeaders.ContentType, "image/png"),
67+
)
68+
} else {
69+
respond(
70+
content = "",
71+
status = HttpStatusCode.Found,
72+
headers = headersOf(
73+
HttpHeaders.Location to listOf("https://cdn.example.com/image.png"),
74+
HttpHeaders.SetCookie to listOf("session=granted; Path=/"),
75+
),
76+
)
77+
}
78+
}
79+
80+
val result = fetchImage(engine, NetworkConfig(), "https://cdn.example.com/image.png")
81+
82+
assertTrue(result is FetchResult.Success, "expected success but was $result")
83+
assertContentEquals(imageBytes, result.data)
84+
}
85+
86+
/** A redirect chain no longer than [NetworkConfig.maxRedirects] hops is followed to success. */
87+
@Test
88+
fun redirectChainWithinMaxRedirectsSucceeds() = runTest {
89+
val imageBytes = byteArrayOf(0x5, 0x6)
90+
// maxRedirects = 2 permits: /1 -> /2 -> /ok (two redirect hops).
91+
val engine = MockEngine { request ->
92+
when (request.url.encodedPath) {
93+
"/1" -> respond("", HttpStatusCode.Found, headersOf(HttpHeaders.Location, "https://h/2"))
94+
"/2" -> respond("", HttpStatusCode.Found, headersOf(HttpHeaders.Location, "https://h/ok"))
95+
else -> respond(
96+
content = imageBytes,
97+
status = HttpStatusCode.OK,
98+
headers = headersOf(HttpHeaders.ContentType, "image/png"),
99+
)
100+
}
101+
}
102+
103+
val result = fetchImage(engine, NetworkConfig(maxRedirects = 2), "https://h/1")
104+
105+
assertTrue(result is FetchResult.Success, "expected success but was $result")
106+
assertContentEquals(imageBytes, result.data)
107+
}
108+
109+
/** A redirect chain longer than [NetworkConfig.maxRedirects] fails instead of looping forever. */
110+
@Test
111+
fun redirectChainBeyondMaxRedirectsFails() = runTest {
112+
// Always redirect: the send-count limit must stop it rather than looping indefinitely.
113+
val engine = MockEngine {
114+
respond("", HttpStatusCode.Found, headersOf(HttpHeaders.Location, "https://h/loop"))
115+
}
116+
117+
val result = fetchImage(engine, NetworkConfig(maxRedirects = 2), "https://h/start")
118+
119+
assertTrue(result is FetchResult.Error, "expected error but was $result")
120+
assertTrue(
121+
result.throwable is SendCountExceedException,
122+
"expected SendCountExceedException but was ${result.throwable}",
123+
)
124+
}
125+
}

0 commit comments

Comments
 (0)