Ktor Client の Pipeline を活用したクリーンなエラーハンドリング

この記事は はてなエンジニア Advent Calendar 2025 24日目の記事です。

🎄 Happy Holidays! 2025年も残すところわずかですね。キンダープンシュがおいしい季節です。

ホリデーシーズンの家で、ストーブと Kotlin 言語モチーフのヤカン、クリスマスツリー、クリスマスチキンが写っている、ファンタジー風の画像。


この記事は Kotlin 2.3.0 および Ktor 3.3.3 に準拠しています。また、記事中に挿入しているイラスト画像は Nano Banana Pro により生成しています。

Ktor は JetBrains が開発する Kotlin 向けの HTTP フレームワークです。HTTP サーバーだけでなく、HTTP クライアントも提供されています。Kotlin coroutines を活用した非同期処理や、プラグインシステムによる高い拡張性が特徴です。

HTTP クライアントで通信する際、リクエストのタイムアウトやレスポンスの不整合など、さまざまな例外が発生する可能性があります。

ネットワーク層でこれらの例外をあらかじめドメインエラーに変換しておくと、利用側で実装の詳細を知る必要がなくなり、クリーンなエラーハンドリングが行えます。

今回は Ktor Client の Pipeline という仕組みを利用して、クリーンなエラーハンドリングを実現する方法について紹介します。

ドメインエラーの定義

ドメインエラーを規定する AppError クラスを考えます。このクラスを継承したサブクラスが、ドメインエラーであるとみなすことができます。

sealed class AppError : Exception()

今回は、デバイスがオフラインである場合の NetworkUnavailableError とリクエストがタイムアウトした場合の NetworkTimeoutError について考えることにします。

import kotlinx.io.IOException

class NetworkUnavailableError : AppError()
class NetworkTimeoutError(override val cause: IOException) : AppError()

(NetworkTimeoutErrorcause を受け取るようにしている理由は後述します)

では実際に例外をドメインエラーに変換していきましょう。そこでまずは Ktor Client の Pipeline の概念を紹介します。

Pipeline

Pipeline を利用すると、HTTP クライアントの工程ごとに追加の処理を挟むことができます。いわゆる Middleware のようなものですが、加えて Phase で実行されるタイミングを詳細に制御できます。

記事の中で言及されている Ktor Client の Pipeline を説明するファンタジー風なイラスト画像。

主な Pipeline には、リクエストを組み立て送信の準備をする工程の HttpRequestPipeline、実際に HTTP エンジンがリクエストを送信する工程の HttpSendPipeline、レスポンスを受け取って解釈する工程の HttpResponsePipeline があります。

Phase はそれぞれの Pipeline ごとに定義されていて、例えば HttpRequestPipeline には最優先される Before や、リクエストボディーを OutgoingContent 型に変換する Render などがあります。(HttpRequestPipeline.kt)

多くの Ktor Client プラグインが Pipeline を利用しています。このように、Pipeline は高い拡張性の恩恵をもたらしています。

リクエストの送信前にオンラインかどうか確認する

リクエストが組み立てられて、送信される直前にチェックを行いたいため、HttpSendPipeline を利用します。Phase は KDoc を参考に HttpSendPipeline.Monitoring としてみます。

よって HttpClient DSL は次のように書けます。

import io.ktor.client.HttpClient
import io.ktor.client.request.HttpSendPipeline

val httpClient = HttpClient {
  install("PreRequestCheck") {
    sendPipeline.intercept(HttpSendPipeline.Monitoring) {
      if (!isDeviceOnline()) {
        throw NetworkUnavailableError()
      }

      proceed()
    }
  }
}

expect fun isDeviceOnline(): Boolean

intercept ブロックにある proceed 関数は、パイプラインの実行の継続を指示します。一方、このブロック内で例外をスローすると、パイプラインの実行が中断され、呼び出し元に例外が伝播されます。

Ktor Client がリクエストを組み立てたあと、オンラインならリクエストを送信し、そうでないならドメインエラーである NetworkUnavailableError がスローされます。

val response = try {
  httpClient.get("https://ktor.io")
} catch (e: NetworkUnavailableError) {
  // TODO: デバイスがオフラインだった場合のハンドリング
  throw e
}

ところで isDeviceOnline 関数は、実行環境によってさまざまな実装が考えられそうです。Android 環境では ConnectivityManager を利用するのが手軽そうです。

