Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include examples and documentation for the Ktor retry plugin #3

Merged
merged 2 commits into from
Apr 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading