Bagaimanapun hati-hati kita memvalidasi input, exception tetap dapat muncul: database timeout, koneksi Redis putus, atau bug logika internal. Plugin StatusPages Ktor menyatukan penanganan exception ke satu titik sehingga respons error konsisten — tidak ada stack trace yang bocor ke klien. Pada chapter ini kita akan memahami StatusPages, mendaftar handler per tipe exception, dan menyusun fallback untuk kesalahan tak terduga.

Plugin StatusPages #

StatusPages adalah plugin Ktor yang membungkus eksekusi handler dalam try-catch. Saat handler melempar exception, plugin mencari handler terdaftar untuk tipe exception tersebut. Bila tidak ada, plugin memanggil fallback global. Hasilnya: respons HTTP yang seragam walau sumber error berbeda-beda.

kotlin install(StatusPages)
install(StatusPages) {
    exception<NotFoundException> { call, cause ->
        call.respond(HttpStatusCode.NotFound, mapOf("code" to "NOT_FOUND", "message" to cause.message))
    }
    exception<UnauthorizedException> { call, cause ->
        call.respond(HttpStatusCode.Unauthorized, mapOf("code" to "UNAUTHORIZED", "message" to cause.message))
    }
    exception<Throwable> { call, cause ->
        call.respond(HttpStatusCode.InternalServerError, mapOf("code" to "INTERNAL", "message" to "Terjadi kesalahan"))
    }
}

Urutan pendaftaran exception penting: Ktor mencari handler dari tipe paling spesifik ke paling umum. Handler exception<NotFoundException> harus didaftarkan sebelum exception<Throwable> agar tipe spesifik ditangkap lebih dulu.

Custom Exception #

Mendefinisikan hierarki exception sendiri membuat kode lebih ekspresif. Kelas dasar AppException membawa statusCode dan code — sehingga handler generik dapat mengonversi AppException ke respons tanpa branching panjang.

Hierarki Exception #

ExceptionStatus HTTPKapan dipakai
NotFoundException404Resource tidak ada
UnauthorizedException401Token hilang/invalid
ConflictException409Resource duplikat
ValidationException400Input invalid
AppException (base)bervariasiKelas dasar

Fallback Global #

Fallback adalah handler terakhir yang menangkap Throwable — tipe paling umum. Fallback harus selalu hadir agar tidak ada exception yang lolos dan menyebabkan respons 500 tanpa body. Fallback yang baik melakukan logging detail (stack trace), namun hanya mengirim pesan generik ke klien — agar tidak membocorkan detail internal.

Demo StatusPages #

Kita akan memodelkan StatusPages sebagai class Kotlin dengan registry handler. Program ini mendemonstrasikan bagaimana exception yang berbeda menghasilkan respons yang berbeda — namun semua melalui jalur yang sama.

kotlin
// ktor_error_handling.kt
// Mendemonstrasikan plugin StatusPages pada Ktor: memetakan exception
// ke respons HTTP, custom exception handler, dan fallback.

import kotlin.reflect.KClass

// Custom exceptions
open class AppException(val statusCode: Int, val code: String, message: String) : RuntimeException(message)
class NotFoundException(resource: String) : AppException(404, "NOT_FOUND", "Resource $resource tidak ditemukan")
class UnauthorizedException(msg: String = "Token tidak valid") : AppException(401, "UNAUTHORIZED", msg)
class ConflictException(msg: String) : AppException(409, "CONFLICT", msg)

// StatusPages: pasang handler per tipe exception
class StatusPages {
    private val handlers = mutableMapOf<KClass<*>, (AppException) -> Pair<Int, String>>()
    var fallback: ((Throwable) -> Pair<Int, String>)? = null

    @PublishedApi
    internal fun putHandler(k: KClass<*>, h: (AppException) -> Pair<Int, String>) {
        handlers[k] = h
    }

    inline fun <reified T : AppException> exception(noinline handler: (T) -> Pair<Int, String>) {
        @Suppress("UNCHECKED_CAST")
        putHandler(T::class, handler as (AppException) -> Pair<Int, String>)
    }

    fun handleException(ex: Throwable): Pair<Int, String> {
        if (ex is AppException) {
            val handler = handlers[ex::class]
            if (handler != null) return handler(ex)
        }
        return fallback?.invoke(ex)
            ?: (500 to """{"code":"INTERNAL","message":"Terjadi kesalahan internal"}""")
    }
}

// Mock ApplicationCall
class ApplicationCall {
    var responseStatus: Int = 200
    var responseBody: String = ""
    var requestPath: String = ""
}

