iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 27 篇

Kotlin 手刻 Ktor 從零開始 Day 27 Status Pages,統一的錯誤回應處理

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

第 16 篇做了 ErrorHandlingMiddleware,把 exception 轉成 500 或 RelixHttpException 對應的 status code,那是最小版本,真實應用需要更多

  • 不同 exception 對應到不同 status code,不是全部擠成 500
  • 應用層自己定義的 exception,框架不認識也要能映射
  • router 找不到路由回的 404,也要能統一輸出
  • 錯誤回應還是純文字,要升級成固定的結構化格式,client 才不用自己 parse

這篇引入 StatusPages Plugin,把錯誤處理升級成「可客製的映射」

目標 API

做完之後,一個應用的錯誤映射會集中在同一個地方

app.install(StatusPages) {
    exception<NotFoundException> { cause ->
        errorResponse(404, cause.message ?: "Not found")
    }
    exception<ValidationException> { cause ->
        errorResponse(422, cause.message ?: "Validation failed")
    }
    status(404) {
        errorResponse(404, "Resource not found")
    }
}

exception<T> 管的是「有例外被丟出來」,status(code) 管的是「回應的狀態碼長這樣」,兩個是不同的入口,最後一輪會看到它們接到的 404 根本不是同一種 404

錯誤處理分成四個部分

跟第 23、24 篇一樣由內往外疊,每個部分都能單獨測

  • errorResponse() 只負責把 status 跟 message 變成一份 JSON,不碰 exception 也不碰 pipeline
  • StatusPagesConfig 跟 findHandler() 是兩張 map 的註冊與查表,決定一個 exception 該由誰接走
  • statusPagesMiddleware() 把查表接到 pipeline 上,處理三條路徑,其中沒人接的那條就回 500,這個部分開始要有 RelixCall,但還不用起 app
  • StatusPages Plugin 把 middleware 裝進 application,router 找不到路由的那個 404 要到這個部分才走得到

程式碼新增一個檔案 StatusPages.kt,全部的實作全部寫在裡面,另外 main.kt 最上面會多一個 ValidationException,它是應用層自己的例外,不屬於框架,測試跟 main() 都用得到

測試四輪各一個檔案,ErrorResponseTest.kt、StatusPagesConfigTest.kt、StatusPagesMiddlewareTest.kt、StatusPagesTest.kt

裝了 StatusPages 之後就不要再裝 ErrorHandling (第 16 篇的 middleware,第 18 篇包成 Plugin),兩個 middleware 做的是同一件事,理由留到最後的取捨那節

TDD 先確認 errorResponse() 的輸出格式

最裡面這個部分是 status code 加訊息進去、一份 JSON 出來,不需要 exception、不需要 pipeline,測試檔案放 ErrorResponseTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals

class ErrorResponseTest {

    private fun emptyCall(): RelixCall {
        val app = RelixApplication()
        val request = RelixRequest(
            method = "GET",
            path = "/",
            headers = emptyMap(),
            queryParameters = emptyMap(),
        )
        return RelixCall(app, request)
    }

    @Test
    fun `the status code goes into both the response and the body`() {
        val response = emptyCall().errorResponse(404, "User not found")

        assertEquals(404, response.statusCode)
        assertEquals(
            """{"status":404,"message":"User not found"}""",
            response.body.toString(Charsets.UTF_8),
        )
    }

    @Test
    fun `the content type says json`() {
        val response = emptyCall().errorResponse(500, "Internal Server Error")

        assertEquals(
            "application/json; charset=utf-8",
            response.headers["Content-Type"]?.firstOrNull(),
        )
    }

    @Test
    fun `quotes in the message are escaped`() {
        val response = emptyCall().errorResponse(422, """name "id" is required""")

        assertEquals(
            """{"status":422,"message":"name \"id\" is required"}""",
            response.body.toString(Charsets.UTF_8),
        )
    }

    @Test
    fun `a newline in the message stays inside the string`() {
        val response = emptyCall().errorResponse(500, "line1\nline2")

        assertEquals(
            """{"status":500,"message":"line1\nline2"}""",
            response.body.toString(Charsets.UTF_8),
        )
    }
}

