FCMのBatch Send APIがDEPRECATEDになったのでHTTP v1 APIに移行した話（今更）

こちらの記事は「[CBcloud Advent Calendar 2024](https://qiita.com/advent-calendar/2024/cbcloud)」11日目の記事となっております。
もし良ければ他の方の記事もご覧いただけるとエンジニア一同うれしく思います。

# はじめに
気がつけば2024年も終わりに近づく今日この頃、皆様いかがお過ごしでしょうか？(挨拶)

CBcloudにjoinしてから一年半ほど経過しましたが、今までのキャリアでは得ることがなかった学びも多く刺激の多いエンジニアライフを過ごしています。今まで色々な機能改修やリリースに携わってきた中で特に印象に残っている話として、FCMのプッシュ通知配信処理を改修したときの話をせっかくの年の瀬なので備忘録も兼ねて書かせてもらえればと思います。


# 何が起きたか
2024年8月あたりからFCMのプッシュ通知配信リクエストに対してこんなエラーレスポンスが返ってくることがしばしば起きるようになっていました。

```
HTTP/1.1 501 Not Implemented
Vary: Origin
Vary: X-Origin
Vary: Referer
Content-Type: application/json; charset=UTF-8

{
  "error": {
    "code": 501,
    "message": "Operation is not implemented, or supported, or enabled.",
    "status": "UNIMPLEMENTED"
  }
}
```

この時点では常にこのレスポンスが返ってくるわけではなく今まで通り200番レスポンスを返すこともあり、「ユーザにプッシュ通知が全く飛ばない！」みたいなことにはなっていなかったので「FCMサーバ側の一時的な障害かな？」程度にしか考えていませんでした。しかし数日経ってもFCM公式から障害情報が出ることもなく流石におかしい、と思って調査に乗り出しました。

# どうしてこうなった
結論から言うと配信リクエストとして叩いていたBatch Send APIがDEPRECATEDになっていただけでした。
（まぁ記事タイトルで書いているので大方の予想通りであるとは思いますが）

https://firebase.google.com/docs/cloud-messaging/migrate-v1?hl=ja

> HTTP と XMPP に非推奨の FCM レガシー API を使用しているアプリは、できるだけ早く HTTP v1 API に移行する必要があります。 これらの API を使用したメッセージ（アップストリーム メッセージを含む）の送信は、2023 年 6 月 20 日に非推奨となっており、2024 年 7 月 22 日に廃止されます。


元々はこんな感じの実装で、まさにDEPRECATEDなAPIを叩いていたから501エラーが返ってきていただけでした。そりゃそうだ。
(廃止日が過ぎてからも普通にリクエストが通ることもあったのはどういうことなのか今だに謎ではありますが:thinking:)

```ruby
### ！！！！この方法はDEPRECATEDな方法です！！！！ ###

# メッセージ配信のエンドポイント
project_id = 'my_project_id'
message_endpoint = "https://fcm.googleapis.com/v1/projects/#{project_id}/messages:send"
access_token = 'access_token' # FCM HTTP v1 APIの認証トークン

# 配信対象となるFCMトークン一覧
tokens = %w(device1 device2 device3 ...)

# 各token用にメッセージを構築
push_messages = tokens.map do |token|
  <<~"BOUNDARY"
    --subrequest_boundary
    Content-Type: application/http
    Content-Transfer-Encoding: binary
    Authorization: Bearer #{access_token}

    POST #{message_endpoint}
    Content-Type: application/json
    accept: application/json

    #{
      {
        message: {
          token: token,
          notification: {
            title: '通知タイトル',
            body: '通知本文',
          },
        },
      }.to_json
    }
  BOUNDARY
end
push_messages << '--subrequest_boundary--' # バッチリクエストの終端文字列

# バッチリクエスト実行
uri = URI.parse('https://fcm.googleapis.com/batch')
request = Net::HTTP::Post.new(uri)
request.content_type = 'multipart/mixed; boundary="subrequest_boundary"'
request.body = push_messages.join("\r\n")

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end
```


# どう対応したか
FCM SDKを使っている場合は最新版に更新することで対応できた(らしい)のですが、弊社システムではREST APIを直接叩く実装であるためその方法は使えませんでした。
（そもそも開発言語がRubyなので現行バージョンに沿ったSDKが存在しない）

したがって「配信対象となる端末それぞれに対して配信リクエストを飛ばす」ような実装を自前で用意する必要がありました。それに加えて1回のプッシュ通知に対して配信対象が数千件以上にもなる場合もあり1件1件ずつ順次APIを叩いていくわけにもいかなかったので、並列的にHTTPリクエストを飛ばす仕組みを実装しました。

## 実装コード
まずHTTPリクエストを並列実行できるように`faraday-typhoeus`gemをinstallします。
```ruby:Gemfile
gem 'faraday'
gem 'faraday-typhoeus'
```

インストールした`typhoeus`アダプタを使ってHTTPクライアントを生成します。
```ruby
require 'faraday/typhoeus'

connection = Faraday.new do |builder|
    builder.request :json
    builder.adapter :typhoeus
end
```

あとはそのHTTPクライアントを使って一括リクエストするだけ。
```ruby
# メッセージ配信のエンドポイント
project_id = 'my_project_id'
message_endpoint = "https://fcm.googleapis.com/v1/projects/#{project_id}/messages:send"
access_token = 'access_token' # FCM HTTP v1 APIの認証トークン

# 配信対象となるFCMトークン一覧
tokens = %w(device1 device2 device3 ...)

# 一括リクエスト実行
responses = [] # それぞれのレスポンスを格納する配列
connection.in_parallel do
    tokens.each do |token|
        request_body = {
            message: {
                token: token,
                notification: {
                    title: '通知タイトル',
                    body: '通知本文',
                }
            },
        }
        
        api_response = connection.post(
            message_endpoint,
            request_body,
            {
                'Content-Type' => 'application/json',
                'Authorization' => "Bearer #{access_token}",
            },
        )
        
        responses << [token, api_response]
    end
end

# 配信リクエストが成功しているかをそれぞれ確認する
responses.each do |(token, response)|
    next if response.success?
    
    # 404などのエラーハンドリング
    ...
end
```

上記改修をリリースすることにより501エラーも出なくなりました。よかったよかった:hugging_face:


# 終わりに

今回の事象は迅速に原因調査および改修を進める必要があるものだったので、かなり肝を潰す思いで対応したのを強烈に覚えています。サービス品質にも影響するような不具合だったので、致命的になる前に気づけたのは不幸中の幸いでした。
もちろん本来であればエラーが出る前に検知して対処すべき事象であることは間違いないので、ちゃんと外部APIに対するアンテナを張っておかないといけないな、と痛感させられました。

# 余談

501エラーが出る原因について調査していたときにFCMの公式ドキュメントも確認していたのですが、ドキュメント **(日本語版)** ではBatch Send APIを使う方法が特に注釈なしで掲載されていました:innocent: 
英語ドキュメントではちゃんとDEPRECATEDになった旨が書かれていたのでそこで気づけたのですが、もし参照していなかったらどうなっていたか・・・。

https://firebase.google.com/docs/cloud-messaging/send-message?_gl=1*ubo44n*_up*MQ..*_ga*MTExOTQ1ODM4Ny4xNzMzMjA4NTgw*_ga_CW55HF8NVT*MTczMzIwODU3OS4xLjAuMTczMzIwODU3OS4wLjAuMA..#send-messages-to-multiple-devices

> Important: The send methods described in this section were deprecated on June 21, 2023, and will be removed in June 2024. For protocol, instead use the standard HTTP v1 API send method, implementing your own batch send by iterating through the list of recipients and sending to each recipient's token. For Admin SDK methods, make sure to update to the next major version. See the Firebase FAQ for more information.

**教訓：公式ドキュメントを参照するときは原本を参照するようにしよう！**

## さらに余談
さすがに本記事を執筆した時には注釈が追加されてました。

https://firebase.google.com/docs/cloud-messaging/send-message?hl=ja&_gl=1*ubo44n*_up*MQ..*_ga*MTExOTQ1ODM4Ny4xNzMzMjA4NTgw*_ga_CW55HF8NVT*MTczMzIwODU3OS4xLjAuMTczMzIwODU3OS4wLjAuMA..#send-messages-to-multiple-devices

> 重要: このセクションで説明する送信方法は 2023 年 6 月 21 日に非推奨となり、2024 年 6 月に削除されます。 プロトコルには、代わりに標準の HTTP v1 API 送信メソッドを使用し、受信者のリストを反復処理して各受信者のトークンに送信することで、独自のバッチ実装を行います。Admin SDK メソッドの場合は、次のメジャー バージョンに更新してください。 詳しくは、Firebase のよくある質問をご覧ください。