Pipeline data asinkron harus tangguh terhadap kegagalan: network timeout, data korup, atau dependency tidak tersedia. Bagian 3 membahas empat pola error handling yang umum dipakai di produksi: SupervisorJob untuk isolasi, Flow.catch untuk streaming, retryWhen untuk retry dengan kondisi, dan Result<T> untuk expected error.
Empat Pola Error Handling #
| Pola | Tujuan | API Kotlin |
|---|---|---|
| SupervisorJob | Anak yang gagal tidak membatalkan saudara | CoroutineScope(SupervisorJob()) |
| Flow.catch | Tangkap error di tengah stream, emit fallback | flow.catch { e -> emit(...) } |
| retryWhen | Retry dengan kondisi (mis. hanya transient error) | Custom suspend function |
Result<T> | Expected error tanpa exception | runCatching { }, .map, .getOrElse |
Implementasi Lengkap #
Berikut program yang mendemonstrasikan keempat pola:
kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// === Error handling dengan SupervisorJob ===
// Anak yang gagal tidak membatalkan saudaranya.
data class JobResult(val name: String, val status: String, val error: String? = null)
suspend fun prosesStage(nama: String, shouldFail: Boolean): JobResult {
kotlinx.coroutines.delay(20)
return if (shouldFail) {
JobResult(nama, "gagal", "RuntimeException pada $nama")
} else {
JobResult(nama, "sukses")
}
}
// === retryWhen: retry berdasarkan kondisi ===
suspend fun <T> retryWhen(
maxAttempts: Int = 5,
shouldRetry: (Throwable, attempt: Int) -> Boolean,
block: suspend (attempt: Int) -> T
): T {
var attempt = 0
while (true) {
attempt++
try {
return block(attempt)
} catch (e: Throwable) {
if (attempt >= maxAttempts || !shouldRetry(e, attempt)) {
throw e
}
println(" [retry] attempt $attempt gagal: ${e.message}")
kotlinx.coroutines.delay(50L * attempt) // linear backoff
}
}
}
fun main() = runBlocking {
println("=== SupervisorJob: isolasi failure ===")
val results = mutableListOf<JobResult>()
val supervisor = CoroutineScope(SupervisorJob())
val jobs = listOf("fetch-a" to false, "fetch-b" to true, "fetch-c" to false).map { (name, fail) ->
supervisor.async { prosesStage(name, fail) }
}
// awaitAll dengan try-catch per job (SupervisorJob tidak propagate failure)
jobs.forEach { job ->
try {
results.add(job.await())
} catch (e: Throwable) {
results.add(JobResult("unknown", "exception", e.message))
}
}
results.forEach { println(" $it") }
println("\n=== Flow.catch: tangani error di tengah stream ===")
val flow = flow {
emit(1)
emit(2)
throw RuntimeException("error di tengah")
}
flow.catch { e ->
println(" [catch] error: ${e.message}")
emit(-1) // emit fallback
}.collect { v ->
println(" emit: $v")
}
println("\n=== retryWhen: hanya retry untuk transient error ===")
var attemptCount = 0
val hasil = retryWhen(
maxAttempts = 5,
shouldRetry = { e, attempt -> e is RuntimeException && attempt < 3 }
) { attempt ->
println(" [attempt $attempt]")
attemptCount = attempt
if (attempt < 2) throw RuntimeException("transient")
"OK pada attempt $attempt"
}
println("Hasil: $hasil (total attempts: $attemptCount)")
println("\n=== Pola Result<T>: tidak pakai exception untuk control flow ===")
val r: Result<String> = runCatching {
if (attemptCount < 0) throw IllegalStateException("tidak mungkin")
"sukses"
}
println("Result: $r")
println("Map: ${r.map { it.uppercase() }}")
println("GetOrNull: ${r.getOrNull()}")
println("getOrElse: ${r.getOrElse { "default" }}")
println("\nError handling pipeline selesai.")
}Output yang Dihasilkan:
text
=== SupervisorJob: isolasi failure ===
JobResult(name=fetch-a, status=sukses, error=null)
JobResult(name=fetch-b, status=gagal, error=RuntimeException pada fetch-b)
JobResult(name=fetch-c, status=sukses, error=null)
=== Flow.catch: tangani error di tengah stream ===
emit: 1
emit: 2
[catch] error: error di tengah
emit: -1
=== retryWhen: hanya retry untuk transient error ===
[attempt 1]
[retry] attempt 1 gagal: transient
[attempt 2]
Hasil: OK pada attempt 2 (total attempts: 2)
=== Pola Result<T>: tidak pakai exception untuk control flow ===
Result: Success(sukses)
Map: Success(SUKSES)
GetOrNull: sukses
getOrElse: sukses
Error handling pipeline selesai.Mari kita bedah empat pola di atas:
CoroutineScope(SupervisorJob()): scope di mana child failure tidak propagate ke sibling. Tanpa SupervisorJob, satuasyncyang gagal akan membatalkan semua sibling. Cocok untuk pipeline yang menerima kegagalan parsial.flow.catch { e -> emit(-1) }: operatorcatchpada Flow menangkap exception upstream dan mengubahnya menjadi emit downstream. Alternatif untuk fallback value tanpa menghentikan stream.retryWhen { e, attempt -> ... }: custom suspend function yang menerima predicate. Predicate menentukan apakah error tertentu layak retry. Contoh: retry hanya untukIOException(transient), langsung gagal untukIllegalArgumentException(programmer error).runCatching { }.map { }: Result monad — bungkus hasil dalam container yang menampung baik value maupun error. Cocok untuk expected error (mis. parsing input). Berbeda dengan exception, Result tidak menggangu control flow.
Best Practice #
- Transient vs permanent error: retry hanya untuk transient (network, timeout). Jangan retry permanent (validation error, 4xx HTTP).
- Backoff: selalu gunakan delay antara retry untuk menghindari overwhelming downstream. Eksponensial lebih disukai.
- Circuit breaker: jika banyak retry gagal berturut-turut, hentikan mencoba untuk sementara (cooldown). Library
resilience4jmenyediakan ini. - Dead letter queue: untuk pipeline message, simpan pesan yang gagal ke queue terpisah untuk investigasi manual.
- Observability: log setiap retry, error, dan fallback. Metrik penting: retry_count, error_rate, p99_latency.
Referensi Lanjutan #
- Kotlin coroutines design
— dokumentasi desain coroutines untuk
try/catchdanretrypada pipeline asinkron
Selanjutnya #
Pada Bagian 4 (terakhir), kita akan menulis pengujian untuk pipeline asinkron ini dan mengukur performanya dengan metrik throughput dan latency.