第一個測試驗證兩件事,status 同時要出現在回應的狀態碼跟 body 裡面,client 只讀 body 也知道發生什麼事

emptyCall() 跟第 23 篇那個 helper 同一招,errorResponse() 掛在 RelixCall 上,但它一個 request 欄位都沒讀,所以組一個空的 call 就夠了,headers 跟 queryParameters 明確填成 emptyMap(),第 04 篇的 RelixRequest 這兩個欄位沒有預設值

後面兩個測試是跳脫,訊息裡的引號跟換行都可能來自 exception message,一旦沒跳脫,回出去的就不是合法 JSON,client 那邊 parse 直接爆掉。這兩個測試同時也是在確認一件事,這裡用的是 Json.encodeToString() 而不是自己拼字串

實作 errorResponse()

新增 StatusPages.kt

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class ErrorResponse(
    val status: Int,
    val message: String,
)

fun RelixCall.errorResponse(status: Int, message: String): RelixResponse {
    val body = Json.encodeToString(ErrorResponse(status, message))
    return RelixResponse(
        status,
        mapOf("Content-Type" to listOf("application/json; charset=utf-8")),
        body.toByteArray(),
    )
}

錯誤輸出固定格式

{
  "status": 404,
  "message": "User not found"
}

所有錯誤都走這個格式,client 不用猜回應長什麼樣子,如果 Content Negotiation 有安裝,也可以走 ok(ErrorResponse(...)) 讓框架自動選 converter,但目前這樣直接 encode 比較簡單

這個 errorResponse() 取代第 16 篇那個檔案級的 private 版本,那時候 JSON 序列化還沒實作,錯誤回應是純文字,現在有 kotlinx.serialization (第 20 篇) 可以用,直接 encode 一個 @Serializable 的 data class 就好,不用自己處理引號跟跳脫,它也從 private 函式改成 RelixCall 的 extension function,因為 StatusPages 的 handler receiver 就是 RelixCall,這樣註冊 handler 的時候才呼叫得到

TDD 先確認 exception 映射的查表規則

第二個部分要回答的是,exception<T> { } 註冊進去之後,一個 exception 丟出來要怎麼找到對的 handler

在寫測試之前先補一個應用層的例外,它不屬於框架,寫在 main.kt 的最上面就好,main() 跟測試都看得到

class ValidationException(message: String) : RuntimeException(message)

它刻意不繼承 RelixHttpException,用來示範「框架不認識的例外也能被映射」,NotFoundException 則是第 16 篇已經定義過的,繼承 RelixHttpException,直接沿用

這個部分還是不用起 app,測試檔案放 StatusPagesConfigTest.kt

import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

class StatusPagesConfigTest {

    private fun emptyCall(): RelixCall {
        val app = RelixApplication()
        val request = RelixRequest(
            method = "GET",
            path = "/",
            headers = emptyMap(),
            queryParameters = emptyMap(),
        )
        return RelixCall(app, request)
    }

    private fun statusOf(
        handler: (suspend RelixCall.(Throwable) -> RelixResponse)?,
        cause: Throwable,
    ): Int? = handler?.let { runBlocking { it.invoke(emptyCall(), cause) }.statusCode }

    @Test
    fun `exception registers the handler under its own class`() {
        val config = StatusPagesConfig()
        config.exception<NotFoundException> { errorResponse(404, "not found") }

        assertNotNull(config.exceptionHandlers[NotFoundException::class])
    }

    @Test
    fun `status registers the handler under the code`() {
        val config = StatusPagesConfig()
        config.status(404) { errorResponse(404, "Resource not found") }

        assertNotNull(config.statusHandlers[404])
    }

    @Test
    fun `an exact class match wins over the parent class`() {
        val config = StatusPagesConfig()
        config.exception<RuntimeException> { errorResponse(500, "runtime") }
        config.exception<ValidationException> { errorResponse(422, "validation") }

        val handler = findHandler(config.exceptionHandlers, ValidationException("bad"))

        assertEquals(422, statusOf(handler, ValidationException("bad")))
    }

