綴Photo の勉強ノートに戻る
技術ブログ2026.07.28

中身の無い返事が「全部消せ」という指示になっていた

API設計データ保全fail-closed綴Photo

1週間前に書いた「受け取れる形なのにわざと弾く — 部分リストを黙って受けるとデータが静かに消える」の続きです。舞台は同じ一覧デコーダで、あのときは部分リスト(ページ分割形式)を受けないと決めた。今回はその同じ方針に、穴がもう1つ残っていた。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

AIレビューの指摘を追いかけたら、その関数のすぐ上に「こうすべき」と書いてある説明文があって、すぐ隣の行がそれを破っていた、という記録です。

端末とサーバのどちらからでも「web に公開する / やめる」ができます。だから同期のたびに、まずサーバへ「いま公開されているのはどれ?」と聞きに行って、端末側をそれに合わせる。サーバが正で、端末はそのキャッシュです。

端末で「web に出す」にした写真は、サーバ側では「お気に入り(favorites)」の一覧として持っています。聞きに行く処理はこれです。

dart
Future<Set<int>> fetchPublishedServerPhotoIds() async {
  final rows = await _apiClient.getList(favoritesEndpoint);  // サーバの公開一覧を取りに行く
  final ids = <int>{};                                       // Set=重複しない写真 ID の集まり
  for (final row in rows) {
    final photoId = row['photo'];                            // 1行から写真の ID だけ抜く
    if (photoId is int) ids.add(photoId);
  }
  return ids;
}

返ってきた集合に載っていない写真は「公開を取り消された」と解釈して、端末側の公開フラグを下ろす。そのあと、下ろした状態をサーバへ送り返す。

つまり 空の集合は「全部の公開を取り消せ」という指示になります。

空の指示が、本文の無い返事から作られていた

配列を受け取るデコーダはこうなっていた。

dart
List<Map<String, dynamic>> _decodeList(http.Response res) {
  if (res.statusCode >= 200 && res.statusCode < 300) {
    if (res.body.isEmpty) return const <Map<String, dynamic>>[];   // ← ここ
    final decoded = jsonDecode(res.body);
    // ...
  }
}
サーバ DB には公開の行が残っている。届いた本文はメモリで、保存しない。届いた本文0 バイトメモリ・保存しない空リストに化けるここには残らない通す端末 SQLitePhotos.webSelected全部 falseキャッシュを上書き閉じても残る送信サーバ DBFavoriteMemory行が消える公開の正が空になる閉じても空のまま空リストはメモリ上の戻り値で、置き場所ではない。書いた先は端末 SQLite の Photos.webSelected。消えるのはサーバの FavoriteMemory。本文 0 バイトは保存しない。サーバには、読む時点では公開の行が残っていた。

本文が1バイトも無い返事から、破壊的な操作が組み立てられていた。

どういう時に起こりうるか。

  • サーバ側が「0件の時は 204 No Content を返す」に変えた日
  • 開発中のローカル接続や、間に何かを挟む構成で、200 のまま本文だけが落ちた時

本番の通信は HTTPS なので、途中の機械が本文だけ空に差し替えることはできません(証明書が合わずに通信ごと失敗します=それは例外として上がります)。効いてくるのは1つ目、サーバ自身が善意で仕様を変えた日のほうです。「壊れた」とは言いにくい、ふつうに起こりうる形で、起きた時にユーザーの目に映るのは、家族が見ている公開ページが1操作で空になることだった。

方針は同じファイルに書いてあった。破っていたのも同じファイル

いちばん効いたのはここ。この関数のすぐ上に、説明文がこう書いてあった。

要約すると「ページ分割された形式はわざと受け取らない。部分的なリストを受け取ると、2ページ目以降の公開を全部誤って落とすから。黙って部分成功するより、例外で落ちるほうが安全」。

方針としては完全に正しい。しかも「黙って部分成功するより落ちるほうが安全」まで言語化されている。

その方針から、空ボディだけが漏れていた。

方針と、それを破る1行は同じコミットで同時に入っている。この関数を新設した時に、説明文も本体も一度に書かれた。後から方針だけ足して既存コードと突き合わせ損ねた、という話ではない。原則を書いた手が、その6行下で原則を外している。

構造的な理由もあります。この関数が置かれているのは HTTP のデコーダの層で、そこには「この戻り値が破壊的か」という情報がありません。その層だけで見れば「本文が無いなら空のリスト」は素直な判断です。

