iT邦幫忙

2026 iThome 鐵人賽

0
Software Development

Kotlin Ktor 實戰 101系列 第 31 篇

Kotlin Ktor 實戰 101 Day 31 Ktor HttpClient 與 MockEngine

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

前面文章的 todo-api 一直在做同一件事,收請求、查資料庫、回應,這一篇主動往外面打 POST /todos/import/{externalId},去一個公開的測試 API 抓一筆待辦回來,轉成自己的 Todo 存進資料庫

外部服務會慢、會掛、會回你沒看過的欄位,所以這一篇的東西幾乎都是為了這些狀況存在的,timeout、重試、多出來的欄位、失敗要對應成什麼狀態碼,測試那一側則是 MockEngine,同一份 client 設定,engine 換成假的,這些行為全部在不碰網路的情況下驗證

這篇要完成什麼

  • POST /todos/import/{externalId} 從 JSONPlaceholder 抓一筆,轉成本地的 Todo 存起來
  • todoImportClient 收的是 HttpClientEngine 而不是寫死的 CIO,正式環境給 CIO、測試給 MockEngine
  • 外部回的欄位比自己宣告的多,靠 ignoreUnknownKeys 擋下來
  • 外部服務關掉的時候,使用者要等 3.3 秒才拿到 502,而且訊息上的例外名字是 timeout,真正的死因是連線被拒
  • 那 3 秒是重試的等待,IMPORT_RETRIES=0 之後同一個失敗 48 毫秒就回來了
  • retryOnServerErrors 沒有只管 server error,IOException 照樣被打 3 次,timeout 反而是唯一不重試的
  • HttpClient 進了 DI 容器就不用自己關,day 18 那條生命週期規則接得上

相依

build.gradle.kts 的 dependencies { } 加 2 行,改 1 行

     implementation(ktorLibs.server.websockets)
+    implementation(ktorLibs.client.core)
+    implementation(ktorLibs.client.cio)

     testImplementation(kotlin("test"))
     testImplementation(ktorLibs.server.testHost)
-    testImplementation(ktorLibs.client.contentNegotiation)
+    implementation(ktorLibs.client.contentNegotiation)

被改掉的那一行本來是 testImplementation,因為在這之前只有測試裡的 client 需要處理 JSON,這一輪 main 底下真的要用它了,所以換成 implementation

測試那邊多一行 mock engine

     testImplementation(ktorLibs.client.websockets)
+    testImplementation(ktorLibs.client.mock)

設定

src/main/resources/application.yaml 的 todo 節點尾巴加 4 行

   openapi:
     title: "$OPENAPI_TITLE:Todo API"
     version: "$OPENAPI_VERSION:1.0.0"
+  importer:
+    baseUrl: "$IMPORT_BASE_URL:https://jsonplaceholder.typicode.com"
+    timeoutMillis: "$IMPORT_TIMEOUT:2000"
+    maxRetries: "$IMPORT_RETRIES:2"

環境變數覆寫的語法是 day 17 那一套,冒號後面是預設值,那個網址不是憑證,寫進版控沒問題,day 27 那條「憑證不給預設值」的規矩管的是 JWT_SECRET 那一種

這個節點叫 importer 而不是 httpclient,理由在 baseUrl 這個 key 上,HttpClient 本身沒有「一個網址」,它是拿來打任意位址的,會有固定網址的是「匯入」這個功能,旁邊的 database、auth、openapi 也都是用途名,沒有一塊是用函式庫命名的

src/main/kotlin/com/cashwu/todo/TodoConfig.kt 的 TodoConfig 多一個 importer 欄位,同一個檔案尾巴多一個 data class

@Serializable
data class ImporterConfig(
    val baseUrl: String,
    val timeoutMillis: Long,
    val maxRetries: Int,
)

client 本身

新檔案 src/main/kotlin/com/cashwu/todo/TodoImporter.kt,第 1 段是 client 的組法

