Skip to content

Commit 01e46c1

Browse files
authored
fix: transport requests to throw AlgoliaRuntimeExceptions (#327)
1 parent 34ed4d5 commit 01e46c1

File tree

17 files changed

+196
-147
lines changed

17 files changed

+196
-147
lines changed

client/src/commonMain/kotlin/com/algolia/search/client/ClientAccount.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package com.algolia.search.client
22

33
import com.algolia.search.client.internal.IndexImpl
4+
import com.algolia.search.exception.AlgoliaApiException
45
import com.algolia.search.model.ApplicationID
56
import com.algolia.search.model.task.Task
6-
import io.ktor.client.features.ResponseException
77
import io.ktor.http.HttpStatusCode
88

99
/**
@@ -27,8 +27,8 @@ public object ClientAccount {
2727
var hasThrown404 = false
2828
try {
2929
destination.getSettings()
30-
} catch (exception: ResponseException) {
31-
hasThrown404 = exception.response.status.value == HttpStatusCode.NotFound.value
30+
} catch (exception: AlgoliaApiException) {
31+
hasThrown404 = exception.httpErrorCode == HttpStatusCode.NotFound.value
3232
if (!hasThrown404) throw exception
3333
}
3434
if (!hasThrown404) {

client/src/commonMain/kotlin/com/algolia/search/client/internal/ClientSearchImpl.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.algolia.search.endpoint.internal.EndpointAPIKey
1616
import com.algolia.search.endpoint.internal.EndpointDictionary
1717
import com.algolia.search.endpoint.internal.EndpointMulticluster
1818
import com.algolia.search.endpoint.internal.EndpointMultipleIndex
19+
import com.algolia.search.exception.AlgoliaApiException
1920
import com.algolia.search.model.IndexName
2021
import com.algolia.search.model.LogType
2122
import com.algolia.search.model.response.ResponseAPIKey
@@ -36,7 +37,6 @@ import com.algolia.search.serialize.RouteTask
3637
import com.algolia.search.transport.CustomRequester
3738
import com.algolia.search.transport.RequestOptions
3839
import com.algolia.search.transport.internal.Transport
39-
import io.ktor.client.features.ResponseException
4040
import io.ktor.http.HttpMethod
4141
import io.ktor.http.HttpStatusCode
4242
import kotlinx.coroutines.TimeoutCancellationException
@@ -104,8 +104,8 @@ internal class ClientSearchImpl internal constructor(
104104
while (true) {
105105
try {
106106
return getAPIKey(apiKey)
107-
} catch (exception: ResponseException) {
108-
if (exception.response.status.value != HttpStatusCode.NotFound.value) throw exception
107+
} catch (exception: AlgoliaApiException) {
108+
if (exception.httpErrorCode != HttpStatusCode.NotFound.value) throw exception
109109
}
110110
delay(1000L)
111111
}
@@ -126,8 +126,8 @@ internal class ClientSearchImpl internal constructor(
126126
while (true) {
127127
try {
128128
getAPIKey(apiKey)
129-
} catch (exception: ResponseException) {
130-
if (exception.response.status.value == HttpStatusCode.NotFound.value) return true else throw exception
129+
} catch (exception: AlgoliaApiException) {
130+
if (exception.httpErrorCode == HttpStatusCode.NotFound.value) return true else throw exception
131131
}
132132
delay(1000L)
133133
}

client/src/commonMain/kotlin/com/algolia/search/endpoint/internal/EndpointIndex.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package com.algolia.search.endpoint.internal
44

55
import com.algolia.search.configuration.CallType
66
import com.algolia.search.endpoint.EndpointIndex
7+
import com.algolia.search.exception.AlgoliaApiException
78
import com.algolia.search.model.IndexName
89
import com.algolia.search.model.index.Scope
910
import com.algolia.search.model.internal.request.RequestCopyOrMove
@@ -15,7 +16,6 @@ import com.algolia.search.serialize.KeyMove
1516
import com.algolia.search.serialize.internal.JsonNoDefaults
1617
import com.algolia.search.transport.RequestOptions
1718
import com.algolia.search.transport.internal.Transport
18-
import io.ktor.client.features.ResponseException
1919
import io.ktor.http.HttpMethod
2020
import io.ktor.http.HttpStatusCode
2121

@@ -67,8 +67,8 @@ internal class EndpointIndexImpl(
6767
override suspend fun exists(): Boolean {
6868
try {
6969
EndpointSearch(transport, indexName).search(Query(responseFields = emptyList()))
70-
} catch (exception: ResponseException) {
71-
if (exception.response.status == HttpStatusCode.NotFound) return false
70+
} catch (exception: AlgoliaApiException) {
71+
if (exception.httpErrorCode == HttpStatusCode.NotFound.value) return false
7272
}
7373
return true
7474
}
Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,39 @@
11
package com.algolia.search.exception
22

33
/**
4-
* Exception thrown when an error occurs during the Serialization/Deserialization process.
4+
* Algolia runtime exception.
5+
*
6+
* @param message the detail message
7+
* @param cause the cause of the exception
8+
*/
9+
public sealed class AlgoliaRuntimeException(
10+
message: String? = null,
11+
cause: Throwable? = null
12+
) : RuntimeException(message, cause)
13+
14+
/**
15+
* Exception thrown when an error occurs during API requests.
16+
*
17+
* @param message the detail message
18+
* @param cause the cause of the exception
519
*/
6-
public sealed class AlgoliaRuntimeException(message: String? = null, cause: Throwable? = null) :
7-
RuntimeException(message, cause)
20+
public class AlgoliaClientException(
21+
message: String? = null,
22+
cause: Throwable? = null
23+
) : AlgoliaRuntimeException(message, cause)
24+
25+
/**
26+
* Exception thrown in case of API failure.
27+
*
28+
* @param message the detail message
29+
* @param cause the cause of the exception
30+
* @param httpErrorCode
31+
*/
32+
public class AlgoliaApiException(
33+
message: String? = null,
34+
cause: Throwable? = null,
35+
public val httpErrorCode: Int? = null
36+
) : AlgoliaRuntimeException(message, cause)
837

938
/**
1039
* Exception thrown when all hosts are unreachable.
@@ -14,7 +43,4 @@ public sealed class AlgoliaRuntimeException(message: String? = null, cause: Thro
1443
*/
1544
public class UnreachableHostsException(
1645
public val exceptions: List<Throwable>,
17-
) : AlgoliaRuntimeException("Unreachable Hosts", exceptions.last()) {
18-
19-
public constructor(vararg exceptions: Throwable) : this(exceptions.toList())
20-
}
46+
) : AlgoliaRuntimeException("Error(s) while processing the retry strategy", exceptions.last())
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.algolia.search.exception.internal
2+
3+
import com.algolia.search.exception.AlgoliaApiException
4+
import com.algolia.search.exception.AlgoliaClientException
5+
import com.algolia.search.exception.AlgoliaRuntimeException
6+
import io.ktor.client.features.ResponseException
7+
8+
/**
9+
* Coerce a Throwable to a [AlgoliaClientException].
10+
*/
11+
internal fun Throwable.asClientException(): AlgoliaClientException {
12+
return AlgoliaClientException(message = message, cause = cause)
13+
}
14+
15+
/**
16+
* Coerce a [ResponseException] to a [AlgoliaRuntimeException].
17+
*/
18+
internal fun ResponseException.asApiException(): AlgoliaApiException {
19+
return AlgoliaApiException(message = message, cause = cause, httpErrorCode = response.status.value)
20+
}

