avatar
蕭富云モバイルアプリ開発者 · Android & Flutter

オフラインで失敗したリクエストが、再接続後になぜ勝手に送られたのか?

2026-06-17 · 9 min read

注: 本記事は、私の初期メモと考えをもとに AI(Claude)が仕上げたものです。

何年も腑に落ちなかった謎がある。失敗するはずのネットワークリクエストが、ネットワークを繋ぎ直したあとに、なぜか勝手に送信されてしまうことがあるのだ。

最近 Flutter アプリでまた遭遇し、今度こそ経緯を解き明かせた。結論は拍子抜けするものだった——「再接続で自動同期する」気の利いた機能などではなく、TCP の接続タイムアウトとの競合(レース)だったのだ。

症状

このアプリはオフラインファーストだ。レコードを保存するとき、まずローカルの暗号化データベースに書き込み、それからバックグラウンドで HTTP アップロードを「撃ちっぱなし(fire-and-forget)」で発射する。オフラインでもデータは失われず、アップロードはベストエフォートだ。

(以下のテストと数字はすべて iOS で測ったもの——接続タイムアウトやネガティブキャッシュの挙動は OS によって異なるので、秒数をクロスプラットフォームの定数とみなさないでほしい。)

テスト中、こんな光景を見た:

  1. wifi を切ってレコードを保存する——アップロードリクエストはすぐには失敗せず、固まる
  2. しばらくして wifi を繋ぎ直し、まったく無関係な操作をする。
  3. 「失敗するはずだった」そのレコードが、勝手にアップロードされる

まるでアプリがネットワークの復帰を検知して再送したように見える。だが私は確信していた——このアプリには接続状態を監視するコードが一行もない。 では、いったいどうやって送られたのか?

誤読:「きっと再接続自動同期があるはずだ」

これが最も直感的で、最も間違った解釈だ。connectivity listener がなければ、再送を引き起こす者などいない。リクエストは再送されたのではなく、そもそも一度しか発射されていない。その一度が、完了までにとても長くかかっただけだ。

「とても長くかかった」——これがすべての肝だ。

真の原因:リクエストは「失敗」ではなく「待機」していた

ネットワークがないとき HTTP POST が何をするかを分解してみよう:

そして Dart の http.Client.postデフォルトでアプリ層のタイムアウトを持たないので、Future は接続タイムアウトの間ずっと pending のままになる。

さらにリクエストは撃ちっぱなし(unawaited、または await しない .then())だ。UI はとっくに先へ進んでいるが、リクエストはバックグラウンドで生きている:

// だいたいこんな感じ——実際のコードではない
unawaited(client.post(uri, body: payload)); // timeout なし、await なし
// ↑ オフラインだと、この Future は OS の接続タイムアウトの中で固まる——最長 ~30 秒

こうして「魔法」が起きる。接続タイムアウトが切れる前にネットワークを繋ぎ直せば、次に再送された SYN がついに返事を受け取り → 接続が確立し → バックグラウンドで並んでいた POST が完了する。

自動同期のように見えるが、実態は接続タイムアウトが切れる前に再接続できるかどうかのレースだ。

強調しておきたい——これは極めて不安定だ。一歩遅れて——タイムアウトがすでに切れたあとに——繋ぎ直せば、リクエストはとっくに失敗しており、誰かが明示的に再試行するまで何もアップロードされない。

傍証:最初の一回だけ遅く、あとは即失敗

メカニズムを裏づけるもう一つの手がかりがある。オフラインで保存する最初の一件はリクエストが約 30 秒固まるが、二件目・三件目は一瞬で失敗する

これは典型的な**ネガティブキャッシュ(negative caching)**だ。到達できないホストへの最初の接続は OS の接続タイムアウトをまるごと待つが、一度失敗すると OS は「ホスト到達不能/DNS ネガティブ」の結果をキャッシュするので、以降の試行は待たずに即失敗する。

最初が遅く、あとは速い——アプリが何かをしているのではなく、ネットワークスタックの挙動だ。

兄弟の教訓:「インターフェースが up」≠「インターネットに到達できる」

「オフライン」を判定するために connectivity_pluscheckConnectivity() を使った。だがこれが読むのはOS のネットワークインターフェースの状態であって、実際にインターネットに到達できるかどうかではない。

これに噛まれた。wifi を切り、デバッグのために USB で Mac に繋いだ iPad では、checkConnectivity()ConnectivityResult.other を返した——USB のデバッグ回線が「接続済み」のインターフェースとして数えられるのだ——ので、アプリは自分をオンラインだと思い込み、そのままアップロードを走らせ、固まった。最初の修正(any != none で判定)が間違っていたのは、まさにこの理由による。

正しいやり方は、本当に外部ネットに繋がるインターフェースwifi / mobile / ethernet / vpn)だけをオンラインと数え、other / bluetooth / none はオフラインとして扱うことだ。

教訓はこうだ:インターフェースの存在 ≠ インターネット到達性。 connectivity_plus が答えるのは「up なインターフェースはあるか?」であって、「インターネットに到達できるか?」ではない。後者を問うには、実際に探りを入れる(短いタイムアウト付きの DNS 照会やリクエスト)必要がある——その代償として「一瞬」の応答をわずかな遅延と引き換えにする。

締め:まだ死んでいない socket に賭けるより、自分で境界を引け

オフラインの挙動を、接続タイムアウトとの賭けではなく確定的にしたいなら、方向は明らかだ:

三行でまとめる:タイムアウトの境界は自分で引け、まだあえいでいる socket を信じるな、そして「インターフェースが up」は決して「インターネットに到達できる」を意味しない。