// Pipeline eksekusi handler
class Application(val statusPages: StatusPages) {
    fun handle(call: ApplicationCall, block: () -> String) {
        try {
            call.responseBody = block()
            call.responseStatus = 200
        } catch (ex: Throwable) {
            val (status, body) = statusPages.handleException(ex)
            call.responseStatus = status
            call.responseBody = body
        }
    }
}

fun main() {
    val statusPages = StatusPages().apply {
        exception<NotFoundException> { ex ->
            404 to """{"code":"${ex.code}","message":"${ex.message}"}"""
        }
        exception<UnauthorizedException> { ex ->
            401 to """{"code":"${ex.code}","message":"${ex.message}"}"""
        }
        exception<ConflictException> { ex ->
            409 to """{"code":"${ex.code}","message":"${ex.message}"}"""
        }
        fallback = { ex ->
            500 to """{"code":"INTERNAL","message":"Kesalahan tidak terduga: ${ex.message}"}"""
        }
    }

    val app = Application(statusPages)

    println("=== Request: berhasil ===")
    val c1 = ApplicationCall().apply { requestPath = "/users/1" }
    app.handle(c1) { """{"id":1,"nama":"Budi"}""" }
    println("Status: ${c1.responseStatus}, Body: ${c1.responseBody}")

    println("\n=== Request: not found ===")
    val c2 = ApplicationCall().apply { requestPath = "/users/999" }
    app.handle(c2) { throw NotFoundException("User dengan id 999") }
    println("Status: ${c2.responseStatus}, Body: ${c2.responseBody}")

    println("\n=== Request: unauthorized ===")
    val c3 = ApplicationCall().apply { requestPath = "/me" }
    app.handle(c3) { throw UnauthorizedException() }
    println("Status: ${c3.responseStatus}, Body: ${c3.responseBody}")

    println("\n=== Request: conflict ===")
    val c4 = ApplicationCall().apply { requestPath = "/users" }
    app.handle(c4) { throw ConflictException("Email sudah dipakai") }
    println("Status: ${c4.responseStatus}, Body: ${c4.responseBody}")

    println("\n=== Request: error internal ===")
    val c5 = ApplicationCall().apply { requestPath = "/pay" }
    app.handle(c5) { error("Koneksi database timeout") }
    println("Status: ${c5.responseStatus}, Body: ${c5.responseBody}")
}

Output yang Dihasilkan:

text
=== Request: berhasil ===
Status: 200, Body: {"id":1,"nama":"Budi"}

=== Request: not found ===
Status: 404, Body: {"code":"NOT_FOUND","message":"Resource User dengan id 999 tidak ditemukan"}

=== Request: unauthorized ===
Status: 401, Body: {"code":"UNAUTHORIZED","message":"Token tidak valid"}

=== Request: conflict ===
Status: 409, Body: {"code":"CONFLICT","message":"Email sudah dipakai"}

=== Request: error internal ===
Status: 500, Body: {"code":"INTERNAL","message":"Kesalahan tidak terduga: Koneksi database timeout"}

Mari kita bedah bagian penting kode di atas:

  1. open class AppException(val statusCode, val code, message): kelas dasar exception yang membawa metadata HTTP. Subclass NotFoundException, UnauthorizedException, ConflictException mengisi nilai ini — sehingga handler generik dapat membaca ex.code tanpa branching.
  2. inline fun <reified T : AppException> exception(handler): mendaftar handler untuk tipe T. reified memungkinkan akses T::class pada runtime. Lambda noinline agar dapat dipakai di luar inline call.
  3. fun handleException(ex: Throwable): Pair<Int, String>: dispatch exception. Bila ex is AppException, cari handler spesifik. Bila tidak ada, panggil fallback.
  4. app.handle(c2) { throw NotFoundException(...) }: simulasi handler yang melempar exception. Lambda ini dipanggil dalam try-catch — exception ditangkap dan diterjemahkan ke respons.
  5. fallback = { ex -> 500 to ... }: fallback terakhir yang menangkap semua Throwable. Pada produksi, di sinilah kita log stack trace penuh ke logger eksternal (Sentry, Datadog).

Pola yang Sering Dipakai #

Beberapa pola StatusPages yang lazim di produksi: mendaftar exception<NotFoundException> untuk resource tidak ditemukan, exception<UnauthorizedException> untuk auth gagal, exception<ConflictException> untuk duplikat, dan exception<Throwable> sebagai catch-all. Pemisahan ini membuat respons konsisten dan logging terstruktur per kategori error.

Referensi Lanjutan #

  • Ktor status pages — panduan resmi plugin StatusPages untuk menangani pengecualian dan memetakannya ke status HTTP

Selanjutnya #

Setelah exception dikelola, langkah berikutnya adalah mengamankan endpoint. Chapter Ktor Authentication akan membahas plugin Authentication dengan Basic auth, JWT, dan Session — tiga mekanisme yang sering dipakai di API modern.