    @Test
    fun `an unregistered exception falls back to its parent class`() {
        val config = StatusPagesConfig()
        config.exception<RuntimeException> { errorResponse(500, "runtime") }

        val handler = findHandler(config.exceptionHandlers, IllegalStateException("boom"))

        assertEquals(500, statusOf(handler, IllegalStateException("boom")))
    }

    @Test
    fun `nothing registered means no handler`() {
        val config = StatusPagesConfig()
        config.exception<ValidationException> { errorResponse(422, "validation") }

        assertNull(findHandler(config.exceptionHandlers, IllegalStateException("boom")))
    }

    @Test
    fun `among parent classes the first one registered wins`() {
        val config = StatusPagesConfig()
        config.exception<RuntimeException> { errorResponse(400, "runtime") }
        config.exception<Exception> { errorResponse(500, "exception") }

        val handler = findHandler(config.exceptionHandlers, IllegalStateException("boom"))

        assertEquals(400, statusOf(handler, IllegalStateException("boom")))
    }

    @Test
    fun `registering the parent class first swallows the more specific one`() {
        val config = StatusPagesConfig()
        config.exception<Exception> { errorResponse(500, "exception") }
        config.exception<RuntimeException> { errorResponse(400, "runtime") }

        val handler = findHandler(config.exceptionHandlers, IllegalStateException("boom"))

        assertEquals(500, statusOf(handler, IllegalStateException("boom")))
    }
}

statusOf() 這個 helper 是為了讓後面幾個測試讀起來像在問「誰接走了」,直接比對 handler 本身也做得到,但斷言會變成兩個 lambda 的位址相等,看不出哪一個才是對的,改成把找到的 handler 真的叫一次、看它回什麼狀態碼,測試名稱跟斷言就對得上了

前兩個測試是註冊本身,exception<T> 用 exception 的 KClass 當 key,status(code) 用狀態碼當 key,兩張 map 各走各的

中間三個是查表的三種結果,精確 match、退到父類別、完全找不到,第三個是關鍵,findHandler() 找不到不是回一個預設 handler,而是回 null,把「找不到要怎麼辦」這個決定留給下一個部分的 middleware

最後兩個測試是同一件事的兩面,兩個 handler 都能接同一個 exception 的時候,先註冊的那個贏。registering the parent class first swallows the more specific one 這個測試名稱看起來像在描述 bug,它就是在描述 bug,只是這個行為是刻意留下來的,理由寫在下一節

實作 StatusPagesConfig 與 findHandler()

補進 StatusPages.kt

class StatusPagesConfig {
    @PublishedApi
    internal val exceptionHandlers =
        mutableMapOf<kotlin.reflect.KClass<*>, suspend RelixCall.(Throwable) -> RelixResponse>()

    internal val statusHandlers = mutableMapOf<Int, suspend RelixCall.() -> RelixResponse>()

    inline fun <reified T : Throwable> exception(
        noinline handler: suspend RelixCall.(T) -> RelixResponse,
    ) {
        @Suppress("UNCHECKED_CAST")
        exceptionHandlers[T::class] = handler as suspend RelixCall.(Throwable) -> RelixResponse
    }

    fun status(code: Int, handler: suspend RelixCall.() -> RelixResponse) {
        statusHandlers[code] = handler
    }
}

internal fun findHandler(
    handlers: Map<kotlin.reflect.KClass<*>, suspend RelixCall.(Throwable) -> RelixResponse>,
    exception: Throwable,
): (suspend RelixCall.(Throwable) -> RelixResponse)? {
    // 精確 match
    handlers[exception::class]?.let { return it }

    // 往父類別找
    for ((klass, handler) in handlers) {
        if (klass.java.isAssignableFrom(exception::class.java)) {
            return handler
        }
    }
    return null
}

