Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,17 @@ import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.Parameters
import io.ktor.http.contentType
import io.ktor.http.encodeURLPath
import io.ktor.http.formUrlEncode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withTimeout
import kotlinx.datetime.Clock
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import io.ktor.websocket.Frame
import io.ktor.websocket.readText

class KtorApiClient(
private val httpClient: HttpClient = defaultHttpClient(),
Expand All @@ -58,6 +56,7 @@ class KtorApiClient(

var attempt = 0
var lastThrowable: Throwable? = null
var isRetryExhausted = false

while (attempt < retryPolicy.maxAttempts) {
attempt++
Expand Down Expand Up @@ -93,19 +92,34 @@ class KtorApiClient(
}

val delayMs = retryPolicy.delayForAttempt(attempt)
emit(NetworkEvent.RetryScheduled(attempt, delayMs, "status=${mappedResponse.statusCode}"))
emit(
NetworkEvent.RetryScheduled(
attempt,
delayMs,
"status=${mappedResponse.statusCode}"
)
)
delay(delayMs)
} catch (throwable: Throwable) {
lastThrowable = throwable
logger.error("Request failed at attempt $attempt", throwable)
interceptors.forEach { interceptor -> interceptor.onFailure(throwable, attempt) }

if (attempt == retryPolicy.maxAttempts) {
isRetryExhausted = true
break
} else if (retryPolicy.avoidRetryOnThrowable.any { it.isInstance(throwable) }) {
break
}

val delayMs = retryPolicy.delayForAttempt(attempt)
emit(NetworkEvent.RetryScheduled(attempt, delayMs, throwable.message ?: "unknown error"))
emit(
NetworkEvent.RetryScheduled(
attempt,
delayMs,
throwable.message ?: "unknown error"
)
)
delay(delayMs)
}
}
Expand All @@ -116,7 +130,7 @@ class KtorApiClient(
requestId = request.id,
message = lastThrowable?.message ?: "Request failed",
cause = lastThrowable,
isRetryExhausted = true
isRetryExhausted = isRetryExhausted
)
)
)
Expand All @@ -143,11 +157,13 @@ class KtorApiClient(
builder.header(header.key, VariableResolver.resolve(header.value, variableLayers))
}

