Feedforce Developer Blog

フィードフォース開発者ブログ

Ruby を 3.2 にアップデートしたら、net-httpの変更にハマった話

年の瀬の風物詩といえば、Rubyのリリースです。先日Ruby 3.3がリリースされましたが、このほどEC Boosterでは、Rubyのバージョンを3.0から3.2へとアップデートしました。一つずつ上げろよという話もありますが、ともあれアップデートできたのはよいことです。

今回は、そのRubyアップデートの途上で出会ったトラブルをご紹介します。事象としては、特定のサーバからのファイルダウンロードができなくなった、というものでした。

EC Boosterにおけるファイルダウンロードの重要性

EC BoosterはECカートシステムからECサイトの商品データを取得し、Google ショッピング広告などの運用を行うサービスです。

ecbooster.jp

ECカートシステムとの商品データの連携方法には、大きく分けて「APIにより取得するもの」と「ファイルで受け渡しを行うもの」があります。後者の具体的な方法の中には、HTTPSでCSVファイルをダウンロードするものもあり、今回のトラブルでは、そうしたECカートシステムとの間で商品データ連携がストップしてしまいました。

商品データのCSVがダウンロードできなくなると、広告に出す商品データが更新できなくなってしまいます。新しく追加した商品や、商品説明を更新した内容などが検索結果に反映されず、お客様の商品の売り時を逃してしまうので大変です。

エラーの詳細

さて、EC BoosterでCSVファイルをダウンロードする際には、ファイルを最後までDLできたか確かめる仕組みが備わっていました。どういうものかというと、HTTPによるダウンロードでは、まずHEADリクエストを行い、 Content-Length ヘッダの値を取得して商品データのファイルサイズを特定、次にGETリクエストを行い、取得できたデータのサイズと比較する、という仕組みでした。

本番環境で発生したエラーのうちの一つをよく見ると、HEADリクエストで取得した Content-Length の値と、GETリクエストで取得できたファイルのサイズにズレがあり、正常にダウンロードできた判定になっていない、という状況が起きているようでした。

この機序のエラーは特定のサーバでのみ発生していたので、本番環境でリクエストしていたURLにcurlでHEADリクエストを送信してみました。すると、得られたContent-Length ヘッダの値は、確かにEC Boosterの実装で得られる値とは異なっていました。

さらに、CSVダウンロードでもエラーになっていないケースもあったので、他のサーバに対してもcurlと既存実装の違いを確かめてみました。すると、サーバによってContent-Lengthの応答が異なることがわかってきました。以下にその例を示します。

相手先サーバ curlによる値 EC Booster実装による値
ECカートA 12859 2371
ECカートB 59959289 (Content-Lengthなし)
Amazon S3 20180 20180

実際のファイルサイズよりも少ない値を返すケースがあることで、Accept-Encodingによる圧縮が関係していそうだなというアタリがついてきました。

net-http の差分と解決方法

ところで、EC Booster でファイルダウンロードを行う際には、Ruby標準添付ライブラリのnet-httpを使ってHTTPリクエストをしています。 net-httpのリリースノートをよく読むと、0.2.2のリリース時にHEADリクエストのAccept-Encodingについてのバグ修正が含まれていました。この差分は、Ruby 3.2 までに取り込まれています。

bugs.ruby-lang.org

バグチケットに曰く、net-httpによるリクエストはデフォルトのAccept-Encodingが付与されていることが大半なのだから、HEADリクエストを例外にする意味はないのではないか、と。この修正の結果、HEADリクエストにもnet-httpデフォルトのAccept-Encodingヘッダが付与されるようになり、サーバはそれに対応した値を返していた、ということでした。1

さて、GETリクエストの前にHEADリクエストを送信していたのは、Content-Lengthヘッダの値を通じてファイルサイズを事前に知ることが目的でした。そこに立ち返ると、圧縮前のファイルサイズを知りたいので、curlでやったのと同じようにAccept-Encodingなしでリクエストを送ればよさそうです。

net-httpにおいては、リクエストヘッダはデフォルト値に対するオーバーライドで設定されるので、空ハッシュではなく同じキーで空文字をセットします。こんな具合です。

uri = URI::parse('[http://example.com')](http://example.com%27%29/)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true if uri.port == 443
res = http.head(uri.request_uri, 'accept-encoding' => '')
res.content_length #=> 圧縮前のレスポンスボディのサイズが得られる

というわけで、net-http 0.2.2 以降においては、Accept-EncodingなしでHEADリクエストを送信することで、非圧縮のレスポンスボディのサイズを知ることができるようになりました。

おわりに

最後に、どうしてこれが本番にリリースするまで気付けなかったのか、という点に触れます。

「ファイルを最後までDLできたか確かめる仕組み」のテストには、Content-Lengthで得た値と同じサイズのファイルが作られる、サイズが異なっていたらリトライする、というテストが書いてありました。しかし、HEADリクエストのレスポンスをモックしていたので、本番に出すまでデフォルトのAccept-Encodingの変更に気づけませんでした。

あるいはリリースノートを丹念に読んでいれば事前に気づけたかもしれず、悔しいトラブルではありましたが、久々にいいトラブルシューティングができた感触があったのでこうして記事にしました。

フィードフォースは今日12/29が仕事納めです。みなさんよいお年を!


  1. 同時期に「Net::HTTP#getはContent-Encodingがあればレスポンスを展開するのだから、Content-Lengthは展開後の値であるべきだ」という議論も行われ、その提言通り実装されています。その結果、「HEADのContent-Length -> 圧縮済みの値」、「GETのContent-Length -> 展開した値」というズレが生じています。ただそれぞれのチケットを見るとどちらも事情は理解できる
    Bug #16672: net/http leaves original content-length header intact after inflating response - Ruby master - Ruby Issue Tracking System