exception<NotFoundException> { cause -> ... } 把 handler 存進 map,key 是 exception 的 KClass,status(404) { ... } 存另一個 map,key 是 status code,handler 的 receiver 是 RelixCall,所以裡面可以用剛剛做好的 errorResponse()

exception() 是 inline + reified,才拿得到 T::class 這個實際型別,代價是它會被 inline 到呼叫端,而呼叫端在框架外面,所以 exceptionHandlers 不能是 private,@PublishedApi internal 就是為了這件事,讓 inline 函式引用得到,同時不變成公開 API

findHandler() 寫成 internal 而不是 private,是因為 Kotlin 的 top-level private 是檔案級的,測試檔在另一個檔案裡就呼叫不到

往父類別找的迴圈有個細節要注意,它走的是 handlers 的 iteration order,也就是註冊順序,第一個 isAssignableFrom 通過的 handler 就會被拿來用,前面最後那兩個測試測的就是這件事,同時註冊 exception<RuntimeException> 跟 exception<Exception>,前者註冊在前才會優先被選,順序反了的話所有 RuntimeException 都會被 Exception 的 handler 接走

正式的 framework 通常會計算 hierarchy distance,沿繼承鏈找「最近的祖先」,才不需要靠註冊順序。這裡接受這個限制,把精確的 exception handler 註冊在前面就好

TDD 先確認 middleware 的三條路

前面兩個部分都還沒碰到 pipeline,這輪要測的是 middleware 怎麼把它們接起來,next() 正常回來、丟 RelixHttpException、丟其他 Throwable,三條路各自的結果

middleware 的型別是第 14 篇那個帶 suspend 的 RelixMiddleware,測試可以直接把它當函式呼叫,自己傳一個 lambda 扮演 next,外面包一層 runBlocking,一樣不用起 app,測試檔案放 StatusPagesMiddlewareTest.kt

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class StatusPagesMiddlewareTest {

    private fun emptyCall(): RelixCall {
        val app = RelixApplication()
        val request = RelixRequest(
            method = "GET",
            path = "/",
            headers = emptyMap(),
            queryParameters = emptyMap(),
        )
        return RelixCall(app, request)
    }

    private fun config(): StatusPagesConfig = StatusPagesConfig().apply {
        exception<NotFoundException> { cause -> errorResponse(404, cause.message ?: "Not found") }
        exception<ValidationException> { cause -> errorResponse(422, cause.message ?: "Validation failed") }
        status(404) { errorResponse(404, "Resource not found") }
    }

    private fun respond(
        development: Boolean = false,
        next: Next,
    ): RelixResponse = runBlocking {
        statusPagesMiddleware(config(), development).invoke(emptyCall(), next)
    }

    @Test
    fun `a mapped exception becomes the mapped status`() {
        val response = respond { throw NotFoundException("User not found") }

        assertEquals(404, response.statusCode)
        assertTrue(response.body.toString(Charsets.UTF_8).contains("User not found"))
    }

    @Test
    fun `an application exception the framework does not know about is mapped too`() {
        val response = respond { throw ValidationException("Name is required") }

        assertEquals(422, response.statusCode)
    }

    @Test
    fun `an unmapped RelixHttpException keeps its own status code`() {
        val response = respond { throw RelixHttpException(401, "Not authenticated") }

        assertEquals(401, response.statusCode)
        assertTrue(response.body.toString(Charsets.UTF_8).contains("Not authenticated"))
    }

    @Test
    fun `an unmapped exception falls back to 500 without leaking the message`() {
        val response = respond { throw RuntimeException("unexpected bug") }

        assertEquals(500, response.statusCode)
        val body = response.body.toString(Charsets.UTF_8)
        assertFalse(body.contains("unexpected bug"))
        assertTrue(body.contains("Internal Server Error"))
    }

    @Test
    fun `the same exception shows its message in development`() {
        val response = respond(development = true) { throw RuntimeException("unexpected bug") }

        assertEquals(500, response.statusCode)
        assertTrue(response.body.toString(Charsets.UTF_8).contains("unexpected bug"))
    }

    @Test
    fun `an exception without a message falls back to the class name in development`() {
        val response = respond(development = true) { throw RuntimeException() }

        assertTrue(response.body.toString(Charsets.UTF_8).contains("RuntimeException"))
    }

    @Test
    fun `a cancellation is rethrown instead of turning into 500`() {
        assertFailsWith<CancellationException> {
            respond { throw CancellationException("client gone") }
        }
    }

    @Test
    fun `a normal response whose status is mapped gets replaced`() {
        val response = respond { RelixResponse(404, emptyMap(), "Not Found".toByteArray()) }

        assertEquals(404, response.statusCode)
        assertTrue(response.body.toString(Charsets.UTF_8).contains("Resource not found"))
    }

    @Test
    fun `a normal response whose status is not mapped passes through untouched`() {
        val response = respond { ok("fine") }

        assertEquals(200, response.statusCode)
        assertEquals("fine", response.body.toString(Charsets.UTF_8))
    }
}