client/src/commonMain/kotlin/com/algolia/search/transport/internal/Transport.kt

Lines changed: 89 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import com.algolia.search.configuration.Configuration
66
import com.algolia.search.configuration.Credentials
77
import com.algolia.search.configuration.RetryableHost
88
import com.algolia.search.exception.UnreachableHostsException
9+
import com.algolia.search.exception.internal.asApiException
10+
import com.algolia.search.exception.internal.asClientException
911
import com.algolia.search.transport.CustomRequester
1012
import com.algolia.search.transport.RequestOptions
1113
import io.ktor.client.call.HttpClientCall
@@ -23,7 +25,6 @@ import io.ktor.util.reflect.TypeInfo
2325
import io.ktor.utils.io.errors.IOException
2426
import kotlinx.coroutines.sync.Mutex
2527
import kotlinx.coroutines.sync.withLock
26-
import kotlin.math.floor
2728

2829
internal class Transport(
2930
configuration: Configuration,
@@ -35,6 +36,60 @@ internal class Transport(
3536

3637
internal val credentials get() = credentialsOrNull!!
3738

39+
/**
40+
* Runs an HTTP request (with retry strategy) and get a result as [T].
41+
*
42+
* @param httpMethod http method (verb)
43+
* @param callType indicate whether the HTTP call performed is of type Read or Write
44+
* @param path request path
45+
* @param requestOptions additional request configuration
46+
* @param body request body
47+
*/
48+
internal suspend inline fun <reified T> request(
49+
httpMethod: HttpMethod,
50+
callType: CallType,
51+
path: String,
52+
requestOptions: RequestOptions?,
53+
body: String? = null,
54+
): T {
55+
return execute(httpMethod, callType, path, requestOptions, body) {
56+
httpClient.request(it)
57+
}
58+
}
59+
60+
/**
61+
* Execute HTTP request (with retry strategy) and get a result as [T].
62+
*/
63+
private suspend inline fun <T> execute(
64+
httpMethod: HttpMethod,
65+
callType: CallType,
66+
path: String,
67+
requestOptions: RequestOptions?,
68+
body: String? = null,
69+
block: (HttpRequestBuilder) -> T
70+
): T {
71+
val hosts = callableHosts(callType)
72+
val errors by lazy(LazyThreadSafetyMode.NONE) { mutableListOf<Throwable>() }
73+
val requestBuilder = httpRequestBuilder(httpMethod, path, requestOptions, body)
74+
75+
for (host in hosts) {
76+
requestBuilder.url.host = host.url
77+
try {
78+
setTimeout(requestBuilder, requestOptions, callType, host)
79+
return block(requestBuilder).apply {
80+
mutex.withLock { host.reset() }
81+
}
82+
} catch (exception: Throwable) {
83+
host.handle(exception)
84+
errors += exception.asClientException()
85+
}
86+
}
87+
throw UnreachableHostsException(errors)
88+
}
89+
90+
/**
91+
* Get list of [RetryableHost] for a given [CallType].
92+
*/
3893
suspend fun callableHosts(callType: CallType): List<RetryableHost> {
3994
return mutex.withLock {
4095
hosts.expireHostsOlderThan(hostStatusExpirationDelayMS)
@@ -46,6 +101,9 @@ internal class Transport(
46101
}
47102
}
48103

104+
/**
105+
* Get a [HttpRequestBuilder] with given parameters.
106+
*/
49107
private fun httpRequestBuilder(
50108
httpMethod: HttpMethod,
51109
path: String,
@@ -74,64 +132,21 @@ internal class Transport(
74132
}
75133
}
76134

77-
internal suspend inline fun <reified T> request(
78-
httpMethod: HttpMethod,
79-
callType: CallType,
80-
path: String,
81-
requestOptions: RequestOptions?,
82-
body: String? = null,
83-
): T {
84-
return execute(httpMethod, callType, path, requestOptions, body) {
85-
httpClient.request(it)
86-
}
87-
}
88-
89-
private suspend fun genericRequest(
90-
httpMethod: HttpMethod,
91-
callType: CallType,
92-
path: String,
93-
requestOptions: RequestOptions?,
94-
body: String? = null
95-
): HttpResponse {
96-
return execute(httpMethod, callType, path, requestOptions, body) {
97-
httpClient.request(it)
98-
}
99-
}
100-
101-
private suspend inline fun <T> execute(
102-
httpMethod: HttpMethod,
103-
callType: CallType,
104-
path: String,
105-
requestOptions: RequestOptions?,
106-
body: String? = null,
107-
block: (HttpRequestBuilder) -> T
108-
): T {
109-
val hosts = callableHosts(callType)
110-
val errors by lazy(LazyThreadSafetyMode.NONE) { mutableListOf<Throwable>() }
111-
val requestBuilder = httpRequestBuilder(httpMethod, path, requestOptions, body)
112-
113-
for (host in hosts) {
114-
requestBuilder.url.host = host.url
115-
try {
116-
setTimeout(requestBuilder, requestOptions, callType, host)
117-
return block(requestBuilder).apply {
118-
mutex.withLock { host.reset() }
119-
}
120-
} catch (exception: Exception) {
121-
errors += exception
122-
when (exception) {
123-
is HttpRequestTimeoutException, is SocketTimeoutException, is ConnectTimeoutException -> mutex.withLock { host.hasTimedOut() }
124-
is IOException -> mutex.withLock { host.hasFailed() }
125-
is ResponseException -> {
126-
val value = exception.response.status.value
127-
val isRetryable = floor(value / 100f) != 4f
128-
if (isRetryable) mutex.withLock { host.hasFailed() } else throw exception
129-
}
130-
else -> throw exception
135+
/**
136+
* Handle API request exceptions.
137+
*/
138+
private suspend fun RetryableHost.handle(exception: Throwable) {
139+
when (exception) {
140+
is HttpRequestTimeoutException, is SocketTimeoutException, is ConnectTimeoutException -> mutex.withLock { hasTimedOut() }
141+
is IOException -> mutex.withLock { hasFailed() }
142+
is ResponseException -> {
143+
when (exception.response.status.value) {
144+
in (400 until 500) -> throw exception.asApiException()
145+
else -> mutex.withLock { hasFailed() }
131146
}
132147
}
148+
else -> throw exception.asClientException()
133149
}
134-
throw UnreachableHostsException(errors)
135150
}
136151

137152
/**
@@ -160,6 +175,24 @@ internal class Transport(
160175
return httpResponse.call.receiveAs(responseType)
161176
}
162177

178+
/**
179+
* Execute HTTP request (with retry strategy) and get a result as [HttpResponse].
180+
*/
181+
private suspend fun genericRequest(
182+
httpMethod: HttpMethod,
183+
callType: CallType,
184+
path: String,
185+
requestOptions: RequestOptions?,
186+
body: String? = null
187+
): HttpResponse {
188+
return execute(httpMethod, callType, path, requestOptions, body) {
189+
httpClient.request(it)
190+
}
191+
}
192+
193+
/**
194+
* Receive Http payload as [T].
195+
*/
163196
@Suppress("UNCHECKED_CAST")
164197
private suspend fun <T> HttpClientCall.receiveAs(type: TypeInfo): T = receive(type) as T
165198
}

client/src/commonTest/kotlin/configuration/TestUserAgent.kt

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,18 @@ import com.algolia.search.client.ClientSearch
44
import com.algolia.search.configuration.AlgoliaSearchClient
55
import com.algolia.search.configuration.ConfigurationSearch
66
import com.algolia.search.configuration.clientUserAgent
7-
import com.algolia.search.dsl.requestOptions
87
import com.algolia.search.internal.BuildConfig
98
import com.algolia.search.model.APIKey
109
import com.algolia.search.model.ApplicationID
1110
import io.ktor.client.engine.mock.MockEngine
12-
import io.ktor.client.engine.mock.respondBadRequest
1311
import io.ktor.client.engine.mock.respondOk
14-
import io.ktor.client.features.ResponseException
1512
import io.ktor.client.features.UserAgent
1613
import io.ktor.client.request.HttpRequestBuilder
1714
import io.ktor.client.request.request
1815
import io.ktor.client.statement.HttpResponse
1916
import runBlocking
2017
import shouldBeTrue
2118
import shouldEqual
22-
import shouldFailWith
2319
import kotlin.test.Test
2420

2521
internal class TestUserAgent {
@@ -56,26 +52,4 @@ internal class TestUserAgent {
5652
headers[userAgentKey] shouldEqual clientUserAgent(BuildConfig.version)
5753
}
5854
}
59-
60-
@Test
61-
fun overridingUserAgentInRequestOptionsShouldNotBeIgnored() {
62-
runBlocking {
63-
val expected = "My User Agent"
64-
val configuration = ConfigurationSearch(
65-
applicationID = applicationID,
66-
engine = MockEngine {
67-
respondBadRequest()
68-
},
69-
apiKey = apiKey
70-
)
71-
val client = ClientSearch(configuration)
72-
val requestOptions = requestOptions { header(userAgentKey, expected) }
73-
val request = shouldFailWith<ResponseException> {
74-
client.listIndices(requestOptions)
75-
}
76-
val headers = request.response.call.request.headers
77-
78-
headers.get(userAgentKey) shouldEqual expected
79-
}
80-
}
8155
}

0 commit comments

Comments
 (0)