Skip to content

Commit

Permalink
Merge pull request #3 from kresil/ktor-retry-plugin
Browse files Browse the repository at this point in the history
Issue (kresil/kresil#7): Includes examples and documentation for the Ktor retry plugin
  • Loading branch information
franciscoengenheiro authored Apr 7, 2024
2 parents 961a601 + 15f31d0 commit 0c99829
Show file tree
Hide file tree
Showing 25 changed files with 747 additions and 7 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
- 🛠️ [Kotlin Multiplatform](kmp/README.md)
- 🌐 [Kotlin-Js Interop](kotlin-js-interop/README.md)
- ⚙️ [Ktor Framework](ktor/README.md)
- 🔄 [Ktor Retry Plugin](ktor-retry-plugin/README.md)
- 🛡️ [Resilience4j](resilience4j/README.md)
21 changes: 17 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ compose-compiler = "1.5.4"
compose-material3 = "1.2.1"
androidx-activityCompose = "1.8.2"
kotlinx-serialization = "1.6.3"
junit = "5.9.3"
junit = "4.13.2"
junit-jupiter = "5.9.3"
resilience4j = "2.2.0"
logback = "1.5.3"
hamcrest = "2.2"

[libraries]
# kotlin
Expand Down Expand Up @@ -45,23 +48,33 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-bom = { module = "io.ktor:ktor-bom", version.ref = "ktor" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-server-calllogging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
ktor-server-defaultheaders = { module = "io.ktor:ktor-server-default-headers", version.ref = "ktor" }
ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" }
ktor-server-statuspages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktor-server-testhost = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktor-server-hostcommon = { module = "io.ktor:ktor-server-host-common", version.ref = "ktor" }

# Resilience4j
resilience4j-retry = { module = "io.github.resilience4j:resilience4j-retry", version.ref = "resilience4j" }
resilience4j-kotlin = { module = "io.github.resilience4j:resilience4j-kotlin", version.ref = "resilience4j" }

# junit
junit = { module = "junit:junit", version.ref = "junit" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-jupiter" }

# others
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
nexus-publish = { module = "io.github.gradle-nexus.publish-plugin:io.github.gradle-nexus.publish-plugin.gradle.plugin", version.ref = "nexus-publish" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "hamcrest" }

[plugins]
androidLibrary = { id = "com.android.library", version.ref = "agp" }
Expand All @@ -71,4 +84,4 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

# original toml taken from: https://github.com/ktorio/ktor-documentation/blob/2.3.9/codeSnippets/snippets/tutorial-client-kmm/gradle/libs.versions.toml
# original toml taken from: https://github.com/ktorio/ktor-documentation/blob/2.3.9/codeSnippets/snippets/tutorial-client-kmm/gradle/libs.versions.toml
78 changes: 78 additions & 0 deletions ktor-retry-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Ktor Retry Plugin

### Install

The `HttpRequestRetry` plugin can be installed using the `install` function
and configured using its **last parameter function**
(_trailing lambda_), as with other Ktor plugins.

The plugin is part of the `ktor-client-core` module.

### Configuration

| Property | Description |
|----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `maxRetries` | The maximum number of retries | |
| `retryIf` | A lambda that returns `true` if the request should be retried on specific request details and or responses. |
| `retryOnExceptionIf` | A lambda that returns `true` if the request should be retried on specific request details and or exceptions that occurred. |
| `delayMillis` | A lambda that returns the delay in milliseconds before the next retry. Some methods are provided to create more complex delays, such as `exponentialDelay()` or `constantDelay()`. |

> [!NOTE]
> The plugin also provides more specific methods to retry for
> (e.g., on server errors, for example, `retryOnServerErrors()` retries on server 5xx errors).
Example:

```kotlin
val client = HttpClient(CIO) {
install(HttpRequestRetry) {
maxRetries = 5
retryIf { request, response ->
!response.status.isSuccess()
}
retryOnExceptionIf { request, cause ->
cause is NetworkError
}
delayMillis { retry ->
retry * 3000L
} // retries in 3, 6, 9, etc. seconds
}
// other configurations
}
```

Default configuration:

```kotlin
install(HttpRequestRetry) {
retryOnExceptionOrServerErrors(3)
exponentialDelay()
}
```

### Changing a Request Before Retry

It is possible to modify the request
before retrying it by using the `modifyRequest` method inside the configuration block of the plugin.
This method receives a lambda that takes the request and returns the modified request.
One usage example is to add a header with the current retry count:

```kotlin
val client = HttpClient(CIO) {
install(HttpRequestRetry) {
modifyRequest { request ->
request.headers.append("x-retry-count", retryCount.toString())
}
}
// other configurations
}
```

> [!IMPORTANT]
> To preserve configuration context between retry attempts, the plugin uses request attibutes to store data.
> If those are altered, the plugin may not work as expected.
>
> If an attribute is not present in the request, the plugin will use the default configuration associated with that attribute.
> Such behaviour can be seen in the source code:
> - [after applying configuration](https://github.com/ktorio/ktor/blob/7c76fa7c0f2b7dcc6e0445da8612d75bb5d11609/ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/HttpRequestRetry.kt#L366-L373)
> - [before each retry attempt](https://github.com/ktorio/ktor/blob/7c76fa7c0f2b7dcc6e0445da8612d75bb5d11609/ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/HttpRequestRetry.kt#L267-L274)
18 changes: 18 additions & 0 deletions ktor-retry-plugin/client-retry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Client Retry

A sample Ktor project showing how to use the [HttpRequestRetry](https://ktor.io/docs/client-retry.html) plugin.

## Running

This client sample uses the server from the [simulate-slow-server](../simulate-slow-server) example.
The server sample has the `/error` route that returns the `200 OK` response from the third attempt only.

To see `HttpRequestRetry` in action, run this example by executing the following command:

```bash
./gradlew :client-retry:run
```

The client will send three consequent requests automatically to get a success response from the server.

> Note that this example uses the [Logging](https://ktor.io/docs/client-logging.html) plugin to show all requests in a console.
27 changes: 27 additions & 0 deletions ktor-retry-plugin/client-retry/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
plugins {
application
alias(libs.plugins.kotlinJvm)
}

application {
mainClass.set("application.ApplicationKt")
}

repositories {
mavenCentral()
maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") }
}

dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.hostcommon)
implementation(libs.logback.classic)
implementation(project(":simulate-slow-server"))
implementation(project(":end-to-end-utilities"))
testImplementation(libs.junit)
testImplementation(libs.hamcrest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package application

import e2e.*
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.server.application.*
import kotlinx.coroutines.*
import slowserver.main

fun main() {
defaultServer(Application::main).start()
runBlocking {
val client = HttpClient(CIO) {
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 5)
exponentialDelay()
}
install(Logging) { level = LogLevel.INFO }
}

val response: HttpResponse = client.get("http://0.0.0.0:8080/error")
println(response.bodyAsText())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package application

import e2e.readString
import e2e.runGradleAppWaiting
import kotlinx.coroutines.runBlocking
import org.junit.*
import org.junit.Assert.*
import java.io.File

class ApplicationTest {

companion object {
private const val GRADLEW_WINDOWS = "gradlew.bat"
private const val GRADLEW_UNIX = "gradlew"

@JvmStatic
fun findGradleWrapper(): String {
val currentDir = File(System.getProperty("user.dir"))
val parentDir = currentDir.parent ?: error("Cannot find parent directory of $currentDir")
val gradlewName = if (System.getProperty("os.name").startsWith("Windows")) {
GRADLEW_WINDOWS
} else {
GRADLEW_UNIX
}
val gradlewFile = File(parentDir, gradlewName)
check(gradlewFile.exists()) { "Gradle Wrapper not found at ${gradlewFile.absolutePath}" }
return gradlewFile.absolutePath
}
}

@Before
fun setup() {
System.setProperty("gradlew", findGradleWrapper())
}

@Test
fun outputContainsAllResponses() = runBlocking {
runGradleAppWaiting().inputStream.readString().let { outputString ->
assertTrue(outputString.contains("RESPONSE: 500 Internal Server Error"))
assertTrue(outputString.contains("RESPONSE: 200 OK"))
assertTrue(outputString.contains("Server is back online!"))
}
}
}
3 changes: 3 additions & 0 deletions ktor-retry-plugin/end-to-end-utilities/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# End-to-end utilities

This project isn't runnable and contains helper classes and functions for testing samples from this repository.
13 changes: 13 additions & 0 deletions ktor-retry-plugin/end-to-end-utilities/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
plugins {
alias(libs.plugins.kotlinJvm)
}

repositories {
mavenCentral()
maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") }
}

dependencies {
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package e2e

import io.ktor.server.application.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import org.slf4j.helpers.NOPLogger

fun defaultServer(module: Application.() -> Unit) = embeddedServer(CIO, environment = applicationEngineEnvironment {
log = NOPLogger.NOP_LOGGER

connector {
port = 8080
}

module(module)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package e2e

import java.io.InputStream

fun runGradleAppWaiting(): Process = runGradleWaiting("run")
fun runGradleApp(): Process = runGradle("run")

fun runGradleWaiting(vararg args: String): Process {
val process = runGradle(*args)
process.waitFor()
return process
}

fun runGradle(vararg args: String): Process {
val gradlewPath =
System.getProperty("gradlew") ?: error("System property 'gradlew' should point to Gradle Wrapper file")
val processArgs = listOf(gradlewPath, "-Dorg.gradle.logging.level=quiet", "--quiet") + args
return ProcessBuilder(processArgs).start()
}

fun InputStream.readString(): String = readAllBytes().toString(Charsets.UTF_8)
Binary file not shown.
7 changes: 7 additions & 0 deletions ktor-retry-plugin/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading

0 comments on commit 0c99829

Please sign in to comment.