respond() 把「組一個 call、跑一次 middleware、拿回 response」這三件事包成一個 helper,每個測試就只剩下 next 要做什麼跟斷言

前三個是 exception 的三種來源,框架認識的 NotFoundException、應用層自己的 ValidationException、沒註冊映射的 RelixHttpException,第三個是 RelixHttpException 那條分支存在的理由,沒有映射的時候它不會掉到 500,因為它自己就帶著 status code

an unmapped exception falls back to 500 without leaking the message 跟 the same exception shows its message in development 這兩個是對照組,同一個 exception,production 回 "Internal Server Error",development 回 "unexpected bug",模式決定要不要洩漏細節。assertFalse(body.contains("unexpected bug")) 這行是重點,只驗 500 的話,一個把 message 直接回出去的實作也會通過

an exception without a message falls back to the class name in development 補的是 message 是 null 的那種 exception,dev 模式至少要講得出是哪一個類別,這也正好是第 16 篇 development 模式的全部行為

a cancellation is rethrown instead of turning into 500 定住的是 coroutine 的取消不算錯誤,client 斷線的時候 coroutine 會被取消,這個 exception 要讓它往外走,攔下來變成 500 只會在 log 裡製造一堆假的錯誤

最後兩個是沒有 exception 的那條路,狀態碼有映射就換掉,沒映射就原封不動穿過去

實作 statusPagesMiddleware()

補進 StatusPages.kt

fun statusPagesMiddleware(
    config: StatusPagesConfig,
    development: Boolean,
): RelixMiddleware {
    return { next ->
        try {
            val response = next()

            // 檢查 status code 映射
            val statusHandler = config.statusHandlers[response.statusCode]
            if (statusHandler != null) {
                statusHandler.invoke(this)
            } else {
                response
            }
        } catch (e: RelixHttpException) {
            // RelixHttpException 先找 exception 映射,沒有就用它自己帶的 status code
            val handler = findHandler(config.exceptionHandlers, e)
            if (handler != null) {
                handler.invoke(this, e)
            } else {
                errorResponse(e.statusCode, e.message ?: "Error")
            }
        } catch (e: Throwable) {
            if (e is java.util.concurrent.CancellationException) throw e
            // 找 exception 映射
            val handler = findHandler(config.exceptionHandlers, e)
            if (handler != null) {
                handler.invoke(this, e)
            } else {
                // 都沒有就回 500
                val message = if (development) {
                    e.message ?: e::class.simpleName ?: "Unknown error"
                } else {
                    "Internal Server Error"
                }
                errorResponse(500, message)
            }
        }
    }
}

三條路對到的就是前面的三組測試

  1. 正常 response → 檢查 status code 是否有映射 (例如 404 → 統一格式)
  2. RelixHttpException → 找 exception 映射,沒有就用它自己的 status code 加 message
  3. 其他 Throwable → 找 exception 映射,都沒有就回 500

兩個 catch 的順序不能反過來,RelixHttpException 也是 Throwable,寫在後面的話它永遠進不去自己那條分支,assertEquals(401, response.statusCode) 那個測試會變成 500