import android.net.ConnectivityManager
import android.net.NetworkCapabilities

actual fun isDeviceOnline(): Boolean {
  // 説明のため簡略化しています
  // 実用上は ConnectivityManager や Context を DI したり、引数で渡したりすることになるでしょう
  val connectivityManager = getConnectivityManager()
  return connectivityManager.isNetworkAvailable()
}

private fun ConnectivityManager.isNetworkAvailable(): Boolean {
  val network = activeNetwork ?: return false
  val capabilities = getNetworkCapabilities(network) ?: return false

  return listOf(
    NetworkCapabilities.NET_CAPABILITY_INTERNET,
    NetworkCapabilities.NET_CAPABILITY_VALIDATED,
  ).all { capabilities.hasCapability(it) }
}

タイムアウト系の例外を丸める

Ktor Client で発生する可能性のあるタイムアウトは3種類あります。

ktor.io

これらはそれぞれ、次の例外クラスに対応します。

  • io.ktor.client.plugins.HttpRequestTimeoutException
    • リクエストの送信やレスポンスの受信に関するタイムアウトが発生した。
  • io.ktor.client.network.sockets.ConnectTimeoutException
    • リモートサーバーとのコネクションの確立に関するタイムアウトが発生した。
  • io.ktor.client.network.sockets.SocketTimeoutException
    • リモートサーバーとのパケットのやり取りに関するタイムアウトが発生した。

これらのタイムアウトを個別に扱う必要性は低いため、ドメインエラーは単一の NetworkTimeoutError で表し、cause に原因となった元の例外を保持しておくことにします。

では、これらの例外をドメインエラー NetworkTimeoutError に map してみます。組み込みの HttpCallValidator プラグインを使うと、Pipeline を直接操作することなく、リクエストの実行中に発生した例外を加工できます。

import io.ktor.client.HttpClient
import io.ktor.client.network.sockets.ConnectTimeoutException
import io.ktor.client.network.sockets.SocketTimeoutException
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.plugins.HttpResponseValidator

val httpClient = HttpClient {
  HttpResponseValidator {
    handleResponseExceptionWithRequest { cause, _ ->
      when (cause) {
        is HttpRequestTimeoutException, is ConnectTimeoutException, is SocketTimeoutException -> {
          throw NetworkTimeoutError(cause)
        }

        else -> {
          throw cause
        }
      }
    }
  }
}

リクエストの送信、接続の確立、レスポンスの受信などの過程でタイムアウトが発生した場合、NetworkTimeoutError がスローされるようになりました。

val response = try {
  httpClient.get("https://ktor.io")
} catch (e: NetworkTimeoutError) {
  // TODO: リクエストがタイムアウトした場合のハンドリング
  throw e
}

おわり

Pipeline を活用して、Ktor Client で発生する例外をドメインエラーに変換する手法を紹介しました。

次の記事を読んで、まとめにふさわしいイラスト画像。ホリデーシーズンらしい、ファンタジー感が含まれている。

Ktor Client の高い拡張性のおかげで、利用側がネットワーク層の実装詳細を意識する必要がなくなります。これにより、例外ハンドリングをドメインエラーに集中させることができ、例えば UI でのユーザーへのフィードバックなどが実装しやすくなります。

// ViewModel
fun refresh() {
  viewModelScope.launch {
    runCatching {
      repository.fetchData()
    }.onSuccess { data ->
      _state.value = UiState.Success(data)
    }.onFailure { throwable ->
      when (throwable) {
        is NetworkUnavailableError -> {
          _state.value = UiState.Error("インターネット接続がありません!通信状況をご確認ください!")
        }
        is NetworkTimeoutError -> {
          _state.value = UiState.Error("タイムアウトしました!通信状況をご確認ください!")
        }
        else -> {
          throw throwable
        }
      }
    }
  }
}

Ktor Client は HTTP エンジンの実装を自由に切り替えることができ、定番の OkHttp エンジンなどもサポートされているため導入が容易です。

ktor.io

ぜひ実際のプロジェクトでも Ktor Client を試してみてください!


この記事は はてなエンジニア Advent Calendar 2025 24日目の記事でした。

昨日の記事は id:jj1uzh さんの transientを使ってmagitライクなツールを作ってみる でした。明日の担当は id:handat さんです。お楽しみに!