fun todoImportClient(engine: HttpClientEngine, config: ImporterConfig): HttpClient =
    HttpClient(engine) {
        expectSuccess = false
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
        install(HttpTimeout) {
            requestTimeoutMillis = config.timeoutMillis
            connectTimeoutMillis = config.timeoutMillis
        }
        install(HttpRequestRetry) {
            retryOnServerErrors(maxRetries = config.maxRetries)
            exponentialDelay(base = 1.2, maxDelayMs = config.timeoutMillis)
        }
    }

第 1 個參數的型別是 HttpClientEngine,不是寫死的 CIO,這是整篇的接縫,正式環境傳 CIO.create(),測試傳 MockEngine { },而中間那層設定兩邊完全一樣,同一個 install(HttpTimeout)、同一個 install(HttpRequestRetry),測試量的是真的要上線的那份設定

CIO 是 Ktor 自己實作的 engine,名字來自 Coroutine-based I/O,client 這一側能選的 engine 不只它一個,那份版本目錄裡還有 client-apache5、client-okhttp、client-java 這些,差別在最底下真的把 bytes 送出去的是誰,上面那層 plugin 設定不會跟著換,CIO 的特色是不包裝任何既有的 HTTP 函式庫,ktor-client-cio 宣告的相依只有 Ktor 自己的模組加上 kotlinx-coroutines-core 跟 slf4j-api,沒有任何第三方的 HTTP 函式庫,這個專案本來就整條在 coroutine 上,選它就不必為了打一個外部 API 多帶一個第三方 client 進來

install 這個字在這裡跟 server 那邊指的是一樣的事,day 12 裝 ContentNegotiation 學到的 plugin 觀念,在 client 這一側原封不動能用,差別只在裝進去的是哪一個 pipeline

client 這一側的 ContentNegotiation 管的是 2 個方向的轉換,送出去的時候把物件轉成 request body,收回來的時候把 response body 轉成物件,這一輪用到的是後者,也就是下一節那個 response.body()

expectSuccess = false 是把預設值寫出來,HttpClientConfig 的宣告是 public var expectSuccess: Boolean = false,旁邊 followRedirects 跟 useDefaultTransformers 都預設 true,只有這個是 false,所以這一行沒有改變任何行為,寫出來是因為下面要自己讀 response.status 決定回什麼,那個前提留在程式碼上比留在腦子裡好

從外部的 JSON 到本地的 Todo

這個檔案的第 2 段,外部回應的形狀

@Serializable
data class ExternalTodo(
    val id: Int,
    val title: String,
    val completed: Boolean,
)

外部服務實際回的東西比這個多一個 userId