catch (e: Throwable) 第一行就把 CancellationException 丟回去,這是 coroutine 的規則,取消不是錯誤,實作 catch 的是 java.util.concurrent.CancellationException,測試 import 的是 kotlinx.coroutines.CancellationException,兩個在 JVM 上是同一個東西,後者是前者的 typealias,寫哪一個都攔得到

這個 500 根據 development flag 決定要不要洩漏 exception message,production 一律回 "Internal Server Error"

TDD 把 StatusPages 接到 app 上

前面三個部分都沒起過 app,Plugin 還不存在,middleware 也還沒被裝進去,現在要測的正是這段,install(StatusPages) { } 有沒有真的把 middleware 註冊進 pipeline、development 有沒有從 RelixConfig 讀對、router 的 404 有沒有走到 status(404),這條路徑要完整走一遍才知道,測試檔案放 StatusPagesTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class StatusPagesTest {

    private fun createApp(development: Boolean = false): RelixApplication {
        val app = RelixApplication(RelixConfig().apply { this.development = development })
        app.install(StatusPages) {
            exception<NotFoundException> { cause ->
                errorResponse(404, cause.message ?: "Not found")
            }
            exception<ValidationException> { cause ->
                errorResponse(422, cause.message ?: "Validation failed")
            }
            status(404) {
                errorResponse(404, "Resource not found")
            }
        }
        return app
    }

    @Test
    fun `a handler exception comes back as the mapped status`() {
        val app = createApp()
        app.routing {
            get("/users/{id}") { throw NotFoundException("User not found") }
        }

        val response = RelixTestKit(app).handleRequest("GET", "/users/999")

        assertEquals(404, response.statusCode)
        assertTrue(response.body.toString(Charsets.UTF_8).contains("User not found"))
    }

    @Test
    fun `an application exception comes back as 422`() {
        val app = createApp()
        app.routing {
            post("/users") { throw ValidationException("Name is required") }
        }

        val response = RelixTestKit(app).handleRequest("POST", "/users")

        assertEquals(422, response.statusCode)
    }

    @Test
    fun `a router 404 goes through the status mapping`() {
        val app = createApp()
        app.routing {
            get("/hello") { ok("hi") }
        }

        val response = RelixTestKit(app).handleRequest("GET", "/nonexistent")

        assertEquals(404, response.statusCode)
        assertTrue(response.body.toString(Charsets.UTF_8).contains("Resource not found"))
    }

    @Test
    fun `the two kinds of 404 carry different messages`() {
        val app = createApp()
        app.routing {
            get("/users/{id}") { throw NotFoundException("User not found") }
        }

        val testKit = RelixTestKit(app)
        val fromException = testKit.handleRequest("GET", "/users/999")
        val fromRouter = testKit.handleRequest("GET", "/nonexistent")

        assertEquals(404, fromException.statusCode)
        assertEquals(404, fromRouter.statusCode)
        assertTrue(fromException.body.toString(Charsets.UTF_8).contains("User not found"))
        assertTrue(fromRouter.body.toString(Charsets.UTF_8).contains("Resource not found"))
    }

    @Test
    fun `development mode reaches the fallback through the whole pipeline`() {
        val app = createApp(development = true)
        app.routing {
            get("/crash") { throw RuntimeException("unexpected bug") }
        }

        val response = RelixTestKit(app).handleRequest("GET", "/crash")

        assertEquals(500, response.statusCode)
        assertTrue(response.body.toString(Charsets.UTF_8).contains("unexpected bug"))
    }

    @Test
    fun `a normal route is untouched`() {
        val app = createApp()
        app.routing {
            get("/ok") { ok("fine") }
        }

        val response = RelixTestKit(app).handleRequest("GET", "/ok")

        assertEquals(200, response.statusCode)
        assertEquals("fine", response.body.toString(Charsets.UTF_8))
    }
}

createApp() 裡的 install(StatusPages) { } 就是這篇開頭那份目標 API,RelixConfig 是第 25 篇的,development 從建構子傳進去,Plugin 安裝的時候才讀得到

