
day 14 最後的時候,todo-api 對 client 的錯誤有 3 種回法,2 種 400 加 1 種說謊的 500,格式全是純文字,其中那個 500 還把整個請求物件的 toString() 印回去給發請求的人看,這篇裝 StatusPages 把這些一次處理完
順序是先把錯誤的形狀定下來,再一種一種接過去,中間要翻原始碼確認 2 件事,StatusPages 攔例外的位置跟 engine 的 handleFailure 是什麼關係,還有 exception<Throwable> 這種 catch-all 寫法會不會順手蓋掉框架自己的狀態碼對照表
day 13 那句「只知道解不開,說不出哪裡解不開」也在這篇有下文,BadRequestException 底下的 cause chain 值得往下走幾層看看
handleFailure 誰先接到ErrorResponse,讓成功回 JSON、失敗回純文字這件事結束exception<RequestValidationException> 把 500 改回 400,reasons 變成 JSON 陣列,請求物件不再被印回去TodoRoutes.kt 裡 3 個 respondText 的錯誤回應換成 throwexception<Throwable> 會順手接走什麼不該接的東西這篇的實作從頭到尾都繞著 StatusPages 的 2 張表跟 3 個 hook 打轉,所以先把原始碼一次看完,後面就不用再回頭
StatusPages.kt 的主體只有 3 個 hook
public val StatusPages: ApplicationPlugin<StatusPagesConfig> = createApplicationPlugin(
"StatusPages",
::StatusPagesConfig
) {
val statusPageMarker = AttributeKey<Unit>("StatusPagesTriggered")
// ...
on(ResponseBodyReadyForSend) { call, content -> /* 查 statuses 這張表 */ }
on(CallFailed) { call, cause -> /* 查 exceptions 這張表 */ }
on(BeforeFallback) { call -> if (call.isHandled) return@on; unhandled(call) }
}
CallFailed 這個名字 day 10 介紹 hook 的時候點過名,說它「在請求丟出例外時觸發」,當時沒細看它掛在哪,CommonHooks.kt 裡的定義是這樣
public object CallFailed : Hook<suspend (call: ApplicationCall, cause: Throwable) -> Unit> {
private val phase = PipelinePhase("BeforeSetup")
override fun install(
pipeline: ApplicationCallPipeline,
handler: suspend (call: ApplicationCall, cause: Throwable) -> Unit
) {
pipeline.insertPhaseBefore(ApplicationCallPipeline.Setup, phase)
pipeline.intercept(phase) {
try {
coroutineScope { proceed() }
} catch (cause: Throwable) {
handler(call, cause)
if (!call.response.isSent) throw cause
}
}
}
}
它自己開一個 phase 插在 Setup 前面,然後用 try-catch 把 proceed() 整個包起來,day 09 那個「洋蔥模型」的測試證明過 proceed() 前後包夾會形成巢狀結構,這裡就是把洋蔥的最外層拿來當 catch,Setup、Monitoring、Plugins、Call、Fallback 5 個 phase 全部在這個 try 裡面,所以 routing 的 handler、ContentNegotiation 的反序列化、RequestValidation 的規則,任何一段丟出來的例外都逃不掉
那 engine 的 handleFailure 呢,day 12 跟 day 14 2 次翻過的 DefaultEnginePipeline.kt,那段 try-catch 包的是 call.application.execute(call)
pipeline.intercept(EnginePipeline.Call) {
try {
call.application.execute(call)
} catch (error: Throwable) {
// ... handleFailure(call, error)
}
}
StatusPages 的 catch 在 application.execute() 裡面,engine 的 catch 在外面,內層先接到,所以順序是這樣
Setup 前面的那個 phasehandleFailure 這輩子看不到它throw cause,例外繼續往外走到 engine,handleFailure 才接手handleFailure 從 day 12 的「唯一的錯誤處理」降級成 StatusPages 的後備,它不會消失,只是不再有機會表現,而 day 14 那個 500,那份 ERROR log、那個把請求物件印出來的訊息,全部都是它的作品
findHandlerByValue 決定查表的規則,這段要看清楚
fun findHandlerByValue(cause: Throwable): HandlerFunction? {
val keys = exceptions.keys.filter { cause.instanceOf(it) }
if (keys.isEmpty()) return null
if (keys.size == 1) return exceptions[keys.single()]
val key = selectNearestParentClass(cause, keys)
return exceptions[key]
}
先把所有「這個例外是它的實體」的 key 撈出來,撈到多個就交給 selectNearestParentClass 挑最近的父類別,等一下同時註冊 exception<BadRequestException> 跟 exception<Throwable> 的時候,就是靠這一行決定前者贏
statusPageMarker 是另一個要注意的細節,CallFailed 進到 handler 之前會先把這個 marker 放進 call.attributes,而 ResponseBodyReadyForSend 那個 hook 第 1 行就檢查 marker 在不在,在就直接返回,2 張表因此互斥,同一個請求不會被處理 2 次
機制看完了,接下來一路寫到底,先加相依套件,build.gradle.kts 的 dependencies 區塊裡接在 requestValidation 後面
implementation(ktorLibs.server.statusPages)
錯誤回應的資料形狀跟映射規則另外開一個檔案 src/main/kotlin/com/cashwu/todo/ErrorHandling.kt,理由跟 day 14 把驗證規則放進 TodoValidation.kt 一樣,Application.kt 是組裝的地方
@Serializable
data class ErrorResponse(
val status: Int,
val message: String,
val details: List<String> = emptyList(),
)
class ApiException(
val status: HttpStatusCode,
override val message: String,
) : RuntimeException(message)
status 在回應的狀態列已經有一份,body 裡再放一次是為了讓只讀 body 的 client 也知道發生什麼事,message 是一句給人看的話,details 放補充訊息,驗證失敗的多個原因,缺哪幾個欄位,都放這裡,目前 details 仍是給人看的字串,不是穩定的機器介面,前端如果要依欄位或錯誤碼決定行為,應該改成帶 code、field、message 的結構,這 3 個欄位的分工是這篇所有 handler 共用的約定
ApiException 讓 handler 用「丟例外」表達 HTTP 錯誤,這裡刻意不重用 Ktor 內建的 BadRequestException 跟 NotFoundException,因為那 2 個框架自己也在丟,混在一起之後就分不出「這個 message 是我寫的,可以安全回給 client」還是「這個 message 是框架產生的,裡面有內部類別名稱」,開一個自己的型別,這條界線就清楚了
同一個檔案裡再放一個 private 的 helper,讓每個 handler 少寫 2 行
private suspend fun ApplicationCall.respondError(
status: HttpStatusCode,
message: String,
details: List<String> = emptyList(),
) {
respond(status, ErrorResponse(status.value, message, details))
}
respond 而不是 respondText,所以這份 ErrorResponse 會走 day 12 裝的 ContentNegotiation 序列化出去,也因此 day 13 設的 encodeDefaults = true 在這裡有作用,details 就算是空的也會印成 "details":[],client 拿到的每一份錯誤欄位數都一樣,不用先判斷欄位在不在
exception<T> 跟 status(...) 收的東西不一樣
exception<T> 收的是「有東西被丟出來」,掛在 CallFailed
status(...) 收的是「回應的狀態碼長這樣」,掛在 ResponseBodyReadyForSend,也就是 send pipeline 的 After phase404 這種情況兩邊都會遇到,而且來源完全不同,找不到 /todos/999 這筆資料,是我們自己判斷後決定的,打 /nope 這個根本不存在的路徑,沒有任何例外被丟出來,是 day 09 講過的 Fallback phase 那個預設 interceptor 回的 404,前者只有 exception 接得到,後者只有 status 接得到
映射寫在 ErrorHandling.kt,一樣做成 StatusPagesConfig 的 extension function
fun StatusPagesConfig.todoStatusPages() {
exception<ApiException> { call, cause ->
call.respondError(cause.status, cause.message)
}
exception<RequestValidationException> { call, cause ->
call.respondError(HttpStatusCode.BadRequest, "欄位不符合規則", cause.reasons)
}
status(HttpStatusCode.NotFound) { call, status ->
call.respondError(status, "找不到這個路徑")
}
}
Application.kt 那邊接在 install(RequestValidation) 後面
install(StatusPages) {
todoStatusPages()
}
最後是 src/main/kotlin/com/cashwu/todo/TodoRoutes.kt,3 個 respondText 的錯誤回應換成 throw,改完的整份檔案長這樣
package com.cashwu.todo
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.time.Instant
fun Route.todoRoutes() {
route("/todos") {
get {
val limitText = call.request.queryParameters["limit"]
val limit = if (limitText == null) todos.size else limitText.toIntOrNull()?.takeIf { it >= 0 }
?: throw ApiException(HttpStatusCode.BadRequest, "limit 要是 0 以上的整數")
call.respond(todos.take(limit))
}
get("{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw ApiException(HttpStatusCode.BadRequest, "id 要是數字")
val todo = todos.find { it.id == id }
?: throw ApiException(HttpStatusCode.NotFound, "找不到 id $id 的待辦")
call.respond(todo)
}
post {
val request = call.receive<CreateTodoRequest>()
val todo = Todo(
id = (todos.maxOfOrNull { it.id } ?: 0) + 1,
title = request.title,
done = false,
createdAt = Instant.now(),
)
todos.add(todo)
call.respond(HttpStatusCode.Created, todo)
}
}
}
{id} 那個 handler 原本的 6 行變成 2 個 ?:,limit 那個 respondText 加 return@get 也改成同一種寫法,3 個 handler 都只剩下成功的那條路,這是丟例外相對於回傳回應的實際好處,錯誤不用一路傳回來,深一層的函式也能直接中斷
post 那一段一個字都沒改,它從頭到尾就沒有自己處理過錯誤,receive() 丟出來的東西以前掉給 handleFailure,現在掉給 StatusPages,整份檔案已經沒有人在用 respondText,錯誤回應全部交給 StatusPages
2 種 404 從此走 2 條不同的路,/todos/999 走 exception<ApiException>,/nope 走 status(NotFound),同一個狀態碼、同一個形狀,訊息不同,第 1 筆之所以不會再被 status handler 蓋掉一次,靠的就是前面那個 statusPageMarker
驗證失敗那條已經在上一節的 todoStatusPages() 裡了,handler 只取 cause.reasons,cause.message 從頭到尾沒被碰過,day 14 說「這種 API 不能上線」的那個洩漏,是因為沒有人接住例外,message 漏到 HTTP 回應上,現在有人接住了,Validation failed for CreateTodoRequest(title= , done=false) 那串連同請求內容一起消失,reasons 改放進 details 這個陣列
2 條規則同時違反的話 details 就有 2 個元素,day 14 那個 joinToString(".") 串出來、中間只有一個句點的字串從此不再出現在回應裡
剩下的是 day 13 留到現在的那一半。少帶 title 跟送 not json 丟的都是 BadRequestException,訊息也都是同一句
Failed to convert request body to class com.cashwu.todo.CreateTodoRequest
handler 收到的就是這個物件,光看 message 確實分不出兩者。但例外有 cause,cause 底下還有 cause,先在 src/main/kotlin/com/cashwu/todo/ErrorHandling.kt 裡加一個把整條鏈攤成序列的 extension function
internal fun Throwable.causeChain(): Sequence<Throwable> =
generateSequence(this) { current -> current.cause.takeIf { it !== current } }
takeIf { it !== current } 是防自己指向自己的無窮迴圈,某些函式庫包裝例外的時候會出現這種東西
有了它就能看鏈裡面有什麼,開一個暫時的 src/test/kotlin/com/cashwu/todo/CauseChainProbe.kt,裝一個只負責印東西的 StatusPages,站在跟正式 handler 一樣的位置上
@OptIn(ExperimentalSerializationApi::class)
private fun dump(label: String, body: String) = testApplication {
application {
install(ContentNegotiation) {
json(Json { encodeDefaults = true; ignoreUnknownKeys = true })
}
install(StatusPages) {
exception<BadRequestException> { call, cause ->
println(label)
cause.causeChain().forEachIndexed { i, t ->
val missing = (t as? MissingFieldException)
?.let { " missingFields=${it.missingFields}" } ?: ""
println("[$i] ${t::class.simpleName}$missing")
}
call.respondText("dumped")
}
}
routing {
post("/probe") {
call.receive<CreateTodoRequest>()
call.respondText("ok")
}
}
}
client.post("/probe") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody(body)
}
}
@Test
fun `print cause chains`() {
dump("少帶 title", """{"done":true}""")
dump("送 not json", "not json")
}
install(ContentNegotiation) 那段的 Json 設定要跟 Application.kt 裡的一樣,ignoreUnknownKeys 開不開會換掉底下丟出來的例外型別,抄錯一個字看到的鏈就不是正式環境的那一條,這個檔案的用途只有印出相關的內容
跑起來印出這些
少帶 title
[0] BadRequestException
[1] BadRequestException
[2] JsonConvertException
[3] MissingFieldException missingFields=[title]
[4] MissingFieldException missingFields=[title]
送 not json
[0] BadRequestException
[1] BadRequestException
[2] JsonConvertException
[3] JsonDecodingException
2 條鏈的前 3 層一模一樣,差別在第 4 層,一邊是 MissingFieldException、一邊是 JsonDecodingException
MissingFieldException 是 kotlinx.serialization 的公開型別,帶一個 missingFields: List<String>,缺哪幾個欄位它都記著。要拿到它得往下走 3 層,最外層那個 BadRequestException 底下還包著一個 BadRequestException,再來才是 JsonConvertException,然後才是 MissingFieldException
順帶一提,同一個例外在 call.receive() 的呼叫端接到的只有 1 層 BadRequestException,多出來的那一層是它往外走到 StatusPages 的路上被包上去的,所以這條鏈要站在 handler 的位置上看才準
只判斷一層完全對不出東西,因為 JsonConvertException 繼承的是 ContentConvertException,跟 SerializationException 沒有血緣關係
MissingFieldException 在 kotlinx.serialization 1.9.0 仍標著 @ExperimentalSerializationApi,使用它的函式要明確 opt-in,對應的 import 是 kotlinx.serialization.ExperimentalSerializationApi,升版時也要重新確認這段 API
所以判斷的函式走整條 chain,一樣放在 ErrorHandling.kt,接在剛剛那個 causeChain() 後面
@OptIn(ExperimentalSerializationApi::class)
internal fun BadRequestException.bodyDetails(): List<String> {
val chain = causeChain().toList()
val missing = chain.filterIsInstance<MissingFieldException>().firstOrNull()
return when {
missing != null -> missing.missingFields.map { "缺少必填欄位 $it" }
chain.any { it is SerializationException } ->
listOf("request body 無法解析,請檢查 JSON 格式與欄位型別")
else -> emptyList()
}
}
2 個分支的順序不能反過來,MissingFieldException 本身就是 SerializationException 的子類別,先問後者的話缺欄位那條永遠走不到。第 3 個分支回空 list,是給「chain 裡什麼都沒對上」用的,details 就是 [],至少狀態碼跟形狀還在
最後把 handler 補進同一個檔案的 todoStatusPages() 裡,接在 exception<RequestValidationException> 後面
exception<BadRequestException> { call, cause ->
call.respondError(HttpStatusCode.BadRequest, "請求內容不正確", cause.bodyDetails())
}
這 3 個 exception 的先後順序其實不影響誰接到,決定權在前面看過的 findHandlerByValue,它挑的是最近的父類別,不是先註冊的那個。排在這裡只是讓「3 種 client 錯誤」在檔案裡照著這篇講的順序排
2 種請求現在講的是 2 件事,狀態碼都是 400,少帶 title 的 details 是 ["缺少必填欄位 title"],not json 的 details 是 ["request body 無法解析,請檢查 JSON 格式與欄位型別"]。後面這個分支也會接住 JSON 語法合法但欄位型別不符的情況,所以訊息不能只說「不是合法的 JSON」。day 13 結尾那 2 個一字不差的訊息,到這裡終於講的是 2 件不同的事
有一件事沒有做到,解析失敗那條的 details 只講得出「請檢查 JSON 格式與欄位型別」,講不出實際壞在哪裡,JsonDecodingException 的 message 可能帶著 Unexpected JSON token at offset 0 這類資訊,但那串裡面同時有 serial name 等內部細節,要挑出來就得自己解析框架的錯誤字串,下次升版就可能斷掉,offset 對 client debug 的幫助也有限,這裡就停在類別層級
剩下的是完全沒預期到的例外,資料庫斷線、空指標、任何 bug,用 exception<Throwable> 當最後一道防線
exception<Throwable> { call, cause ->
if (cause is CancellationException) throw cause
val known = defaultExceptionStatusCode(cause)
if (known != null) {
call.respondError(known, known.description)
return@exception
}
call.application.log.error("Unhandled exception on ${call.request.uri}", cause)
call.respondError(HttpStatusCode.InternalServerError, "伺服器發生未預期的錯誤")
}
短短幾行,3 個坑,一個一個講
defaultExceptionStatusCode 那 2 行不能省,day 12 那個 415 的測試,送 Content-Type: text/plain 會丟 CannotTransformContentToTypeException,它繼承的是 ContentTransformationException,跟 BadRequestException 一點關係都沒有,於是掉進 exception<Throwable>,少了這個判斷,415 就會變成 500,day 12 的測試當場失敗,defaultExceptionStatusCode 是 io.ktor.server.engine 底下的 public 函式,就是 day 14 列出來的那張對照表,day 12 追 415 的來源時第 1 次提到它,直接拿來用,框架原本的映射就保住了
送 Content-Type: text/plain 拿到的還是 415 Unsupported Media Type,只是 body 換成了 ErrorResponse 的形狀,實際的輸出後面 curl 那節會看到
這一批的 message 是英文的,因為 known.description 就是 HTTP 規格裡的 reason phrase,要中文化就得自己再開一張表把狀態碼對到訊息,這裡選擇不開,多一張表就多一個會過期的地方,而 415 這種狀態碼本來就是機器在看
CancellationException 要丟回去,client 中途斷線的時候 coroutine 會被取消,這個例外沿著同一條路往外走,而 exception<Throwable> 照樣接得住它,實測過沒有這一行的版本,取消被當成一次伺服器內部錯誤處理掉
HTTP/1.1 500 Internal Server Error
Content-Length: 73
Content-Type: application/json
{"status":500,"message":"伺服器發生未預期的錯誤","details":[]}
一個已經沒人在聽的連線,我們對著它寫了一份 500,順便在 log 留下一筆 ERROR 加 stack trace,client 每斷一次線就多一筆假的錯誤,真正的 bug 埋在裡面更難找,Relix day 27 手刻 middleware 時第 1 行就把 CancellationException 丟回去,day 16 那個更早的版本也是這樣寫的,理由都一樣,取消不是錯誤
500 自己要記 log,前面說過 StatusPages 接住之後 handleFailure 不會執行,而 handleFailure 第 1 行呼叫的 logError 才是進到 logFailure 的入口,所以錯誤也不會被記下來,這一行 log.error 是補上那個缺口,stack trace 進 log,不進回應,回應只有一句「伺服器發生未預期的錯誤」,例外的 message 是誰寫的、裡面有什麼,接住它的人沒辦法知道,所以正式環境一律不外流,Relix day 27 給了 development 模式一條例外,開發時把 message 回出去方便 debug,那套做法在 Ktor 這邊也成立,application.developmentMode 讀得到,這裡不做是因為 todo-api 還沒有環境的概念,day 17 講設定管理的時候會補
裝完 status(NotFound) 之後打 /nope,header 區塊會多一行
HTTP/1.1 404 Not Found
X-Response-Time: 0ms
X-Response-Time: 12ms
Content-Length: 61
Content-Type: application/json
同一個 header 出現 2 次,exception 那條路沒有這個問題,只有 status 這條會
原因在 ResponseBodyReadyForSend 掛的位置,send pipeline 的 After phase,Fallback 回的那個 404 先跑完一次 send pipeline,day 10 做的 RequestTiming 在 onCallRespond 裡蓋了一次 header,接著 status handler 拿到控制權,呼叫 call.respondError(),send pipeline 又跑了一次,RequestTiming 再蓋一次。call.response.headers.append() 顧名思義就是追加,於是同一個 header 出現 2 次
修法很短,RequestTiming.kt 的 onCallRespond 開頭多一行
onCallRespond { call ->
if (call.response.headers[headerName] != null) return@onCallRespond
val start = call.attributes.getOrNull(startTimeKey) ?: return@onCallRespond
// ...
}
留第 1 次的數字,第 2 次那個 12ms 其實比較貼近真實耗時,但這裡優先處理 header 重複,這種東西進了 proxy 或 CDN 行為很難預測,這是一個自訂 plugin 遇到 status(...) 才會浮出來的互動,day 10 寫 RequestTiming 的時候完全沒有理由想到
實作到齊了,先寫測試把行為固定下來,再開伺服器看回應長什麼樣
先改 day 14 那 3 個,RequestValidationTest 裡 blank title is rejected、title one char over the max length is rejected、install order does not change when validation runs 3 個測試的 HttpStatusCode.InternalServerError 全部換成 HttpStatusCode.BadRequest,第 1 個 @Test 上面那 2 行「day 15 之後會變成 400」的註解一起拿掉
第 3 個測試要多一步,它自己組 application 驗證 install 順序,原本只裝了 ContentNegotiation 跟 RequestValidation,得補上 StatusPages 才會回 400
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(StatusPages) {
todoStatusPages()
}
這反而讓那個測試更完整,它現在證明的是 3 個 plugin 不管怎麼排,行為都一樣
TodoRoutesTest 那 4 個斷言 400 的測試一行都不用改,因為它們從頭到尾只斷言狀態碼,沒有碰過錯誤訊息的內容,這是 day 12 改那批測試時留下的好處
新的測試開一個 src/test/kotlin/com/cashwu/todo/StatusPagesTest.kt,import 一次列完,後面每一組只貼 class 裡面的部分
package com.cashwu.todo
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
class StatusPagesTest {
@BeforeTest
fun resetTodos() {
todos.clear()
todos.addAll(defaultTodos)
}
private suspend fun HttpResponse.error(): ErrorResponse =
Json.decodeFromString<ErrorResponse>(bodyAsText())
// 底下三組測試都放進這個 class
}
@BeforeTest 是 day 12 那件事的延續,todos 是整個 JVM 共用的全域變數,這個 class 裡沒有測試會成功建立 todo,但別的 class 會,先重設回 defaultTodos 的 3 筆比較安全
error() 這個 helper 把 body 解回 ErrorResponse,下面每個要比對內容的測試都用它
第 1 組盯回應的內容,這是前面那些測試從來沒驗過的地方,validation failure returns every reason in details 送一個又空白又超長的 title,2 條規則同時違反
@Test
fun `validation failure returns every reason in details`() = testApplication {
application { module() }
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"title":"${" ".repeat(TITLE_MAX_LENGTH + 1)}"}""")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
assertEquals(
ErrorResponse(
status = 400,
message = "欄位不符合規則",
details = listOf("title 不能是空白", "title 長度不能超過 100 個字"),
),
response.error(),
)
}
把整個 ErrorResponse 拿來比,比只驗 status 嚴格得多,欄位名稱、訊息、順序全部在裡面,day 14 那個 receive throws with every reason collected 是在 handler 裡把 reasons 攔下來看,這個測試看的是它變成 HTTP 回應之後的樣子
missing required field is named in the details 跟 malformed json gets a different detail from a missing field 是 day 13 那個伏筆的收尾,2 個請求的 message 一樣、details 不一樣
@Test
fun `missing required field is named in the details`() = testApplication {
application { module() }
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"done":true}""")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
assertEquals(
ErrorResponse(400, "請求內容不正確", listOf("缺少必填欄位 title")),
response.error(),
)
}
@Test
fun `malformed json gets a different detail from a missing field`() = testApplication {
application { module() }
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("not json")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
assertEquals(
ErrorResponse(
400,
"請求內容不正確",
listOf("request body 無法解析,請檢查 JSON 格式與欄位型別"),
),
response.error(),
)
}
day 13 結尾那 2 個一字不差的訊息,被這 2 個測試分成 2 件不同的事,bodyDetails() 哪天走錯 cause chain 的分支,倒下來的會是其中一個
handler errors come back in the same shape as json 驗的是這篇開場承諾的那件事
@Test
fun `handler errors come back in the same shape as json`() = testApplication {
application { module() }
val response = client.get("/todos/999")
assertEquals(HttpStatusCode.NotFound, response.status)
assertEquals(ContentType.Application.Json, response.contentType()?.withoutParameters())
assertEquals(
ErrorResponse(404, "找不到 id 999 的待辦"),
response.error(),
)
}
handler 丟出來的錯誤跟成功的回應一樣是 application/json、一樣是 ErrorResponse 的形狀,成功回 JSON 失敗回純文字的日子到此結束,withoutParameters() 是為了把 charset=UTF-8 那段去掉再比
第 2 組盯不該出現的東西,3 個測試都用 assertFalse
@Test
fun `validation failure never echoes the request object`() = testApplication {
application { module() }
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"title":" "}""")
}
val body = response.bodyAsText()
assertFalse(body.contains("CreateTodoRequest"))
assertFalse(body.contains("Validation failed"))
}
@Test
fun `error response never leaks the request class name`() = testApplication {
application { module() }
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("not json")
}
assertFalse(response.bodyAsText().contains("com.cashwu.todo"))
}
第 3 個丟一個帶著祕密的例外,斷言它不會出現在回應裡
@Test
fun `an unexpected exception responds 500 without the message`() = testApplication {
application {
install(ContentNegotiation) { json() }
install(StatusPages) { todoStatusPages() }
routing {
get("/boom") { throw RuntimeException("資料庫密碼是 hunter2") }
}
}
val response = client.get("/boom")
assertEquals(HttpStatusCode.InternalServerError, response.status)
assertFalse(response.bodyAsText().contains("hunter2"))
}
這個測試不走 module(),自己組一條會爆炸的路由,install(ContentNegotiation) 那行是必要的,少了它 respondError() 裡的 respond(status, ErrorResponse(...)) 沒有 converter 可用,回的是 406 不是 500,第 1 次寫的時候就是這樣失敗的
這 3 個測試的價值在於,只驗狀態碼的話,一個把 cause.message 直接回出去的實作也會通過,洩漏這種事沒有斷言就沒有人會發現
第 3 組驗證前面幾節講的行為,2 種 404 各講各的話,而且 exception 那條不會再被 status 那條蓋掉
@Test
fun `the two kinds of not found carry different messages`() = testApplication {
application { module() }
val fromHandler = client.get("/todos/999")
val fromFallback = client.get("/nothing-here")
assertEquals(HttpStatusCode.NotFound, fromHandler.status)
assertEquals(HttpStatusCode.NotFound, fromFallback.status)
assertEquals("找不到 id 999 的待辦", fromHandler.error().message)
assertEquals("找不到這個路徑", fromFallback.error().message)
}
@Test
fun `the status handler does not run again after an exception handler`() = testApplication {
application { module() }
val response = client.get("/todos/999")
assertFalse(response.bodyAsText().contains("找不到這個路徑"))
}
後面這個是 statusPageMarker 那件事的斷言,marker 哪天失效,/todos/999 的訊息就會被 status handler 覆蓋成「找不到這個路徑」
接下來是 exception<Throwable> 那 2 個坑的保險
@Test
fun `a status code the framework already maps keeps that status`() = testApplication {
application { module() }
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Text.Plain)
setBody("""{"title":"倒垃圾"}""")
}
assertEquals(HttpStatusCode.UnsupportedMediaType, response.status)
assertEquals(415, response.error().status)
}
@Test
fun `a cancellation is not swallowed by the catch all`() = testApplication {
application {
install(ContentNegotiation) { json() }
install(StatusPages) { todoStatusPages() }
routing {
get("/cancel") { throw CancellationException("client gone") }
}
}
val body = runCatching { client.get("/cancel").bodyAsText() }.getOrDefault("")
assertFalse(body.contains("伺服器發生未預期的錯誤"))
}
前者拿掉 defaultExceptionStatusCode 那 2 行就會變成 500,後者拿掉 if (cause is CancellationException) throw cause 就會拿到那份不該存在的 500,runCatching 是因為取消丟回去之後 client 那邊拿到的是例外不是回應,測試要斷言的是「沒有人替它寫了一份 500」,不是「拿到什麼」
最後 2 個,一個驗 header 不重複,一個驗 StatusPages 不去碰成功的回應
@Test
fun `the status handler does not add a second timing header`() = testApplication {
application { module() }
val response = client.get("/nothing-here")
assertEquals(1, response.headers.getAll("X-Response-Time")?.size)
}
@Test
fun `a successful response passes through untouched`() = testApplication {
application { module() }
val response = client.get("/todos/1")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains(""""title":"買牛奶""""))
}
上一節 RequestTiming 那一行要驗的就是 size 等於 1 這個數字
測試都通過了,./gradlew run 開起來看實際的回應長什麼樣,先看 2 種 404
curl -i localhost:8080/todos/999
curl -i localhost:8080/nope
HTTP/1.1 404 Not Found
X-Response-Time: 1ms
Content-Length: 66
Content-Type: application/json
{"status":404,"message":"找不到 id 999 的待辦","details":[]}
HTTP/1.1 404 Not Found
X-Response-Time: 0ms
Content-Length: 61
Content-Type: application/json
{"status":404,"message":"找不到這個路徑","details":[]}
同一個狀態碼、同一個形狀,訊息不同,第 2 筆的 X-Response-Time 只有 1 行,是 RequestTiming 那個修法的成果
驗證失敗
curl -i -X POST -H "Content-Type: application/json" \
-d '{"title":" "}' localhost:8080/todos
HTTP/1.1 400 Bad Request
X-Response-Time: 6ms
Content-Length: 84
Content-Type: application/json
{"status":400,"message":"欄位不符合規則","details":["title 不能是空白"]}
400 回來了,請求物件不再被印回去,少帶必填欄位
curl -i -X POST -H "Content-Type: application/json" \
-d '{"done":true}' localhost:8080/todos
HTTP/1.1 400 Bad Request
X-Response-Time: 2ms
Content-Length: 87
Content-Type: application/json
{"status":400,"message":"請求內容不正確","details":["缺少必填欄位 title"]}
把 body 換成 not json 再打一次
curl -i -X POST -H "Content-Type: application/json" \
-d 'not json' localhost:8080/todos
HTTP/1.1 400 Bad Request
X-Response-Time: 1ms
Content-Length: 127
Content-Type: application/json
{"status":400,"message":"請求內容不正確","details":["request body 無法解析,請檢查 JSON 格式與欄位型別"]}
狀態碼一樣是 400、message 一樣是「請求內容不正確」,只有 details 不同,day 13 結尾那 2 個一字不差的訊息在這裡分開了
框架自己對照出來的狀態碼也走同一個形狀
curl -i -X POST -H "Content-Type: text/plain" \
-d '{"title":"倒垃圾"}' localhost:8080/todos
HTTP/1.1 415 Unsupported Media Type
X-Response-Time: 1ms
Content-Length: 62
Content-Type: application/json
{"status":415,"message":"Unsupported Media Type","details":[]}
415 沒有變成 500,defaultExceptionStatusCode 那 2 行守住了
最後看那條沒人接的路,Application.kt 的 routing { } 裡臨時加一條會爆炸的路由
get("/boom") { throw RuntimeException("資料庫密碼是 hunter2") }
curl -i localhost:8080/boom
HTTP/1.1 500 Internal Server Error
X-Response-Time: 14ms
Content-Length: 73
Content-Type: application/json
{"status":500,"message":"伺服器發生未預期的錯誤","details":[]}
伺服器這邊則是
14:37:54.283 [eventLoopGroupProxy-4-2] ERROR io.ktor.server.Application -- Unhandled exception on /boom
java.lang.RuntimeException: 資料庫密碼是 hunter2
at com.cashwu.todo.ApplicationKt$module$4$2.invokeSuspend(Application.kt:51)
後面接著一整份 stack trace,那個 message 是故意寫成這樣的,它留在 log 裡,沒有跟著回應出去
上面那一輪裡面,驗證失敗、少欄位、JSON 壞掉、找不到路由、找不到資料這 5 個請求,伺服器的 ERROR log 是 0 筆,只有 /boom 那 1 筆
這是 day 14 那個「錯誤 log 被灌爆」的問題的另一面,當時 RequestValidationException 不在 logFailure 的名單上,每次驗證失敗都吐一份 ERROR 加 stack trace
現在 StatusPages 把例外接走了,logFailure 根本沒被呼叫,於是連 debug 那一層都沒有,灌爆的問題確實解決了,代價是 client 的錯誤在伺服器端完全沒有痕跡
哪一種比較糟要看情境,一個持續送壞資料進來的 client,一個開始大量回 400 的端點,這些都是該被看見的訊號,而現在看不見,真正該做的是把「回應了什麼」跟「發生了什麼例外」拆成 2 件事分開記,前者每個請求都記一行,後者只在真的出事的時候記,這是 day 16 CallLogging 的工作
Relix 這件事做過 2 次,day 16 的 ErrorHandlingMiddleware 是最小版本,3 個 catch,第 1 個就是把 CancellationException 丟回去,再來才是 RelixHttpException 和 Exception,回的是純文字
day 27 的 StatusPages 才補上 2 張 map、結構化 JSON 跟 status(404),Ktor 這邊等於一次到位,因為 plugin 本來就是完成品
兩邊的 ErrorResponse 幾乎長一樣,status 加 message,Relix 沒有 details 那個欄位,這不是誰想得比較周到,是 Relix 的驗證錯誤走的是 day 22 的 unprocessableEntity(),另一條路、另一個形狀,本來就沒有把驗證結果塞進統一錯誤格式的需求,Ktor 這邊 RequestValidationException 一定會經過 StatusPages,reasons 就得有地方放
ApiException 對應的是 Relix 的 RelixHttpException,2 個都是「帶著狀態碼的例外」,差別在 Relix 那個是框架的一部分,middleware 裡有一條專屬分支處理它,沒註冊映射也會用自己帶的 status code 回應,Ktor 沒有這種東西,ApiException 是我們自己在應用層定義的,忘了註冊 exception<ApiException> 就直接掉到 500,Ktor 的 BadRequestException、NotFoundException 看起來很像 RelixHttpException,但它們沒有 statusCode 這個欄位,對應關係寫死在 defaultExceptionStatusCode 那個 when 裡,加一個自己的例外進去是不可能的
查表的規則兩邊做法不同但結果一樣,Relix 的 findHandler() 先精確 match、找不到再一層一層往父類別走,Ktor 的 findHandlerByValue() 是先把所有 match 的 key 全部撈出來、多於 1 個才呼叫 selectNearestParentClass
差最多的是誰負責 status(...) 那條路,Relix 的 middleware 是自己 try { next() } 之後檢查回傳的 response.statusCode,整條路都在同一個函式裡,讀起來很直觀,Ktor 拆成 2 個 hook 掛在 2 條不同的 pipeline 上,CallFailed 在 application call pipeline,ResponseBodyReadyForSend 在 send pipeline,靠 statusPageMarker 這個共享的 attribute 保證兩邊不會同時動作,Relix 那種寫法不需要 marker,因為 catch 跟 if 天生互斥,Ktor 付出的複雜度換來的是 status(...) 攔得到「任何人送出的任何回應」,包括 Fallback phase 那個 404,Relix 的 middleware 只攔得到穿過它自己那一層的回應
最後一個對照是 Relix day 27 提過的取捨,裝了 StatusPages 就不要再裝 ErrorHandling,2 個 middleware 做同一件事,Ktor 這邊沒有這個問題,因為 handleFailure 不是 plugin,它是 engine pipeline 的一部分,拆不掉也不用拆,StatusPages 沒接到的東西自然落到它手上,這個「後備一定存在」的性質,是框架內建錯誤處理跟自己疊 middleware 的實際差別
StatusPages 3 個 hook 裡負責查表的有 2 個,CallFailed 掛在 Setup 前面的自訂 phase,用 try-catch 罩住整條 application call pipeline 攔例外,ResponseBodyReadyForSend 掛在 send pipeline 的 After phase,攔已經成形的回應狀態碼,兩邊靠 statusPageMarker 互斥,因為 StatusPages 的 catch 在 application.execute() 裡面,engine 的 handleFailure 在外面,前者先接到,後者降級成後備
todo-api 這一輪定出 ErrorResponse 這個統一形狀,驗證失敗從 500 改回 400 而且不再回傳請求物件,BadRequestException 的 cause chain 往下走 3 層拿到 MissingFieldException.missingFields,把 day 13 那個「2 個一模一樣的 400」拆成 2 句不同的話,exception<Throwable> 這道最後防線有 2 個必須自己處理的東西,CancellationException 要丟回去,defaultExceptionStatusCode 要查一次才不會把 415 變成 500,另外 500 的 log 也得自己記,因為 handleFailure 已經不會執行了
StatusPages 一開始要寫的東西是多了一點,ErrorResponse、ApiException、4 個 exception 加 1 個 status,不過做過一次之後就一直重複使用,ErrorHandling.kt 整份複製到其它專案,改掉幾句訊息就能用
錯誤回應統一了,但伺服器的 log 反而變成一片空白,5 個失敗的請求連一行都沒留下,下一篇裝 CallLogging,把「每個請求發生了什麼」記成固定格式的一行,再用 CallId 加 MDC 讓同一個請求在 log 裡串得起來,錯誤發生的時候查得到是誰、什麼時候、打了哪一條路徑
同步刊登於 Blog
圖片來源:AI 產生