Skip to content

Commit

Permalink
Implement detailed API error handling in PackageSearchApiClient
Browse files Browse the repository at this point in the history
This commit introduces the PackageSearchApiException class to provide extensive details on API errors. Additionally, an HttpCallValidator has been added to verify client responses, alongside the SerializableHttpStatusCode class which allows for user-friendly presentation of HTTP status codes.
  • Loading branch information
lamba92 committed Mar 12, 2024
1 parent 8c072ce commit 261e65b
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.HttpCallValidator
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.compression.ContentEncoding
Expand All @@ -14,11 +15,15 @@ import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.http.fullPath
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import io.ktor.serialization.kotlinx.protobuf.protobuf
Expand All @@ -29,6 +34,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import org.jetbrains.packagesearch.api.v3.ApiPackage
import org.jetbrains.packagesearch.api.v3.ApiProject
Expand All @@ -51,13 +58,28 @@ public class PackageSearchApiClient(
pollingInterval: Duration = 1.seconds,
) : this(endpoints, httpClient)

@Serializable
private data class Error(val error: Inner) {
@Serializable
data class Inner(val message: String, val stackTrace: List<String>)
}

public companion object {

private fun HttpClientConfig<*>.defaultEngineConfig(protobuf: Boolean = true) {
install(ContentNegotiation) {
if (protobuf) protobuf(ProtoBuf { encodeDefaults = false })
json()
}
install(HttpCallValidator) {
validateResponse { response ->
if (!response.status.isSuccess()) {
response.bodyAsText()
.runCatching { Json.decodeFromString<Error>(this) }
.onSuccess { throw it.toException(response) }
}
}
}
install(ContentEncoding) {
gzip()
}
Expand All @@ -73,6 +95,13 @@ public class PackageSearchApiClient(
}
}

private fun Error.toException(response: HttpResponse) = PackageSearchApiException(
serverMessage = error.message,
request = response.request.toSerializable(),
statusCode = response.status.toSerializable(),
remoteStackTrace = error.stackTrace
)

public fun defaultHttpClient(
protobuf: Boolean = true,
additionalConfig: HttpClientConfig<*>.() -> Unit = {},
Expand Down Expand Up @@ -136,7 +165,7 @@ public class PackageSearchApiClient(

override suspend fun getPackageInfoByIds(
ids: Set<String>,
requestBuilder: (HttpRequestBuilder.() -> Unit)?
requestBuilder: (HttpRequestBuilder.() -> Unit)?,
): Map<String, ApiPackage> = defaultRequest<_, List<ApiPackage>>(
method = HttpMethod.Post,
url = endpoints.packageInfoByIds,
Expand All @@ -146,7 +175,7 @@ public class PackageSearchApiClient(

override suspend fun getPackageInfoByIdHashes(
ids: Set<String>,
requestBuilder: (HttpRequestBuilder.() -> Unit)?
requestBuilder: (HttpRequestBuilder.() -> Unit)?,
): Map<String, ApiPackage> = defaultRequest<_, List<ApiPackage>>(
method = HttpMethod.Post,
url = endpoints.packageInfoByIdHashes,
Expand All @@ -156,7 +185,7 @@ public class PackageSearchApiClient(

override suspend fun searchPackages(
request: SearchPackagesRequest,
requestBuilder: (HttpRequestBuilder.() -> Unit)?
requestBuilder: (HttpRequestBuilder.() -> Unit)?,
): List<ApiPackage> = defaultRequest<_, List<ApiPackage>>(
method = HttpMethod.Post,
url = endpoints.searchPackages,
Expand All @@ -166,7 +195,7 @@ public class PackageSearchApiClient(

override suspend fun startScroll(
request: SearchPackagesStartScrollRequest,
requestBuilder: (HttpRequestBuilder.() -> Unit)?
requestBuilder: (HttpRequestBuilder.() -> Unit)?,
): SearchPackagesScrollResponse = defaultRequest<_, SearchPackagesScrollResponse>(
method = HttpMethod.Post,
url = endpoints.startScroll,
Expand All @@ -176,7 +205,7 @@ public class PackageSearchApiClient(

override suspend fun nextScroll(
request: SearchPackagesNextScrollRequest,
requestBuilder: (HttpRequestBuilder.() -> Unit)?
requestBuilder: (HttpRequestBuilder.() -> Unit)?,
): SearchPackagesScrollResponse = defaultRequest<_, SearchPackagesScrollResponse>(
method = HttpMethod.Post,
url = endpoints.nextScroll,
Expand All @@ -186,7 +215,7 @@ public class PackageSearchApiClient(

override suspend fun searchProjects(
request: SearchProjectRequest,
requestBuilder: (HttpRequestBuilder.() -> Unit)?
requestBuilder: (HttpRequestBuilder.() -> Unit)?,
): List<ApiProject> =
defaultRequest<_, List<ApiProject>>(
method = HttpMethod.Post,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.jetbrains.packagesearch.api.v3.http

import kotlinx.serialization.Serializable

@Serializable
public data class PackageSearchApiException(
val serverMessage: String,
val request: SerializableHttpRequest,
val statusCode: SerializableHttpStatusCode,
val remoteStackTrace: List<String> = emptyList(),
) : Throwable() {
override val message: String
get() = buildString {
append("Error response for endpoint ${request.method} ${request.url}:")
appendLine("- Headers:")
request.headers.forEach { appendLine(" -> ${it.key}: ${it.value.joinToString()}") }
appendLine("- Status code: ${statusCode.value} ${statusCode.description}")
if (remoteStackTrace.isNotEmpty()) {
append("- Remote stack trace:")
remoteStackTrace.forEach { appendLine(" $it") }
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package org.jetbrains.packagesearch.api.v3.http

import io.ktor.client.plugins.*
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.HttpRequest
import io.ktor.client.statement.HttpResponse
import io.ktor.http.*
import io.ktor.http.HttpMessageBuilder
import io.ktor.http.HttpStatusCode
import io.ktor.http.URLBuilder
import io.ktor.http.Url
import io.ktor.util.toMap
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.serialization.Serializable
import org.jetbrains.packagesearch.api.v3.ApiPackage
import org.jetbrains.packagesearch.api.v3.search.NextScrollParametersBuilder
import org.jetbrains.packagesearch.api.v3.search.SearchParametersBuilder
Expand All @@ -23,12 +28,14 @@ internal fun HttpMessageBuilder.header(key: String, vararg values: Any?) {

internal var HttpTimeout.HttpTimeoutCapabilityConfiguration.requestTimeout: Duration?
get() = requestTimeoutMillis?.milliseconds
set(value) { requestTimeoutMillis = value?.inWholeMilliseconds }
set(value) {
requestTimeoutMillis = value?.inWholeMilliseconds
}

internal fun HttpRequestRetry.Configuration.constantDelay(
delay: Duration = 1.seconds,
randomization: Duration = 1.seconds,
respectRetryAfterHeader: Boolean = true
respectRetryAfterHeader: Boolean = true,
) {
constantDelay(delay.inWholeMilliseconds, randomization.inWholeMilliseconds, respectRetryAfterHeader)
}
Expand All @@ -40,4 +47,20 @@ public suspend fun PackageSearchApiClient.startScroll(builder: StartScrollParame
startScroll(buildStartScrollParameters(builder))

public suspend fun PackageSearchApiClient.nextScroll(builder: NextScrollParametersBuilder.() -> Unit): SearchPackagesScrollResponse =
nextScroll(buildNextScrollParameters(builder))
nextScroll(buildNextScrollParameters(builder))

@Serializable
public data class SerializableHttpStatusCode(val value: Int, val description: String)

public fun HttpStatusCode.toSerializable(): SerializableHttpStatusCode =
SerializableHttpStatusCode(value, description)

@Serializable
public data class SerializableHttpRequest(
val url: String,
val method: String,
val headers: Map<String, List<String>>,
)

public fun HttpRequest.toSerializable(): SerializableHttpRequest =
SerializableHttpRequest(url.toString(), method.value, headers.toMap())

0 comments on commit 261e65b

Please sign in to comment.