1週間前に書いた「受け取れる形なのにわざと弾く — 部分リストを黙って受けるとデータが静かに消える」の続きです。舞台は同じ一覧デコーダで、あのときは部分リスト(ページ分割形式)を受けないと決めた。今回はその同じ方針に、穴がもう1つ残っていた。
ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。
AIレビューの指摘を追いかけたら、その関数のすぐ上に「こうすべき」と書いてある説明文があって、すぐ隣の行がそれを破っていた、という記録です。
端末とサーバのどちらからでも「web に公開する / やめる」ができます。だから同期のたびに、まずサーバへ「いま公開されているのはどれ?」と聞きに行って、端末側をそれに合わせる。サーバが正で、端末はそのキャッシュです。
端末で「web に出す」にした写真は、サーバ側では「お気に入り(favorites)」の一覧として持っています。聞きに行く処理はこれです。
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;
}返ってきた集合に載っていない写真は「公開を取り消された」と解釈して、端末側の公開フラグを下ろす。そのあと、下ろした状態をサーバへ送り返す。
つまり 空の集合は「全部の公開を取り消せ」という指示になります。
空の指示が、本文の無い返事から作られていた
配列を受け取るデコーダはこうなっていた。
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);
// ...
}
}本文が1バイトも無い返事から、破壊的な操作が組み立てられていた。
どういう時に起こりうるか。
- サーバ側が「0件の時は 204 No Content を返す」に変えた日
- 開発中のローカル接続や、間に何かを挟む構成で、200 のまま本文だけが落ちた時
本番の通信は HTTPS なので、途中の機械が本文だけ空に差し替えることはできません(証明書が合わずに通信ごと失敗します=それは例外として上がります)。効いてくるのは1つ目、サーバ自身が善意で仕様を変えた日のほうです。「壊れた」とは言いにくい、ふつうに起こりうる形で、起きた時にユーザーの目に映るのは、家族が見ている公開ページが1操作で空になることだった。
方針は同じファイルに書いてあった。破っていたのも同じファイル
いちばん効いたのはここ。この関数のすぐ上に、説明文がこう書いてあった。
要約すると「ページ分割された形式はわざと受け取らない。部分的なリストを受け取ると、2ページ目以降の公開を全部誤って落とすから。黙って部分成功するより、例外で落ちるほうが安全」。
方針としては完全に正しい。しかも「黙って部分成功するより落ちるほうが安全」まで言語化されている。
その方針から、空ボディだけが漏れていた。
方針と、それを破る1行は同じコミットで同時に入っている。この関数を新設した時に、説明文も本体も一度に書かれた。後から方針だけ足して既存コードと突き合わせ損ねた、という話ではない。原則を書いた手が、その6行下で原則を外している。
構造的な理由もあります。この関数が置かれているのは HTTP のデコーダの層で、そこには「この戻り値が破壊的か」という情報がありません。その層だけで見れば「本文が無いなら空のリスト」は素直な判断です。
だからこれは、どちらのファイルを単体で読んでも見つからないバグだった。2つの層をまたいで初めて姿が出ます。
直したもの
2つあります。
空ボディを契約違反にします。
if (res.body.isEmpty) {
throw const FormatException(
'Expected a JSON array but the body was empty');
}空の正常な配列([])は今まで通り受けます。 ここまで潰すと「本当に全部の公開をやめた」が表現できなくなります。前回「空は空として扱ってよい」と書いたのはこの [] のことで、0バイトはそこから切り離した。
| 返ってきたもの | サーバが言いたいこと | 突き合わせの読み | 修正後 |
|---|---|---|---|
[] |
公開中は0枚 | 全部の公開を取り消せ | 受ける(正しい指示) |
| 本文なし(0バイト) | 何も言っていない | 全部の公開を取り消せ | 落とす(事故を指示にしない) |
| 1ページ目だけ | まだ続きがある | 2ページ目以降を取り消せ | 落とす(元から) |
photo が型崩れの1行 |
1行だけおかしい | その写真を取り消せ | 落とす(下で直した) |
⚠️ 根はどれも同じで、「一覧に載っていない=取り消された」を採った時点で、欠ける理由が何であれ全部「取り消し」に化けます。
行が壊れていたら、その集合ごと捨てます。
if (photoId is! int || photoId <= 0) {
throw FormatException(
'公開一覧の photo が正の整数ではありません: ${photoId.runtimeType}',
);
}元は if (photoId is int) ids.add(photoId) で、型の合わない行を黙って飛ばしていた。飛ばされた行の写真は「一覧に載っていない」=「公開を取り消された」と読まれます。つまり 1行の型崩れが、破壊的な公開解除に変換されていた。
配列の中に object でない要素が混ざっている場合も同じ理由で落とすようにした。元は whereType<Map>() で静かに捨てていた。
落ちるべき時に落ちるか、を先に確かめた
テストは3本とも、修正を入れる前に走らせて赤くなることを確認してから採用した。
古いテストのほうが厄介で、こう書いてあった。
test('空ボディ・空配列はどちらも空リスト', () async { /* ... */ });現状の挙動をそのまま固定している。これを消して書き直すのだが、消す前に「これは考えて決めたことなのか、そう動いていたから書いただけなのか」を見る必要があります。今回は後者だった(考えて決めた方針は、上の説明文のほうに書いてあって、そちらは逆を言っている)。
書き直したテストには、なぜ改訂したかをコメントで残した。
// 元は「空ボディも空配列と同じく空リスト」で固定していた(改訂)。
// 空リストは reconcile にとって「全部公開を取り消された」という破壊的な指示で、
// それを本文の無い応答から作ってはいけない。トレードオフは飲んだ
fail-closed に倒したので、サーバが契約から少しでも外れると同期が止まります。ユーザーには「同期に失敗しました」としか出ません。
それでいいと判断したのは、比べる相手が「家族の見ているページが空になる」だから。
ただしこれで、端末側だけが「photo は必ず正の整数」という約束を前提にした状態になった。その約束はサーバ側のコードにはどこにも書かれていません。片方だけ厳しくして、もう片方に伝えていません。
学び
- 「空」と「何も返ってきていない」を同じ値で表しません。この2つが同じ型に潰れると、事故が指示に化けます
- 戻り値の意味は、読み手が決めます。しかもこの厳しさが正しいのは、いまこのデコーダの読み手が1つしかないからで、別の用途でも使い始めた日には同じ厳しさが過剰になります。関数の安全さが、呼び出し元の数に乗っています
- 黙って捨てる処理(
whereType/if (x is T))は、捨てた事実がどこにも残りません。捨てた結果が「無かったこと」と同じ意味になる文脈では、捨てずに止めます
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。