if (request.cookies.isNotEmpty()) {
builder.header(HttpHeaders.Cookie, request.cookies.filter { it.enabled }
.joinToString(separator = "; ") { cookie ->
"${cookie.key}=${VariableResolver.resolve(cookie.value, variableLayers)}"
})
val enabledCookies = request.cookies.filter { it.enabled }
if (enabledCookies.isNotEmpty()) {
builder.header(
HttpHeaders.Cookie, enabledCookies
.joinToString(separator = "; ") { cookie ->
"${cookie.key}=${VariableResolver.resolve(cookie.value, variableLayers)}"
})
}

applyAuth(builder, request, variableLayers)
Expand All @@ -165,8 +181,10 @@ class KtorApiClient(
when (auth.type) {
AuthType.NONE -> Unit
AuthType.BASIC -> {
val username = VariableResolver.resolve(auth.params["username"].orEmpty(), variableLayers)
val password = VariableResolver.resolve(auth.params["password"].orEmpty(), variableLayers)
val username =
VariableResolver.resolve(auth.params["username"].orEmpty(), variableLayers)
val password =
VariableResolver.resolve(auth.params["password"].orEmpty(), variableLayers)
val value = "$username:$password".encodeToByteArray().encodeBase64()
builder.header(HttpHeaders.Authorization, "Basic $value")
}
Expand All @@ -191,7 +209,8 @@ class KtorApiClient(
}

AuthType.OAUTH2 -> {
val accessToken = VariableResolver.resolve(auth.params["accessToken"].orEmpty(), variableLayers)
val accessToken =
VariableResolver.resolve(auth.params["accessToken"].orEmpty(), variableLayers)
if (accessToken.isNotBlank()) {
builder.header(HttpHeaders.Authorization, "Bearer $accessToken")
}
Expand Down Expand Up @@ -259,11 +278,40 @@ class KtorApiClient(
val body = request.body
when (body.type) {
BodyType.NONE -> Unit
BodyType.JSON -> applyRawBody(builder, ContentType.Application.Json, body.content, variableLayers)
BodyType.RAW_TEXT -> applyRawBody(builder, ContentType.Text.Plain, body.content, variableLayers)
BodyType.XML -> applyRawBody(builder, ContentType.Application.Xml, body.content, variableLayers)
BodyType.HTML -> applyRawBody(builder, ContentType.Text.Html, body.content, variableLayers)
BodyType.JAVASCRIPT -> applyRawBody(builder, ContentType.parse("application/javascript"), body.content, variableLayers)
BodyType.JSON -> applyRawBody(
builder,
ContentType.Application.Json,
body.content,
variableLayers
)

BodyType.RAW_TEXT -> applyRawBody(
builder,
ContentType.Text.Plain,
body.content,
variableLayers
)

BodyType.XML -> applyRawBody(
builder,
ContentType.Application.Xml,
body.content,
variableLayers
)

BodyType.HTML -> applyRawBody(
builder,
ContentType.Text.Html,
body.content,
variableLayers
)

BodyType.JAVASCRIPT -> applyRawBody(
builder,
ContentType.parse("application/javascript"),
body.content,
variableLayers
)

BodyType.GRAPHQL -> {
builder.contentType(ContentType.Application.Json)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
package com.reqlab.core.network

import kotlin.coroutines.cancellation.CancellationException
import kotlin.reflect.KClass

data class RetryPolicy(
val maxAttempts: Int = 1,
val baseDelayMs: Long = 250,
val maxDelayMs: Long = 2_500,
val retryOnStatusCodes: Set<Int> = setOf(408, 429, 500, 502, 503, 504)
val retryOnStatusCodes: Set<Int> = setOf(408, 429, 500, 502, 503, 504),
val avoidRetryOnThrowable: Set<KClass<out Throwable>> = setOf(
CancellationException::class,
Error::class,
NullPointerException::class,
IllegalArgumentException::class,
IllegalStateException::class,
IndexOutOfBoundsException::class,
)
) {
init {
require(maxAttempts >= 1) { "maxAttempts must be at least 1" }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.reqlab.core.network

import com.reqlab.core.model.BodyType
import com.reqlab.core.model.HttpMethodType
import com.reqlab.core.model.KeyValueEntry
import com.reqlab.core.model.RequestBody
import com.reqlab.core.model.RequestDefinition
import com.reqlab.core.model.BodyType
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
Expand All @@ -16,6 +16,7 @@ import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlinx.io.IOException
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
Expand All @@ -29,7 +30,10 @@ class KtorApiClientTest {
respond(
content = "{\"ok\":true}",
status = HttpStatusCode.OK,
headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
headers = io.ktor.http.headersOf(
HttpHeaders.ContentType,
ContentType.Application.Json.toString()
)
)
}

Expand Down Expand Up @@ -78,7 +82,10 @@ class KtorApiClientTest {
respond(
content = "ok",
status = HttpStatusCode.OK,
headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString())
headers = io.ktor.http.headersOf(
HttpHeaders.ContentType,
ContentType.Text.Plain.toString()
)
)
}

Expand All @@ -89,7 +96,8 @@ class KtorApiClientTest {
expectSuccess = false
}

val apiClient = KtorApiClient(httpClient = client, retryPolicy = RetryPolicy(maxAttempts = 1))
val apiClient =
KtorApiClient(httpClient = client, retryPolicy = RetryPolicy(maxAttempts = 1))

val request = RequestDefinition(
id = "req-dyn",
Expand Down Expand Up @@ -117,7 +125,10 @@ class KtorApiClientTest {
respond(
content = "{\"result\":42}",
status = HttpStatusCode.OK,
headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
headers = io.ktor.http.headersOf(
HttpHeaders.ContentType,
ContentType.Application.Json.toString()
)
)
}

Expand All @@ -126,7 +137,8 @@ class KtorApiClientTest {
expectSuccess = false
}

val apiClient = KtorApiClient(httpClient = client, retryPolicy = RetryPolicy(maxAttempts = 1))
val apiClient =
KtorApiClient(httpClient = client, retryPolicy = RetryPolicy(maxAttempts = 1))

val request = RequestDefinition(
id = "req-timing",
Expand Down Expand Up @@ -156,7 +168,10 @@ class KtorApiClientTest {
respond(
content = "ok",
status = HttpStatusCode.OK,
headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString())
headers = io.ktor.http.headersOf(
HttpHeaders.ContentType,
ContentType.Text.Plain.toString()
)
)
}

Expand Down Expand Up @@ -195,15 +210,17 @@ class KtorApiClientTest {
"Expected multipart payload content, got '$capturedBody'",
)
assertTrue(
capturedBody.contains("name") && capturedBody.contains("alice") && capturedBody.contains("role") && capturedBody.contains("tester"),
capturedBody.contains("name") && capturedBody.contains("alice") && capturedBody.contains(
"role"
) && capturedBody.contains("tester"),
"Multipart payload should contain form fields, got '$capturedBody'",
)
}

@Test
fun emits_retry_scheduled_and_failure_when_retries_exhausted() = runTest {
val mockEngine = MockEngine {
throw IllegalStateException("timeout-like failure")
throw IOException("timeout-like failure")
}

val client = HttpClient(mockEngine) {
Expand All @@ -228,12 +245,17 @@ class KtorApiClientTest {
)

val events = apiClient.execute(request).toList()

println(events.joinToString("\n") { it.toString() })
assertTrue(events.first() is NetworkEvent.Started)
assertEquals(1, events.count { it is NetworkEvent.RetryScheduled })
assertTrue(events.last() is NetworkEvent.Failure)
val failure = events.last() as NetworkEvent.Failure
assertTrue(failure.error.isRetryExhausted)
assertTrue(failure.error.message.contains("failed", ignoreCase = true) || failure.error.message.contains("timeout", ignoreCase = true))
assertTrue(
failure.error.message.contains(
"failed",
ignoreCase = true
) || failure.error.message.contains("timeout", ignoreCase = true)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class DocumentModel(initialText: String = "") {
return if (nextStart == Int.MAX_VALUE) {
buffer.length
} else {
// nextStart points to the char after '\n'; subtract 1 to exclude '\n'
// nextStart points to the char after '\n'; subtract 1 to point to '\n' (end of line)
(nextStart - 1).coerceAtLeast(lineIndex.lineStart(line))
}
}
Expand Down Expand Up @@ -136,5 +136,6 @@ class DocumentModel(initialText: String = "") {
return sb.toString()
}

override fun toString(): String = "DocumentModel(${buffer.length} chars, ${lineIndex.lineCount} lines, v$version)"
override fun toString(): String =
"DocumentModel(${buffer.length} chars, ${lineIndex.lineCount} lines, v$version)"
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.reqlab.ui.shared.persistence
package com.reqlab.ui.desktop.persistence

import com.reqlab.ui.shared.persistence.ImportExportRepository
import com.reqlab.ui.shared.state.AppState
import java.io.File
import kotlin.test.Test
Expand All @@ -15,8 +16,10 @@ class ImportExportFixturesIntegrationTest {
fun imports_collection_and_environment_from_deterministic_fixtures() {
val state = AppState(openDefaultTab = false, withDemoData = false)

val importedCollection = ImportExportRepository.importCollectionFromString(state, collectionFixture.readText())
val importedEnvironment = ImportExportRepository.importEnvironmentFromString(state, environmentFixture.readText())
val importedCollection =
ImportExportRepository.importCollectionFromString(state, collectionFixture.readText())
val importedEnvironment =
ImportExportRepository.importEnvironmentFromString(state, environmentFixture.readText())

assertEquals("ReqLab Test Suite", importedCollection)
assertEquals("Local Dev – Sample Server", importedEnvironment)
Expand Down Expand Up @@ -44,7 +47,7 @@ class ImportExportFixturesIntegrationTest {

val root = restored.collections.firstOrNull { it.name == "ReqLab Test Suite" }
assertTrue(root != null)
assertTrue(root.children.any { it.isFolder && it.name == "New Script APIs Coverage" })
assertTrue(root.children.any { it.isFolder && it.name == "Scripting Runtime Coverage" })

val env = restored.environments.firstOrNull { it.name == "Local Dev – Sample Server" }
assertTrue(env != null)
Expand Down
Loading
Loading