タスクはシンプルだった。コードホスティングプラットフォームからファイル添付をダウンロードする。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