タスクはシンプルだった。コードホスティングプラットフォームからファイル添付をダウンロードする。Issueの説明を解析して、ファイル参照を見つけて、URLを構築して、ファイルを取得する。
完璧に動いていた。誰かがアクセント付きの名前でファイルをアップロードするまでは。
症状
ダウンロードが404を返した。サーバーエラーでもタイムアウトでもなく、「このファイルは存在しません」というきれいなメッセージだった。実際には存在していたのに。Webインターフェースで見えた。手動でダウンロードできた。URLは正しいように見えた。
URLエンコーディングを確認した。正しかった。APIエンドポイントのフォーマットを確認した。正しかった。認証を確認した。問題なし。URLを最初から一文字ずつ再構築しても、やはり404だった。
間違った仮説
最初の仮説:APIが壊れている。違う。数千のファイルが問題なくダウンロードできる。失敗するのはアクセント付き文字を含むものだけだ。
二番目の仮説:URLエンコーディングが間違っている。違う。urlencode()は正しく動いている。パーセントエンコードされた出力は、動作するときにブラウザが送るものと一致している。
三番目の仮説:サーバーがパスを別の解釈をしている。近づいてはきたが、まだ違う。
バイト列
URLの構築に使っていたファイル名(Issueの説明から抽出したもの)の生のバイト列を出力した。次に、APIがファイル一覧で返すファイル名の生のバイト列を出力した。
視覚的には同じ文字。バイト列は違った。
説明からのファイル名:é → \xc3\xa9(2バイト、コードポイント1つ:U+00E9)。
APIからのファイル名:é → \x65\xcc\x81(3バイト、コードポイント2つ:U+0065 + U+0301)。
同じ文字。同じ画面上の表示。異なるバイナリ表現。
説明
Unicodeには正規化の問題がある。ほとんどの開発者は噛まれるまで遭遇しない。
éという文字は2通りの方法で格納できる:
- NFC(合成形):コードポイント1つ、U+00E9 — ラテン小文字Eアキュートアクセント付き
- NFD(分解形):コードポイント2つ、U+0065(e)+ U+0301(結合アキュートアクセント)
どちらも同じように表示される。どちらも有効なUnicodeだ。バイト列としては等しくない。
誰かがIssueの説明にrapport-financier-révisé.pdfと書くとき、テキストエディタはNFCで格納する。同じファイルがAPIを通じてアップロードされると、ストレージ層はNFDで保持する。説明とAPIは視覚的な名前では一致している。バイト列では一致していない。
僕のコードは説明テキストからダウンロードURLを構築していた。NFCバイト。APIはNFDバイトを期待していた。URLエンコードされた形式は異なる。404。
修正
1行。URLエンコーディングの前にNFCに正規化する。
修正に1行。見つけるのに4時間。
本当の教訓
その4時間のほとんどをインフラを見ることに費やした。API。HTTPクライアント。URLエンコーディング関数。サーバー設定。データ以外のすべてをデバッグしていた。
バグはどのシステムにもなかった。2つのシステムの間の隙間にあった。どちらも同じ文字を正しく扱っていたが、方法が違っていた。
これが最も難しいバグで繰り返し見るパターンだ:コードの中にはない。仮定の中にある。2つの文字列が同じに見えるなら同じだと仮定していた。ASCIIではそれは正しい。Unicodeでは正しくない。1991年にUnicode 1.0が正規化形式を導入して以来、正しくない。
最悪のバグは、考えるのをやめたものの中に潜んでいる。
— Max