這裡沒有裝 ContentNegotiation,因為 errorResponse() 是自己 encode 的,StatusPages 這條路從頭到尾不經過 converter

a router 404 goes through the status mapping 是前三輪測不到的那條路,/nonexistent 沒有任何 handler,也沒有任何 exception 被丟出來,是 router 回了一個 404 response,然後被 status(404) 換掉。middleware 那輪雖然也有一個狀態碼映射的測試,但那個 404 是測試自己捏出來的,這裡才是真的從 router 出來的

the two kinds of 404 carry different messages 是這篇的重點,同一個狀態碼、兩條完全不同的路,一條走 exception<NotFoundException>、一條走 status(404),把「例外映射」跟「狀態碼攔截」的差別直接寫成斷言

development mode reaches the fallback through the whole pipeline 跟 middleware 那輪的 dev 測試看起來重複,差別在它驗的是 development 這個值真的從 RelixConfig 一路傳到 middleware,不是測試自己傳的參數。這條線斷掉的話,production 的機器會開始回 exception message

實作 StatusPages Plugin

補進 StatusPages.kt,這是最後一段

object StatusPages : RelixPlugin<StatusPagesConfig> {
    override fun createDefaultConfig() = StatusPagesConfig()

    override fun install(
        application: RelixApplication,
        config: StatusPagesConfig,
    ) {
        val development = application.config.development
        application.use(statusPagesMiddleware(config, development))
    }
}

install(StatusPages) { ... } 建立 config、跑使用者的 lambda、註冊 middleware,這三件事是第 18 篇 install() 做的,Plugin 這裡只負責最後那一步,development 在安裝的當下讀一次,之後改 config.development 不會影響已經裝好的 middleware

安全考量