curl -s https://jsonplaceholder.typicode.com/todos/1
{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

多出來的欄位就是 ignoreUnknownKeys = true 在擋的,這不是可有可無的設定,外部服務哪天多回一個欄位不會先問過你,少了這一行,那次改版就變成你的 502

再來是轉換與失敗的對應

class TodoImporter(
    private val client: HttpClient,
    private val config: ImporterConfig,
    private val repository: TodoRepository,
) {
    suspend fun import(externalId: Int): Todo {
        val external = fetch(externalId)
        return repository.create(external.title, external.completed)
    }

    private suspend fun fetch(externalId: Int): ExternalTodo {
        val response = ask(externalId)
        if (response.status == HttpStatusCode.NotFound) {
            throw ApiException(HttpStatusCode.NotFound, "外部服務沒有 id $externalId 的待辦")
        }
        if (!response.status.isSuccess()) {
            throw ApiException(HttpStatusCode.BadGateway, "外部服務回 ${response.status.value}")
        }
        return try {
            response.body()
        } catch (cause: CancellationException) {
            throw cause
        } catch (cause: Throwable) {
            throw ApiException(HttpStatusCode.BadGateway, "外部服務回的不是預期的 JSON")
        }
    }

    private suspend fun ask(externalId: Int): HttpResponse =
        try {
            client.get("${config.baseUrl}/todos/$externalId")
        } catch (cause: CancellationException) {
            throw cause
        } catch (cause: Throwable) {
            throw ApiException(
                HttpStatusCode.BadGateway,
                "外部服務連不上 (${cause::class.simpleName})",
            )
        }
}

外部的 404 變成自己的 404,其他失敗一律 502,這個對應是有意義的,404 是「你要的東西不存在」,使用者換一個 id 再打一次就好,502 是「我這邊往外走的路壞了」,使用者重試也沒有用,2 種都丟 ApiException,剩下的交給 day 15 那個 StatusPages

catch (cause: CancellationException) { throw cause } 出現 2 次,是結構化併發的規矩,CancellationException 是 coroutine 用來傳「你被取消了」的通道,不是失敗,吞掉它會把一次正常的取消寫成一個假的 502

反序列化那一段單獨包起來,是因為它跟連線失敗是 2 件不同的事,200 加上一坨不是 JSON 的東西也是壞掉,但錯的地方不在網路

return response.body() 這一行沒有寫型別,回來的東西卻會變成 ExternalTodo,靠的不是「函式宣告什麼就轉成什麼」這條規則,是 Kotlin 的型別推論從呼叫點的期望型別推出來的,return 的期望型別剛好是 fetch 宣告的 ExternalTodo,所以推成那個,下面 3 種寫法等價

return response.body()                  // 從返回型別推
val e: ExternalTodo = response.body()   // 從變數型別推
val e = response.body<ExternalTodo>()   // 明講

body() 的宣告在 ktor-client-core 3.5.2 的 HttpClientCall.kt,逐字是這樣

public suspend inline fun <reified T> HttpResponse.body(): T = call.bodyNullable(typeInfo<T>()) as T

reified T 讓 Ktor 在執行期拿得到真正的型別,這是它能決定要用哪個 serializer 的前提,不過推得出型別不等於真的會反序列化,中間還有 2 個前提,缺的是哪一個,丟出來的例外不一樣,指的毛病也不一樣

第 1 個是 client 要裝 install(ContentNegotiation) { json(...) },而且回應的 Content-Type 要對得上註冊的 converter,這一條不成立的時候丟的是 NoTransformationFoundException,沒裝 ContentNegotiation、body 是合法 JSON 但 header 是 text/html、完全沒有 Content-Type header,這 3 種情況都是同一個例外

第 2 個是 JSON 的內容要對得上那個 data class,Content-Type 是 application/json 但 body 是 <html>maintenance</html>,丟的是 JsonConvertException,欄位缺了也是它,缺 title 跟 completed 的時候訊息逐字是 Fields [title, completed] are required for type with serial name 'com.cashwu.todo.ExternalTodo', but they were missing at path: $

T 是 String 或 ByteArray 這種的就不需要 ContentNegotiation,那是 Ktor 內建的 transformer 處理掉的,沒裝 ContentNegotiation 的 client 用 body<String>() 照樣拿得到原始的 JSON 字串

後面那個 a 200 that is not the expected json becomes a 502 走的就是第 2 條,helper 給的 header 是 application/json,內容卻是 HTML,而 fetch 裡的 catch (cause: Throwable) 把這 2 種例外一起接成 502

路由與接線

同一個檔案的最後一段是路由

@OptIn(ExperimentalKtorApi::class)
fun Route.todoImportRoute(importer: TodoImporter, events: TodoEvents) {
    route("/todos") {
        post("import/{externalId}") {
            val externalId = call.parameters["externalId"]?.toIntOrNull()
                ?: throw ApiException(HttpStatusCode.BadRequest, "externalId 要是數字")
            val todo = importer.import(externalId)
            events.publish(TodoEvent.Created(todo))
            call.respond(HttpStatusCode.Created, todo)
        }.describeJson(
            summary = "從外部服務匯入一筆待辦",
            successStatus = HttpStatusCode.Created,
            responseType = typeOf<Todo>(),
            badRequest = true,
            notFound = true,
        )
    }
}

events.publish 那一行讓匯入進來的待辦跟自己建的走同一條事件流,day 30 那個 WebSocket 訂閱者不用改任何東西就會收到

src/main/kotlin/com/cashwu/todo/Application.kt 的 DI 容器多註冊 2 個

     dependencies {
         provide<TodoEvents> { TodoEvents() }
+        provide<HttpClient> { todoImportClient(CIO.create(), resolve<TodoConfig>().importer) }
+        provide<TodoImporter> { TodoImporter(resolve(), resolve<TodoConfig>().importer, resolve()) }
     }

取用的地方多一行,路由那邊也多一行

     val events: TodoEvents by dependencies
+    val importer: TodoImporter by dependencies
    routing {

        // ...

        authenticate(TODO_AUTH) {

            // ...
             todoRoutes(repository, events)
+            todoImportRoute(importer, events)
        }
    }

todoImportRoute 掛在 authenticate(TODO_AUTH) 裡面,跟其他 todo 路由同一層,匯入會寫資料庫,沒有理由比新增寬鬆

HttpClient 進容器還有一個附帶效果,day 18 那篇量過容器會在 ApplicationStopping 的時候把實作 AutoCloseable 的相依關掉,而 HttpClient 宣告的是 java.io.Closeable,那個介面本身 extends AutoCloseable,所以照樣被關,不用自己在哪裡寫一行 close(),這件事有測試驗證,後面會貼

真的打一次

資料庫還是 day 23 那個 docker compose 起的 PostgreSQL,./gradlew run 的指令跟 day 28 一樣

docker compose up -d
JWT_SECRET=local-dev-secret-32-bytes-minimum ALICE_PASSWORD=alice-secret \
  BOB_PASSWORD=bob-secret DB_PASSWORD=todo ./gradlew run

登入拿 access token 的做法跟 day 30 一樣,然後打匯入

curl -s -X POST localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","password":"alice-secret"}' > /tmp/login.json
AT=$(python3 -c 'import json; print(json.load(open("/tmp/login.json"))["accessToken"])')

curl -i -X POST localhost:8080/todos/import/1 -H "Authorization: Bearer $AT"

回來的東西逐字是這樣,Date 跟 Server 這 2 行省略

HTTP/1.1 201 Created
X-Request-Id: hn9j3r55r4+d
X-Response-Time: 767ms
Content-Length: 93
Content-Type: application/json

{"id":4,"title":"delectus aut autem","done":false,"created_at":"2026-09-04T23:18:53.725674Z"}

id 是 4 不是 1,因為那是自己的資料庫給的號碼,外部那個 1 只是查詢用的,title 原封不動搬過來,外部的 completed: false 變成自己的 done: false,created_at 是匯入的時間而不是外部那筆的建立時間

id 不一定是 4,要看當下資料庫 table 最後一筆資料的 id 決定

外部找不到那一筆的時候

curl -s -X POST localhost:8080/todos/import/9999 -H "Authorization: Bearer $AT"
{"status":404,"message":"外部服務沒有 id 9999 的待辦","details":[]}

外部掛掉的時候

把 IMPORT_BASE_URL 指到一個沒有人聽的 port,其他都不動

IMPORT_BASE_URL=http://127.0.0.1:9099 JWT_SECRET=local-dev-secret-32-bytes-minimum \
  ALICE_PASSWORD=alice-secret BOB_PASSWORD=bob-secret DB_PASSWORD=todo ./gradlew run

$AT 還是前面那個 token,換一個 shell 打 2 次,順便把狀態碼跟耗時印出來

for i in 1 2; do
  curl -s -w ' | %{http_code} %{time_total}s\n' \
    -X POST localhost:8080/todos/import/1 -H "Authorization: Bearer $AT"
done
{"status":502,"message":"外部服務連不上 (HttpRequestTimeoutException)","details":[]} | 502 3.346917s
{"status":502,"message":"外部服務連不上 (HttpRequestTimeoutException)","details":[]} | 502 3.608761s

2 件事同時發生了,使用者等了 3.3 秒才拿到失敗,而那個例外的名字是 HttpRequestTimeoutException,看起來像對方接了但太慢

它是錯的,9099 沒有人聽,連線是立刻被拒絕的,從頭到尾沒有任何一次真的 timeout,拆這件事的方法是一次只改一個環境變數,程式碼一行都不動

IMPORT_TIMEOUT IMPORT_RETRIES 浮上來的例外 2 次的耗時
2000 2 HttpRequestTimeoutException 3.347s / 3.609s
30000 2 ConnectException 3.035s / 2.802s
2000 0 ConnectException 0.048s / 0.004s

第 1 件事是那 3 秒是誰花的,關掉重試之後同一個失敗 48 毫秒就回來了,連線本身根本沒花時間,3 秒全部是重試之間的退避在等,exponentialDelay(base = 1.2) 加上預設的 baseDelayMs = 1000,2 次重試的固定延遲是 1000 跟 1200 毫秒,後面還各有一段隨機抖動,加起來就是那 3 秒

第 2 件事是 timeout 這個參數在這裡只決定名字,設 2 秒的時候,重試的等待先把那 2000 毫秒吃光,於是 HttpTimeout 先開口,把真正的死因蓋掉,設 30 秒的時候退避跑得完,浮上來的就是真的那一個 ConnectException

正確的因果是「重試的等待撐爆了 request timeout」,不是「timeout 之後又重試」,照前一種讀法會去調 timeout,而 timeout 從來不是問題,對方根本沒開,照後一種讀法才會去看重試次數,那才是 3 秒的來源

HttpTimeout 跟 HttpRequestRetry 這 2 個 plugin 在這裡是疊在一起的,request timeout 罩的是整個帶重試的請求而不是單次嘗試,計時只起跑一次,不會每次重試都重來,所以 timeoutMillis 不能當成「單次連線最多等多久」

但它也不等於「使用者最多等多久」,上面那 3.3 秒就是反例,退避那段 delay 不在它能中斷的範圍裡,時間到了也要等手上這一段跑完才輪得到例外冒出來,要算使用者最多等多久,得把 timeout、重試次數跟退避的總和一起看

用 MockEngine 測

上面那些行為都是靠改環境變數、真的打出去才看到的,接下來同一批行為要在不碰網路的情況下重現一次,換掉的只有 engine

新增一個檔案是 src/test/kotlin/com/cashwu/todo/TodoImporterTest.kt,共用的零件放在最上面

private const val BASE_URL = "https://todos.example.com"

private val testImporterConfig = ImporterConfig(
    baseUrl = BASE_URL,
    timeoutMillis = 2_000,
    maxRetries = 2,
)

private const val EXTERNAL_BODY =
    """{"userId":1,"id":7,"title":"delectus aut autem","completed":true}"""

那個網域是 example.com,RFC 2606 保留給文件使用、不會落到任何人手上的,挑它不是隨便挑的,萬一哪天 MockEngine 沒接到而請求真的送出去了,todos.example.com 這個子網域也不解析,打不到任何一個真實的服務

seen 是 class 裡的一個欄位,engine 每收到一個請求就往裡面放

class TodoImporterTest {

    private val seen = mutableListOf<HttpRequestData>()

    private fun importer(
        config: ImporterConfig = testImporterConfig,
        respondWith: MutableList<Pair<HttpStatusCode, String>> =
            mutableListOf(HttpStatusCode.OK to EXTERNAL_BODY),
    ): TodoImporter {
        val engine = MockEngine { request ->
            seen += request
            val (status, body) = respondWith.removeFirstOrNull()
                ?: (HttpStatusCode.OK to EXTERNAL_BODY)
            if (status.value >= 500) {
                respondError(status)
            } else {
                respond(
                    content = body,
                    status = status,
                    headers = headersOf(HttpHeaders.ContentType, "application/json"),
                )
            }
        }
        return TodoImporter(
            todoImportClient(engine, config),
            config,
            FakeTodoRepository(),
        )
    }
}

MockEngine { } 收到的是一個 HttpRequestData,回的是一個假造的回應,中間沒有 socket、沒有 DNS、沒有 port,respondWith 是一個會被逐次拿掉第 1 筆的清單,所以同一個測試可以安排「第 1 次 500、第 2 次 500、第 3 次 200」這種劇本,seen 記下每一個經過的請求,斷言就對著它做

先確認送出去的東西跟拿回來的東西都對

@Test
fun `an import becomes one get against the configured base url`() = runTest {
    val todo = importer().import(7)

    assertEquals(1, seen.size)
    assertEquals(HttpMethod.Get, seen[0].method)
    assertEquals("$BASE_URL/todos/7", seen[0].url.toString())
    assertEquals("delectus aut autem", todo.title)
    assertTrue(todo.done)
    assertEquals(4, todo.id)
}

assertTrue(todo.done) 這一行盯的是欄位改名這件事,外部叫 completed,自己叫 done,中間那個對應斷掉的話這裡會失敗,assertEquals(4, todo.id) 則是說明匯入拿到的是本地的新號碼,不是外部的 7

外部多回欄位也要有測試驗證

@Test
fun `the fields the external service adds are ignored`() = runTest {
    val todo = importer(
        respondWith = mutableListOf(
            HttpStatusCode.OK to
                """{"userId":1,"id":7,"title":"倒垃圾","completed":false,"priority":"high"}""",
        ),
    ).import(7)

    assertEquals("倒垃圾", todo.title)
    assertFalse(todo.done)
}

那個 priority 是憑空多出來的欄位,模擬的就是外部服務某天自己改版,拿掉 ignoreUnknownKeys 這個測試就會失敗

失敗的對應也各有一個

@Test
fun `a missing external todo becomes a 404`() = runTest {
    val failure = assertFailsWith<ApiException> {
        importer(respondWith = mutableListOf(HttpStatusCode.NotFound to "")).import(404)
    }

    assertEquals(HttpStatusCode.NotFound, failure.status)
    assertEquals(1, seen.size)
    assertContains(failure.message, "404")
}

@Test
fun `a 200 that is not the expected json becomes a 502`() = runTest {
    val failure = assertFailsWith<ApiException> {
        importer(
            respondWith = mutableListOf(HttpStatusCode.OK to "<html>maintenance</html>"),
        ).import(7)
    }

    assertEquals(HttpStatusCode.BadGateway, failure.status)
    assertContains(failure.message, "JSON")
}

第 2 個測試裡那坨 HTML 不是亂寫的,外部服務進維護模式的時候回一頁 HTML 加 200 是很常見的事,這比回 503 更難查,因為狀態碼看起來是好的

重試到底重試什麼

retryOnServerErrors 這個名字騙人,HttpRequestRetryConfig 的建構子本身會先跑一次 retryOnExceptionOrServerErrors(3),retryOnServerErrors 換掉的只是「看回應決定要不要重試」那一半,看例外的那一半判斷條件原封不動留著,次數則是共用的,retryOnServerErrors(maxRetries = 2) 會把 init 設的 3 一起蓋掉,所以例外那一側也是最多打 3 次

所以 5xx 會重試是名字說的,IOException 也會被重試是名字沒說的

@Test
fun `an engine that throws is retried too and becomes a 502`() = runTest {
    val attempts = AtomicInteger()
    val engine = MockEngine {
        attempts.incrementAndGet()
        throw java.io.IOException("線斷了")
    }
    val importer = TodoImporter(
        todoImportClient(engine, testImporterConfig),
        testImporterConfig,
        FakeTodoRepository(),
    )

    val failure = assertFailsWith<ApiException> { importer.import(7) }

    assertEquals(HttpStatusCode.BadGateway, failure.status)
    assertContains(failure.message, "IOException")
    assertEquals(3, attempts.get())
}

@Test
fun `a timeout is the one failure that is not retried`() = runTest {
    val config = testImporterConfig.copy(timeoutMillis = 500)
    val attempts = AtomicInteger()
    val engine = MockEngine {
        attempts.incrementAndGet()
        delay(3_000)
        respond(content = EXTERNAL_BODY, status = HttpStatusCode.OK)
    }
    val importer = TodoImporter(todoImportClient(engine, config), config, FakeTodoRepository())

    val failure = assertFailsWith<ApiException> { importer.import(7) }

    assertEquals(HttpStatusCode.BadGateway, failure.status)
    assertEquals(1, attempts.get())
}

assertEquals(3, attempts.get()) 跟 assertEquals(1, attempts.get()) 這 2 行,把「retryOnServerErrors 沒有關掉例外重試」跟「timeout 是唯一不重試的」寫成 2 個會被 CI 抓的東西,這是那種只靠讀名字會猜錯的行為,猜錯了不會有人告訴你,所以要寫成測試

timeout 不重試的原因在建構子的預設值,它用的是 retryOnException(maxRetries, retryOnTimeout = false),倒回去看前面那個沒人聽的 port 就通了,連線被拒屬於例外那一半,所以它被重試,而 timeout 不會

MockEngine 在這裡做的 2 件事順便展示了它的範圍,它可以直接丟例外,網路斷掉不用真的拔網路線,也可以 delay 之後才回,timeout 不用真的等一個慢的伺服器,那 0.5 秒是整份測試裡唯一真的在等的時間

回應那一側的重試也有測試

@Test
fun `a server error that clears on the third try still imports`() = runTest {
    val todo = importer(
        respondWith = mutableListOf(
            HttpStatusCode.InternalServerError to "",
            HttpStatusCode.InternalServerError to "",
            HttpStatusCode.OK to EXTERNAL_BODY,
        ),
    ).import(7)

    assertEquals("delectus aut autem", todo.title)
    assertEquals(3, seen.size)
}

@Test
fun `a client error is not retried`() = runTest {
    val failure = assertFailsWith<ApiException> {
        importer(respondWith = mutableListOf(HttpStatusCode.BadRequest to "")).import(7)
    }

    assertEquals(HttpStatusCode.BadGateway, failure.status)
    assertEquals(1, seen.size)
}

maxRetries = 2 是「重試 2 次」不是「總共 2 次」,所以最多打 3 次,第 1 個測試安排的 3 個回應剛好用完,第 2 個驗的是 4xx 不重試,把一個格式錯誤的請求再送 2 次不會有不同的結果

client 誰來關

前面說 HttpClient 進了容器就不用自己關,這個測試把那句話變成一個破了就會被抓到的斷言

@Test
fun `the client is closed when the application stops`() {
    var client: HttpClient? = null
    testApplication {
        mockedImportApplication { respond(content = EXTERNAL_BODY, status = HttpStatusCode.OK) }
        application { client = dependencies.resolve<HttpClient>() }

        bearerClient().get("/todos")

        assertTrue(client!!.isActive)
    }

    assertFalse(client!!.isActive)
}

中間那個 bearerClient().get("/todos") 不是在測路由,是為了讓 application 真的起來,容器只關已經建出來的相依

testApplication { } 的 block 結束時 application 才停,所以 block 裡面斷言還活著、block 外面斷言關掉了,寫法跟 day 18 那個 AutoCloseable 的測試同一個路數

isActive 這個名字要看清楚,它不是 HttpClient 自己的屬性,看一下它的宣告

public class HttpClient(
    public val engine: HttpClientEngine,
    private val userConfig: HttpClientConfig<out HttpClientEngineConfig> = HttpClientConfig()
) : CoroutineScope, Closeable {

HttpClient 實作了 CoroutineScope,測試檔 import 進來的是 kotlinx.coroutines.isActive,那是 CoroutineScope 的擴充屬性,讀的是 coroutineContext[Job],所以這個測試斷的是「那個 client 的 job 還活著沒有」,不是某個 closed 旗標,這樣寫的好處是就算哪天容器換了關閉的實作,只要行為還在,測試就還是對的

整條路走一次

最後一個測試把 DI 覆寫、路由跟 day 30 的事件流串成一條

@Test
fun `an import through the api creates a todo and publishes an event`() = testApplication {
    val published = CompletableDeferred<TodoEvent>()
    mockedImportApplication {
        respond(
            content = EXTERNAL_BODY,
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json"),
        )
    }
    application {
        val events = dependencies.resolve<TodoEvents>()
        launch(start = CoroutineStart.UNDISPATCHED) {
            events.events.collect { published.complete(it) }
        }
    }

    val response = bearerClient().post("/todos/import/7")

    assertEquals(HttpStatusCode.Created, response.status)
    val event = withTimeout(5_000) { published.await() }
    assertEquals("delectus aut autem", (event as TodoEvent.Created).todo.title)
    assertTrue(event.todo.done)
}

CoroutineStart.UNDISPATCHED 在這裡的作用是保證 collector 在 module() 繼續往下之前就訂閱好了,不然事件有可能在還沒有人聽的時候就發出去

mockedImportApplication 是這個 class 自己的 helper,做的是 day 24 拆過的那一套,把 modules.size 設成 0,先用一個 application { } 把 HttpClient 換成 MockEngine 的版本,再用第 2 個 application { } 跑 module()

private fun ApplicationTestBuilder.mockedImportApplication(
    handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
) {
    configure(overrides = {
        h2Database()
        put("todo.database.url", "jdbc:h2:mem:importer")
        put("ktor.application.modules.size", "0")
    })
    application {
        dependencies.provide<HttpClient> {
            todoImportClient(MockEngine(handler), testImporterConfig)
        }
    }
    application { module() }
    serverConfig { developmentMode = false }
}

那一行 put("todo.database.url", "jdbc:h2:mem:importer") 是後來補的,其他測試共用 jdbc:h2:mem:todo 這個記憶體資料庫,匯入的測試會往裡面塞資料,換一個名字才不會影響到其他測試對資料列數的斷言


小結

這篇讓 todo-api 主動往外面請求資料,正式環境與測試共用同一份 HttpClient 設定,差別只在 engine,正式環境用 CIO、測試用 MockEngine,所以 timeout、重試、反序列化與失敗的對應都能在不碰網路的情況下驗證

失敗要對應成什麼,是這一篇真正的產品決定,外部的 404 變成自己的 404,其他一律 502,兩者對使用者的意義不一樣,一個是換個 id 再試,一個是等我這邊修

重試的成本不能只看 timeout,同一個連線被拒的失敗,重試開著要 3 秒、關掉只要 48 毫秒,而 timeout 設多少只決定最後浮上來的例外叫什麼名字,HttpTimeout 罩的是整個帶重試的請求,但它擋不住重試之間的退避,所以它不是使用者總等待時間的上界

這篇留下的問題有 5 個,匯入是同步的,外部服務慢的時候使用者就得等,沒有排程或背景匯入這個選項,沒有快取,同一個 externalId 匯入 2 次會打 2 次外部服務,自己的資料庫也會多留一筆,沒有斷路器,外部服務掛掉的時候每一個請求都會自己去撞一輪重試,撞完才回 502,容器裡只有一個 HttpClient,timeoutMillis 跟 maxRetries 是裝在那個 client 身上而不是掛在單一整合上,現在只有匯入在用,兩者剛好重疊看不出差別,哪天多接一個對外呼叫就會一起吃到這一份設定,最後是 MockEngine 測得到請求跟回應,測不到真的網路行為,DNS、TLS、連線池都不在範圍裡


下一篇

這 31 天長出來的東西,回的一直都是 JSON,服務的一直都是機器,下一篇讓同一批路由多長出一種給人看的形式,用 htmx 接起來,/todos 帶著 HX-Request 就回 HTML fragment,不帶還是回原本那份 JSON

真正的麻煩不在 HTML,瀏覽器點連結、打網址列的時候沒有地方讓你塞 Authorization header,day 26 選 Bearer 的那個決定,到那一篇才會第 1 次遇到它沒有考慮過的客戶端


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 30 WebSocket 即時推播
系列文
Kotlin Ktor 實戰 101 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言