Skip to content

Commit

Permalink
implement auto-retrying in JsonHttpClient
Browse files Browse the repository at this point in the history
(cherry picked from commit 2409ea5136acba8d5a5dd1e5353b8814f9c9ae72)
  • Loading branch information
angryziber authored and karnilaev committed Aug 6, 2021
1 parent bf6b604 commit 8070d38
Show file tree
Hide file tree
Showing 2 changed files with 66 additions and 12 deletions.
52 changes: 42 additions & 10 deletions src/util/JsonHttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ package util
import app.httpClient
import app.objectMapper
import com.fasterxml.jackson.databind.ObjectMapper
import io.jooby.annotations.PATCH
import kotlinx.coroutines.delay
import kotlinx.coroutines.future.await
import org.slf4j.LoggerFactory
import java.io.IOException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublisher
import java.net.http.HttpRequest.BodyPublishers.ofString
import java.net.http.HttpResponse
import java.net.http.HttpResponse.BodyHandlers.ofString
import java.time.Duration
import java.time.Duration.ofSeconds
import kotlin.reflect.KClass

Expand All @@ -21,36 +25,64 @@ class JsonHttpClient(
val urlPrefix: String = "",
val reqModifier: RequestModifier = { this },
val errorHandler: (HttpResponse<*>, String) -> Nothing = { res, body -> throw IOException("Failed with ${res.statusCode()}: $body") },
val retryCount: Int = 0,
val retryAfter: Duration = ofSeconds(1),
val json: ObjectMapper = objectMapper,
private val http: HttpClient = httpClient
) {
private val logger = LoggerFactory.getLogger(javaClass)
val logger = LoggerFactory.getLogger(Exception().stackTrace.first { it.className !== javaClass.name }.className).apply {
info("Initialized ${JsonHttpClient::class.simpleName} with $urlPrefix")
}

private fun jsonReq(urlSuffix: String) = HttpRequest.newBuilder().uri(URI.create("$urlPrefix$urlSuffix"))
.setHeader("Content-Type", "application/json; charset=UTF-8").setHeader("Accept", "application/json")
.timeout(ofSeconds(10)).reqModifier()

suspend fun <T: Any> request(urlSuffix: String, builder: HttpRequest.Builder.() -> HttpRequest.Builder, type: KClass<T>): T {
private suspend fun <T: Any> request(urlSuffix: String, type: KClass<T>, builder: HttpRequest.Builder.() -> HttpRequest.Builder): T {
val req = jsonReq(urlSuffix).builder().build()
val start = System.nanoTime()
val res = http.sendAsync(req, ofString()).await()
val ms = (System.nanoTime() - start) / 1000_000
val body = res.body()
val body = res.body().trim()
if (res.statusCode() < 300) {
logger.info("${req.method()} $urlSuffix in $ms ms: $body")
return json.parse(body, type)
return if (type == String::class) body as T else json.parse(body, type)
}
else {
logger.error("Failed ${req.method()} $urlSuffix in $ms ms: ${res.statusCode()}: $body")
errorHandler(res, body)
}
}

suspend inline fun <reified T: Any> request(urlSuffix: String, noinline builder: HttpRequest.Builder.() -> HttpRequest.Builder) =
request(urlSuffix, builder, T::class)
private suspend fun <T: Any> retryRequest(urlSuffix: String, type: KClass<T>, builder: HttpRequest.Builder.() -> HttpRequest.Builder): T {
for (i in 0..retryCount) {
try {
return request(urlSuffix, type, builder)
} catch (e: IOException) {
if (i < retryCount) {
logger.error("$e, retry ${i + 1} after $retryAfter")
delay(retryAfter.toMillis())
}
else {
logger.error("$urlSuffix: $e")
throw e
}
}
}
error("Unreachable")
}

suspend fun <T: Any> get(urlSuffix: String, type: KClass<T>) = retryRequest(urlSuffix, type) { GET() }
suspend inline fun <reified T: Any> get(urlSuffix: String) = get(urlSuffix, T::class)

suspend fun <T: Any> post(urlSuffix: String, o: Any?, type: KClass<T>) = retryRequest(urlSuffix, type) { POST(ofJson(o)) }
suspend inline fun <reified T: Any> post(urlSuffix: String, o: Any?) = post(urlSuffix, o, T::class)

suspend fun <T: Any> put(urlSuffix: String, o: Any?, type: KClass<T>) = retryRequest(urlSuffix, type) { PUT(ofJson(o)) }
suspend inline fun <reified T: Any> put(urlSuffix: String, o: Any?) = put(urlSuffix, o, T::class)

suspend fun <T: Any> delete(urlSuffix: String, type: KClass<T>) = retryRequest(urlSuffix, type) { DELETE() }
suspend inline fun <reified T: Any> delete(urlSuffix: String) = delete(urlSuffix, T::class)

suspend inline fun <reified T: Any> get(urlSuffix: String) = request<T>(urlSuffix) { GET() }
suspend inline fun <reified T: Any> post(urlSuffix: String, o: Any?) = request<T>(urlSuffix) { POST(ofString(json.stringify(o))) }
suspend inline fun <reified T: Any> put(urlSuffix: String, o: Any?) = request<T>(urlSuffix) { PUT(ofString(json.stringify(o))) }
suspend inline fun <reified T: Any> delete(urlSuffix: String) = request<T>(urlSuffix) { DELETE() }
private fun ofJson(o: Any?): BodyPublisher = ofString(if (o is String) o else json.stringify(o))
}
26 changes: 24 additions & 2 deletions test/util/JsonHttpClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import org.junit.jupiter.api.assertThrows
import java.io.IOException
import java.net.http.HttpClient
import java.net.http.HttpResponse
import java.time.Duration.ofSeconds
import java.util.concurrent.CompletableFuture.completedFuture
import java.util.concurrent.CompletableFuture.failedFuture

class JsonHttpClientTest {
private val httpClient = mockk<HttpClient>()
private val http = JsonHttpClient("http://some.host/v1", { setHeader("X-Custom-API", "123") }, http = httpClient)
val httpClient = mockk<HttpClient>()
val http = JsonHttpClient("http://some.host/v1", reqModifier = { setHeader("X-Custom-API", "123") },
retryCount = 2, retryAfter = ofSeconds(0), http = httpClient)

@Test
fun get() {
Expand All @@ -37,6 +40,25 @@ class JsonHttpClientTest {
assertThrows<IOException> { runBlocking { http.get<SomeData>("/error") } }
}

@Test
fun exception() {
val exception = IOException()
every { httpClient.sendAsync<String>(any(), any()) }.returnsMany(failedFuture(exception))
assertThrows<IOException> { runBlocking { http.post<String>("/some/data", "Hello") } }
coVerify(exactly = 3) { httpClient.sendAsync<String>(any(), any()) }
}

@Test
fun retry() {
val response = mockResponse(200, """{"hello": "World"}""")
every { httpClient.sendAsync<String>(any(), any()) }.returnsMany(failedFuture(IOException()), completedFuture(response))
runBlocking {
val body = http.post<String>("/some/data", "Hello")
assertThat(body).isEqualTo(response.body())
}
coVerify(exactly = 2) { httpClient.sendAsync<String>(any(), any()) }
}

data class SomeData(val hello: String)

private fun mockResponse(status: Int, body: String) = mockk<HttpResponse<String>> {
Expand Down

0 comments on commit 8070d38

Please sign in to comment.