遅い接続向けKotlinネットワーキング:タイムアウトと安全な再試行
遅い接続向けの実践的なKotlinネットワーキング:タイムアウト設定、モバイルで安全なキャッシュ、重複を作らない再試行、そして不安定な回線で重要操作を守る方法。

遅い・不安定な接続で何が壊れるか
モバイルでは「遅い」は必ずしも「接続なし」を意味しません。多くは断続的にしか動かない接続です。リクエストが8〜20秒かかり、途中で止まってから完了することがあります。ある瞬間に成功して、次の瞬間に失敗することもあります(Wi‑Fi から LTE に切り替わる、電波が弱い場所に入る、OS がアプリをバックグラウンドにする、など)。
「不安定」だとさらに悪化します。パケットが落ち、DNS がタイムアウトし、TLS ハンドシェイクが失敗し、接続がランダムにリセットされます。コード上で「正しく」書いていても、現場ではネットワークが常に変化するため失敗が発生します。
ここでライブラリのデフォルトが破綻しがちです。多くのアプリはタイムアウト、リトライ、キャッシュをデフォルトに任せており、実際のユーザーにとって何が「十分か」を定めていません。デフォルトは安定したWi‑Fi向けに調整されていることが多く、通勤電車、エレベーター、混雑したカフェでは役に立ちません。
ユーザーは「ソケットのタイムアウト」や「HTTP 503」を言いません。彼らが気づくのは症状です:いつまでも回るスピナー、長い待ちのあとに出る突然のエラー(次の試行では成功する)、重複した操作(2件の予約、2回の注文、二重請求)、失われた更新、UI は「失敗」と表示するが実際にはサーバーが成功しているといった混在状態。
遅いネットワークは小さな設計ギャップをお金や信頼の問題に変えます。アプリが「送信中」「失敗」「完了」を明確に分けていなければ、ユーザーは再度タップします。クライアントが盲目的にリトライすると重複が発生します。サーバーが冪等性をサポートしていなければ、不安定な接続が複数の「成功」書き込みを生みます。
「重要なアクション」とは一度しか実行されてはいけない、かつ正確である必要があるものです:支払い、チェックアウト、枠の予約、ポイント移行、パスワード変更、配送先の保存、保険請求の提出、承認の送信など。
現実的な例:弱いLTEでチェックアウトを送信した。リクエストは送られたがレスポンスが届く前に接続が落ちた。ユーザーはエラーを見て「支払う」を再度タップし、サーバーには2回届く。ルールがなければ、アプリは再試行すべきか、待つべきか、停止すべきか判断できません。ユーザーも再試行すべきか判断できません。
コードを調整する前にルールを決める
接続が遅かったり不安定だったりすると、ほとんどのバグはHTTPクライアントそのものではなく「ルールが不明確なこと」から生じます。タイムアウト、キャッシュ、リトライに触る前に、アプリにとって「正しい」とは何かを書き出してください。
まず二度実行されてはいけないアクションを決めます。通常はお金やアカウントに関わる操作です:注文、カード課金、払い出し申請、パスワード変更、アカウント削除など。ユーザーが二度タップしたりアプリが再試行しても、サーバー側はそれを1回として扱うべきです。まだ保証できないなら、そうしたエンドポイントは「自動リトライ禁止」にしておきます。
次に、ネットワークが悪いときに各画面が何をできるべきか決めます。ある画面はオフラインで有用(直近のプロフィール、過去の注文)かもしれません。別の画面はオフライン時に読み取り専用かもしれません(在庫数、リアルタイム価格)。期待を混在させるとUIが混乱し、危険なキャッシュが生まれます。
アクションごとに許容する待ち時間をユーザーの感覚に合わせて決めてください。ログインは短い待ちで許容されることが多い。ファイルアップロードは長めが必要。チェックアウトは速く感じられる一方で安全である必要があります。紙の上で30秒のタイムアウトが「信頼できる」かもしれませんが、ユーザー体験としては壊れたと感じられることがあります。
最後に、何をデバイスに保存し、どれくらい保持するか決めます。キャッシュは役に立ちますが、古いデータが誤った選択を招くことがあります(古い価格、期限切れの適格性)。
ルールは誰でも見られる場所に書いておきましょう(READMEで十分です)。シンプルに保ちます:
- どのエンドポイントが「重複不可」で冪等性が必要か?
- どの画面がオフラインで動くべきか、どれがオフライン時に読み取り専用か?
- アクションごとの最大許容待ち時間は?(ログイン、フィード更新、アップロード、チェックアウトなど)
- デバイスに何をキャッシュし、期限はどれくらいか?
- 失敗後にエラー表示するか、後でキューに入れるか、手動で再試行させるか?
ルールがはっきりすれば、タイムアウト値、キャッシュヘッダー、リトライポリシー、UI状態の実装とテストがずっと簡単になります。
ユーザー期待に合ったタイムアウト
遅いネットワークはさまざまな失敗を生みます。良いタイムアウト設定は単に数字を選ぶのではなく、ユーザーが何をしようとしているかに合ったものです。アプリが回復できる程度に速く失敗させます。
3つのタイムアウトを分かりやすく:
- 接続タイムアウト(Connect timeout):サーバーへの接続確立(DNS、TCP、TLS)にどれだけ待つか。これが失敗するとリクエストは実質開始されていません。
- 書き込みタイムアウト(Write timeout):リクエストボディの送信(アップロード、大きなJSON、遅いアップリンク)にどれだけ待つか。
- 読み取りタイムアウト(Read timeout):リクエスト送信後にサーバーがデータを返すのをどれだけ待つか。スポッティなモバイルではここで失敗することがよくあります。
タイムアウトは画面やリスクに合わせてください。フィードは多少遅くても大きな問題になりにくい。重要なアクションは成功か明確な失敗のどちらかを早く示すべきです。
実用的な出発点(測定後に調整):
- リスト読み込み(低リスク):connect 5–10秒、read 20–30秒、write 10–15秒。
- タイプ検索(Search-as-you-type):connect 3–5秒、read 5–10秒、write 5–10秒。
- 重要アクション(支払いや注文):connect 5–10秒、read 30–60秒、write 15–30秒。
一貫性は完璧さより重要です。ユーザーが「送信中」のスピナーを2分見せられると再度タップします。
UI側でも「無限読み込み」を避けるために明確な上限を表示してください。進捗をすぐに見せ、キャンセルを可能にし、例えば20〜30秒経ったら「まだ試行中です…」と表示して接続を確認・再試行の選択肢を示します。これでネットワークリブラリがまだ待っていても体験は正直になります。
タイムアウトが起きたら、問題の傾向をデバッグできる程度のログを残してください(ただし秘密情報は記録しない)。有用な情報:URLパス(完全なクエリは避ける)、HTTPメソッド、ステータス(あれば)、接続・書き込み・読み取りの時間内訳(取れるなら)、ネットワーク種別(Wi‑Fi/モバイル/機内モード)、概算のリクエスト/レスポンスサイズ、リクエストID(クライアントログとサーバーログを照合するため)。
シンプルで一貫した Kotlin ネットワーク設定
接続が遅いとき、小さなクライアント設定の違いが大きな問題になります。共通のベースラインを作ればデバッグが早くなり、すべてのリクエストに同じルールが適用されます。
1つのクライアント、1つのポリシー
HTTPクライアントを組み立てる場所を1つにまとめます(多くは1つの OkHttpClient を Retrofit が使う形)。そこに共通ヘッダ(アプリ版、ロケール、認証トークン)や明確な User‑Agent、タイムアウト、デバッグ用のログ、そしてリトライ方針(たとえ「自動リトライなし」でも)を置きます。
小さな例:
val okHttp = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.callTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("User-Agent", "MyApp/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/json")
.build()
chain.proceed(request)
}
.build()
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttp)
.addConverterFactory(MoshiConverterFactory.create())
.build()
ユーザーメッセージにマップする集中エラーハンドリング
ネットワークエラーは単なる例外ではありません。画面ごとにバラバラに扱うとユーザーにランダムなメッセージが出ます。
失敗を少数のユーザーフレンドリーな結果に変換するマッパーを作りましょう:接続なし/機内モード、タイムアウト、サーバーエラー(5xx)、バリデーションや認証エラー(4xx)、そして未知のフォールバック。
これでUIの文言を一貫させられ、技術的な詳細を漏らさずに済みます。
画面が閉じられたらリクエストをタグ付けしてキャンセルする
不安定なネットワークでは、遅れて完了した呼び出しが既に消えた画面を更新してしまうことがあります。画面が閉じたらその仕事を止める(キャンセル)ことを標準ルールにしてください。
Retrofit と Kotlin コルーチンであれば、ViewModel のスコープをキャンセルすると基盤のHTTPコールもキャンセルされます。非コルーチンの呼び出しでは Call の参照を保持して cancel() します。機能を抜けるときにリクエストにタグを付けてグループキャンセルすることもできます。
バックグラウンド作業はUIに依存させない
レポート送信、キューの同期、提出の完了など重要な作業は、UI に依存しないスケジューラで実行すべきです。Android では WorkManager が一般的で、後で再試行したりアプリ再起動後も続行できます。UIアクションは軽くして、長時間かかる作業は適切にバックグラウンドに渡しましょう。
モバイルで安全なキャッシュルール
キャッシュは遅い接続で大きな利点になります。繰り返しダウンロードを減らし、画面を即時に見せられます。ただし古いデータが誤った判断を招くこともあります(古い価格、期限切れの在庫)。
安全なアプローチは、多少古くても許容できるものだけをキャッシュし、お金やセキュリティ、最終決定に関わるものは必ず新しいものを確認することです。
信頼できる Cache‑Control の基本
多くは以下のヘッダに落ち着きます:
max-age=60:60秒間はサーバーに問い合わせずキャッシュを使える。no-store:このレスポンスを保存しない(トークンや機密画面に最適)。must-revalidate:期限切れならサーバー確認が必要。
モバイルでは must-revalidate が「静かに間違った」データを防ぎます。地下鉄から出たあとに速く画面を見せつつ、データがまだ有効か確認したい時に有用です。
ETag による再検証:速く安価で信頼できる
読み取りエンドポイントでは ETag ベースの検証が長い max-age より優れることが多いです。サーバーがレスポンスに ETag を付け、次回は If-None-Match を送ります。変わっていなければサーバーは 304 Not Modified を返し、ネットワーク負荷が小さく済みます。
商品リスト、プロフィール詳細、設定画面などに向きます。
簡単な指針:
- 読み取りエンドポイントは短い
max-ageとmust-revalidate、可能ならETagをサポートする。 - 書き込み(POST/PUT/PATCH/DELETE)はキャッシュしない。常にネットワークベースで扱う。
- 機密なもの(認証レスポンス、支払い手順、プライベートメッセージ)は
no-storeを使う。 - 静的アセット(アイコン、公開設定)は長めにキャッシュして構わない。
アプリ全体でキャッシュ方針を一貫させてください。ユーザーはミスマッチを遅延よりも敏感に察知します。
悪化させない安全なリトライ
リトライは簡単な修正のように見えますが、誤ると逆効果です。間違ったリクエストをリトライすると負荷が増え、バッテリを消費し、アプリが固まったように感じさせます。
まず一時的な失敗だけをリトライ対象にします。接続切断、読み取りタイムアウト、短時間のサーバー障害は次で成功する可能性があります。パスワード間違い、必須フィールド欠落、404 は対象外です。
実用的なルール:
- タイムアウトや接続失敗はリトライする。
- 502、503、場合によっては504はリトライ候補。
- 4xx はリトライしない(408 や 429 は待ちルールがあれば例外)。
- すでにサーバーに到達して処理中の可能性があるリクエストはリトライしない。
- リトライ回数は少なく(1〜3回が多い)。
バックオフ+ジッター:リトライの嵐を減らす
多くのユーザーが同じ障害に当たるとき、即時リトライはトラフィックの波を作って復旧を遅らせます。指数バックオフ(回ごとに待ち時間を長くする)とジッター(小さなランダム差)を使って端末が同時にリトライするのを避けましょう。
例:最初は約0.5秒、次は1秒、次は2秒にしてそれぞれ±20%のランダム差を入れる。
合計リトライ時間に上限を設ける
上限がなければユーザーはスピナーに閉じ込められます。操作全体(待ち+リトライ)に上限を設け、通常は10〜20秒で「はっきりした選択肢」を示すことが多いです。
コンテキストも考慮します。フォーム送信のようなユーザー操作では即座に回答を望みますが、バックグラウンド同期は後で再試行して構いません。
非冪等な操作(注文や支払いなど)を自動リトライするべきではありません。冪等性キーやサーバー側の重複検出などの保護がある場合のみ例外です。安全が保証できないなら明確に失敗させ、次のアクションをユーザーに決めさせます。
重要な操作の重複防止
遅い・不安定な接続ではユーザーが二度タップします。OSやネットワークの再試行で同じリクエストが再送されることもあります。オブジェクトを「作る」操作(注文、送金、パスワード変更)では重複が害になります。
冪等性とは同じリクエストが同じ結果を返すことです。繰り返されてもサーバーは二重の注文を作らず、最初の結果を返すか「すでに完了」と返すべきです。
重要操作には冪等性キーを使う
重要操作では、ユーザーが試行を始めたときに一意の idempotency キーを生成し、それをリクエストに含めます(多くは Idempotency-Key ヘッダかボディのフィールド)。
実用的な流れ:
- ユーザーが「支払う」を押したときに UUID の idempotency キーを生成。
- 小さなレコードをローカルに保存:status = pending、createdAt、リクエストペイロードのハッシュなど。
- キー付きでリクエストを送信。
- 成功レスポンスを受け取ったら status = done にしてサーバー結果IDを保存。
- 再試行が必要なときは、新しいキーではなく同じキーを再利用する。
この「同じキーを再利用する」というルールが誤って二重請求するのを止めます。
アプリ再起動やオフライン期間への対応
リクエスト中にアプリが強制終了した場合、次回起動時にも安全である必要があります。idempotency キーとリクエスト状態をローカルに保存し、再起動時には同じキーで再試行するか、保存したキーまたはサーバー結果IDでステータス照会を行います。
サーバー側の契約も重要です:同じキーを受け取ったら二重を拒否するか、最初のレスポンスを返すべきです。サーバーがそれに対応できない限り、クライアント側だけで完全に重複を防ぐことはできません。
ユーザー向けの工夫として、試行が保留中なら「支払い進行中」と表示してボタンを無効化し、確定結果が来るまで操作できないようにするのが有効です。
偶発的な再送を減らすUIパターン
遅い接続はユーザーのタップ行動にも影響します。画面が2秒固まると多くのユーザーは反応がなかったと判断してもう一度タップします。ネットワークが悪いときでも「一度のタップで済んだ」と感じさせるUIが必要です。
楽観UI(optimistic UI)は取り消し可能かリスクの低い操作(スター付け、下書き保存、既読)に向きます。支払い、在庫、取り返しのつかない削除、重複が致命的な操作は確定型UIが適切です。
重要操作のデフォルトは「保留状態」を明確にすること。最初のタップでメインボタンを「送信中」に切り替え、無効化し、短い説明文を表示します。
不安定なネットワークで有効なパターン:
- 最初のタップで主操作ボタンを無効化し、最終結果が出るまで無効のままにする。
- 金額や受取人、アイテム数などを表示する「保留」ステータスを見せる。
- 送信済みの「最近のアクティビティ」ビューを用意してユーザーが送信済みを確認できるようにする。
- アプリがバックグラウンドになっても保留状態を保持する。
- 画面上に複数のタップターゲットを置かず、明確な主ボタンを1つにする。
時にはリクエストは成功しているがレスポンスが失われることがあります。これを「失敗」と判断して再タップを誘うのではなく、通常の結果として扱い「まだ確定していない」と示して「ステータスを確認する」といった安全な次のステップを提示します。ステータス確認ができない場合は保留レコードをローカルに残し、接続が回復したら更新することを伝えます。
「もう一度試す」は明示的で安全に。再試行する場合は同じクライアント側リクエストIDや冪等性キーを使えるときだけ表示します。
現実的な例:不安定なチェックアウト送信
顧客が電波の弱い電車に乗っています。カートに入れ「支払う」をタップしました。アプリは待つ必要がありますが、二重注文を作ってはいけません。
安全なシーケンス例:
- クライアント側の試行IDを作り、チェックアウトリクエストに idempotency キー(例:UUID)を付けて送信。
- 接続タイムアウトの後により長い読み取りタイムアウトで待つ。トンネルに入りコールがタイムアウトする。
- アプリはサーバーからの応答を一度も受け取っていない場合のみ短い遅延の後に一度だけリトライする。
- サーバーは同じ idempotency キーを見て、二重の注文を作らず最初の結果を返す。
- アプリはリトライから返ってきた成功でも最終確認画面を表示する。
キャッシュは厳格に運用します。商品リスト、配送オプション、税情報などのGETは短期間キャッシュして良いが、チェックアウト送信(POST)は決してキャッシュしない。HTTPキャッシュがあっても閲覧の補助と考え、支払いを覚えておく手段にはしないでください。
重複防止はネットワークとUIの組み合わせです。ユーザーが「支払う」を押すとボタンを無効化し「注文を送信中…」と表示、キャンセルは1つだけにします。接続が切れたら「まだ試行中」に切り替え、同じ試行IDを保持します。強制終了して再起動しても、そのIDで注文ステータスを確認して再支払いを促さないようにします。
クイックチェックリストと次のステップ
オフィスのWi‑Fiでは「まあまあ動く」が、電車やエレベーター、地方だと壊れるなら、この項目をリリースゲートとして扱ってください。これは巧妙なコードより「繰り返せる明確なルール」の仕事です。
出荷前のチェックリスト:
- エンドポイント種別ごとにタイムアウトを設定し、スロットリングや高遅延のネットワークでテストする。
- 本当に安全な箇所だけをリトライし、バックオフで回数を制限する(読み取りは数回、書き込みは通常なし)。
- すべての重要な書き込み(支払い、注文、フォーム送信)に冪等性キーを付け、リトライや二重タップで重複が起きないようにする。
- キャッシュのルールを明確にする:どれが多少古くても許されるか、どれが常に最新であるべきか、どれを絶対にキャッシュしてはいけないか。
- 状態を可視化する:保留、失敗、完了を見分けられるようにし、完了した操作は再起動後にもアプリが覚えていること。
「後で決める」が1つでもあると、画面ごとにランダムな挙動になります。
定着させるための次の一手
1ページのネットワーキングポリシーを書いてください:エンドポイントのカテゴリ、タイムアウト目標、リトライルール、キャッシュ期待値。これを一箇所(インターセプタ、共有クライアントファクトリ、小さなラッパー)で強制適用し、チーム全員がデフォルトで同じ挙動を得られるようにします。
次に短い重複ドリルをやってください。チェックアウトなど1つの重要アクションを選び、スピナーを凍らせ、アプリを強制終了し、機内モードを切り替え、再度ボタンを押してみる。安全であることを証明できなければ、ユーザーはやがて壊し方を見つけます。
最後に、バックエンドとクライアントで同じルールを手作業で配線せずに適用したいなら、AppMaster (appmaster.io) のようなツールが本番用バックエンドとネイティブモバイルのソースコードを生成するのに役立ちます。それでも鍵はポリシーです:冪等性、リトライ、キャッシュ、UI状態を一度定義し、フロー全体で一貫して適用してください。
よくある質問
まずは各画面やアクションにとって「正しい」とは何かを定義してください。特に支払いや注文など「一度だけ実行されるべき」操作についてルールを決めることが重要です。ルールが明確になってから、タイムアウト、リトライ、キャッシュ、UI状態をライブラリのデフォルトに頼らずに調整します。
ユーザーは長時間回転するスピナー、長い待ちのあとに出るエラー、二度目で成功する操作、あるいは注文や二重請求のような重複結果に気づきます。これらは多くの場合、電波が悪いことよりも「リトライや保留・失敗の取り扱いが不明確」なことが原因です。
接続確立にかける時間が connect timeout、アップロードなど送信にかかる時間が write timeout、サーバーの応答を待つ時間が read timeout です。リスクが低い読み取りは短め、重要な送信は読み書きとも長めに設定し、UI側でも上限を設けてユーザーが永遠に待たされないようにします。
はい。もし一つだけ設定できるなら callTimeout を使って操作全体の上限を設けるのが現実的です。加えてアップロードや大きなレスポンス向けに connect/read/write を別に設定すると細かく制御できます。
接続切断やタイムアウトなど一時的な障害、DNS障害、502/503/504 のようなサーバー側の短時間障害はリトライ候補です。パスワード不一致や必須フィールド欠落、404 のようなクライアントエラーはリトライすべきではありません。書き込み系は冪等性の保証がない限り自動リトライしてはいけません。
リトライ回数は少なく(通常1〜3回)、指数バックオフとジッター(ランダム差)を入れて同時に大量の端末がリトライしてサーバーを圧迫するのを避けます。合計のリトライ時間に上限を設け、ユーザーが何分もスピナーに囚われないようにします。
冪等性とは同じリクエストを繰り返しても二重に処理されず、同じ結果が返る性質です。支払い・注文などでは重要で、各試行に対して一意の idempotency キーを発行し、リトライ時には同じキーを使うと二重請求や二重作成を防げます。
ユーザーが操作を開始したときに一意のキーを生成し、ローカルに「pending」などの状態で保存してリクエストと一緒に送ります。再試行やアプリ再起動時には同じキーを再利用するか、保存したキーでサーバーに状態照会して二重処理を避けます。
少し古くても許容できるデータだけをキャッシュし、支払い・セキュリティ・最終判断に関わるものは必ず最新を確認するようにします。読み取りは短いキャッシュ寿命+再検証(ETag など)を優先し、書き込みはキャッシュしない、機密データは no-store にします。
最初のタップで主操作ボタンを無効化し、「送信中…」などの状態を即座に表示し、バックグラウンドや再起動後も保留状態がわかるようにします。レスポンスが失われた可能性がある場合は「失敗」ではなく「未確認です(ステータスを確認)」のようにして再タップを促さないUIにします。