同じ 0 バイトの本文。置き場所はメモリ。保存しない。届いた本文 0 バイトメモリ・保存しない。デコーダは関所で、置き場所ではない。公開一覧へ退会へ端末 SQLitePhotos.webSelected全部 false に上書き閉じても残る。破壊的サーバ DBアカウント削除の 204FavoriteMemory は触らない空の {} はメモリ。無害

だからこれは、どちらのファイルを単体で読んでも見つからないバグだった。2つの層をまたいで初めて姿が出ます。

直したもの

2つあります。

空ボディを契約違反にします。

dart
if (res.body.isEmpty) {
  throw const FormatException(
      'Expected a JSON array but the body was empty');
}

空の正常な配列([])は今まで通り受けます。 ここまで潰すと「本当に全部の公開をやめた」が表現できなくなります。前回「空は空として扱ってよい」と書いたのはこの [] のことで、0バイトはそこから切り離した。

同じ空に見えて、置き場所が違う。[] 宣言サーバ DB FavoriteMemory0 行が本文に書いてある受ける=正しい指示閉じても 0 行のまま本文なし 無言HTTP の body 0 バイトメモリ・何も言っていない落とす=事故を指示にしない保存しない。SQLite も書かない[] はサーバ DB の事実。0 バイトはメモリ上の無言で、FavoriteMemory の件数ではない。直し後、無言では端末 SQLite にもサーバ DB にも書かない。
返ってきたもの サーバが言いたいこと 突き合わせの読み 修正後
[] 公開中は0枚 全部の公開を取り消せ 受ける(正しい指示)
本文なし(0バイト) 何も言っていない 全部の公開を取り消せ 落とす(事故を指示にしない)
1ページ目だけ まだ続きがある 2ページ目以降を取り消せ 落とす(元から)
photo が型崩れの1行 1行だけおかしい その写真を取り消せ 落とす(下で直した)

⚠️ 根はどれも同じで、「一覧に載っていない=取り消された」を採った時点で、欠ける理由が何であれ全部「取り消し」に化けます。

行が壊れていたら、その集合ごと捨てます。

dart
if (photoId is! int || photoId <= 0) {
  throw FormatException(
    '公開一覧の photo が正の整数ではありません: ${photoId.runtimeType}',
  );
}

元は if (photoId is int) ids.add(photoId) で、型の合わない行を黙って飛ばしていた。飛ばされた行の写真は「一覧に載っていない」=「公開を取り消された」と読まれます。つまり 1行の型崩れが、破壊的な公開解除に変換されていた。

配列の中に object でない要素が混ざっている場合も同じ理由で落とすようにした。元は whereType<Map>() で静かに捨てていた。

落ちるべき時に落ちるか、を先に確かめた

テストは3本とも、修正を入れる前に走らせて赤くなることを確認してから採用した。

古いテストのほうが厄介で、こう書いてあった。

dart
test('空ボディ・空配列はどちらも空リスト', () async { /* ... */ });

現状の挙動をそのまま固定している。これを消して書き直すのだが、消す前に「これは考えて決めたことなのか、そう動いていたから書いただけなのか」を見る必要があります。今回は後者だった(考えて決めた方針は、上の説明文のほうに書いてあって、そちらは逆を言っている)。

書き直したテストには、なぜ改訂したかをコメントで残した。

dart
// 元は「空ボディも空配列と同じく空リスト」で固定していた(改訂)。
// 空リストは reconcile にとって「全部公開を取り消された」という破壊的な指示で、
// それを本文の無い応答から作ってはいけない。

トレードオフは飲んだ

fail-closed に倒したので、サーバが契約から少しでも外れると同期が止まります。ユーザーには「同期に失敗しました」としか出ません。

それでいいと判断したのは、比べる相手が「家族の見ているページが空になる」だから。

ただしこれで、端末側だけが「photo は必ず正の整数」という約束を前提にした状態になった。その約束はサーバ側のコードにはどこにも書かれていません。片方だけ厳しくして、もう片方に伝えていません。

学び

  • 「空」と「何も返ってきていない」を同じ値で表しません。この2つが同じ型に潰れると、事故が指示に化けます
  • 戻り値の意味は、読み手が決めます。しかもこの厳しさが正しいのは、いまこのデコーダの読み手が1つしかないからで、別の用途でも使い始めた日には同じ厳しさが過剰になります。関数の安全さが、呼び出し元の数に乗っています
  • 黙って捨てる処理(whereType / if (x is T))は、捨てた事実がどこにも残りません。捨てた結果が「無かったこと」と同じ意味になる文脈では、捨てずに止めます

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。