不該回給 client 的資訊

  • Stack trace (會洩漏內部程式碼結構)
  • 檔案路徑 (/app/src/main/kotlin/...)
  • SQL 語句 (SELECT * FROM users WHERE...)
  • 內部服務位址 (http://10.0.0.5:3306)

production 模式的 500 只回 "Internal Server Error",詳細資訊寫進 server log,如果應用自己做了 request ID,log 跟回應應該要各放一份,查問題的時候兩邊才對得上,第 15 篇的 logging middleware 沒有做這件事,需要自己補上

development 模式可以回 exception message,方便開發時 debug,但永遠不回 stack trace 到 response body,即使是 dev 模式,stack trace 也只寫 log

第 16 篇的 development 模式只回 exception 的類別名稱,連 message 都不外流,因為它是框架的最終處理,不知道 message 裡被塞了什麼,StatusPages 不一樣,映射是應用層自己註冊的,哪個 exception 回什麼訊息由你決定,最後那條路只剩下沒註冊的 exception,這種時候回 message 對 debug 的幫助最大,production 模式兩篇的行為則是一致的,都只回固定訊息

在 main 裡組起來跑一次

三種錯誤來源裝在同一個 app 裡,打一遍就知道各自被誰接走,ValidationException 在第二輪已經加到 main.kt 最上面了,這裡不用再寫一次

fun main() {
    val app = Relix {
        development = false
    }

    app.install(Logging) {
        logger = ConsoleLogger()
    }
    app.install(StatusPages) {
        exception<NotFoundException> { cause ->
            errorResponse(404, cause.message ?: "Not found")
        }
        exception<ValidationException> { cause ->
            errorResponse(422, cause.message ?: "Validation failed")
        }
        status(404) {
            errorResponse(404, "Resource not found")
        }
    }

    app.routing {
        get("/users/{id}") { throw NotFoundException("User not found") }
        get("/orders") { throw ValidationException("amount must be positive") }
        get("/ok") { ok("fine") }
    }

    app.start()
}

框架認識的例外

curl -i localhost:8080/users/999
HTTP/1.1 404 Not Found
Content-type: application/json; charset=utf-8
Content-length: 41

{"status":404,"message":"User not found"}

header 名稱印出來是 Content-type 不是程式裡寫的 Content-Type,這是 JDK HttpServer 的 Headers 在做的正規化,第 23、24 篇都遇過同一件事

框架不認識的例外

curl -i localhost:8080/orders
HTTP/1.1 422
Content-type: application/json; charset=utf-8
Content-length: 50

{"status":422,"message":"amount must be positive"}

狀態列只有 422 這個數字,沒有 Unprocessable Entity,理由第 22 篇講過,JDK HttpServer 認不得的狀態碼就不補 reason phrase

ValidationException 是應用層自己定義的,繼承的是 RuntimeException,跟 RelixHttpException 一點關係都沒有。框架不需要認識它,只要在 exception<ValidationException> { } 註冊過,就能映射成 422

根本沒有這個路由

curl -i localhost:8080/nope
HTTP/1.1 404 Not Found
Content-type: application/json; charset=utf-8
Content-length: 45

{"status":404,"message":"Resource not found"}

這一筆跟第一筆都是 404 但訊息不一樣,就是第四輪 the two kinds of 404 carry different messages 那個測試在真實 server 上的樣子

正常的路由

curl -i localhost:8080/ok
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 4

fine

沒有 exception,status 也不是 404,StatusPages 完全不介入,回應原封不動穿過去

log 四筆

[Relix] GET /users/999 -> 404 (9ms)
[Relix] GET /orders -> 422 (0ms)
[Relix] GET /nope -> 404 (1ms)
[Relix] GET /ok -> 200 (0ms)

第一筆的 9ms 是 JVM 的暖機,throw 加上 findHandler() 的 reflection 查表跟 Json.encodeToString() 都是第一次跑,還沒被 JIT 編譯,後面三筆走同一段路就掉到 0 到 1ms

這裡的 development 設成 false,所以沒註冊映射的例外一律回 "Internal Server Error",想看 dev 模式的差別,把 development 改成 true,再自己加一條 get("/crash") { throw RuntimeException("unexpected bug") } 打打看,就會看到前面「安全考量」那節講的 dev 跟 production 的差別

常見陷阱與設計取捨

StatusPages 取代 ErrorHandling 還是建立在之上 ?

這裡是讓 StatusPages 取代第 16 篇的 ErrorHandlingMiddleware,兩個 middleware 做同樣的事 (catch exception → 轉 response),留兩個只會讓行為變難預測 (誰先 catch ?),基本上安裝 StatusPages 就不需要再裝 ErrorHandling

status mapping 會不會攔到不該攔的 ?

status(404) 攔的是「回應的狀態碼是 404」,包括 router 找不到路由的 404,也包括 handler 自己 return 一個 404 response 的情況,但它攔不到 exception 那條路,NotFoundException 沒有註冊映射的時候走的是 errorResponse(e.statusCode, ...),不會再回頭過一次 status handler,如果你只想攔 router 的 404,可以在 handler 回 404 時改用別的狀態碼 (例如 410 Gone),或者不註冊 status(404)

為什麼查表要分兩張 map ?

因為它們的觸發條件不同,一個是「有東西被丟出來」,一個是「回應長這樣」,合成一張表的話 status(404) 跟 exception<NotFoundException> 就分不出誰該先跑,分開之後 middleware 的 try 跟 catch 各查各的,這也是第四輪那個「兩種 404」測試成立的前提


小結

StatusPages 用兩張 map 做映射,exception class → handler、status code → handler,findHandler() 先精確 match 再往父類別找,找不到就回 null,讓 middleware 決定找不到要怎麼辦,沒人接的那條回 500,production 只回固定訊息,development 才放 exception message 出去

errorResponse() 把第 16 篇的純文字錯誤回應升級成統一的 JSON 格式,讓所有錯誤都走同一個形狀


下一篇

下一篇會把 TestKit 收斂成 TestRelixApplication,穩定測試 DSL,加上 request builder 和 JSON assertions


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 26 Dependency Injection,極簡的服務容器
下一篇
Kotlin 手刻 Ktor 從零開始 Day 28 Testing Utilities,框架內建的測試工具
系列文
Kotlin 手刻 Ktor 從零開始 共 32 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言