Google Ads, Yahoo!広告のAPIリクエストとエラー対策でがんばったこと

はじめまして!
エージェンシー事業でリードアプリケーションエンジニアをしている福堀です。

担当サービスでは数十の広告媒体のレポートデータをRailsのResqueを利用したバッチ処理でAPI等から取得しています。
このサービスは立ち上げから7年以上経過しており、私自身も携わってから4年程経ちましたがその間に様々な苦労や工夫したことがありました。
その中から今回はGoogle AdsおよびYahoo!広告のAPIの仕様・実装例についてとエラーが頻発したAPIに対してどのように対応したかを紹介します。

担当サービスではGoogle AdsのレポートをGoogle Ads APIを利用して取得しています。
単純にAPIを叩くのではなく公開されているgemを利用してクエリを実行するという少し特殊な方法になります。
公式のリファレンスはあるものの情報量が多く、必要な情報だけをネットで探しても中々見つからなかった為今回とりあげました。

GemとAPIバージョン

まず、取得にはgoogle-ads-googleadsというgemパッケージを利用しています。
こちらはGoogle Ads APIをより使いやすくする為にGoogleが提供しているライブラリになります。

gemのバージョンとAPIバージョンが連動しているため、利用するAPIバージョンを上げる為にはこのgemのバージョンを上げる必要があります。
こちらのリファレンスの通りAPIの更新頻度が高く、リリースからサポート終了までが3ヶ月ほど、廃止までが1年ほどしかありません。
以前気づいたら利用しているバージョンが廃止間近になっていた、ということもあったためGoogleに限らず利用しているAPIの更新情報は毎月確認するようにしています。

APIの実行について

実際にAPIを叩いてレスポンスを取得する際はこのgemを利用するわけですが、認証からレスポンスの取得までの処理の簡単な流れを紹介します。
認証に必要なtoken等の情報は自動的に生成されるgoogle_ads_config.rbを利用するのですが今回は認証周りは割愛させて頂きます。
まずはこのconfigファイルのパスを指定してインスタンスを作成し、クライアント情報とクエリ文を与えます。

config_file = Rails.root.join('config', 'google_ads_config.rb').to_s
@ads ||= Google::Ads::GoogleAds::GoogleAdsClient.new(config_file)
  
@ga_service = @ads.service.google_ads
response = @ga_service.search(customer_id: @client_id.to_s, query: query)

ここでレポートを取得するためのクエリについてですが、こちらのリファレンスの通り150を超えるresourceが用意されており、必要に応じたクエリ文を用意する必要があります。
私達のサービスではGoogle Adsの何種類かのレポートを取得していますが、その一例として以下のようなクエリを用意しています。

def query
  search_query = <<~QUERY
    SELECT
      segments.date,
      customer.id,
      customer.descriptive_name,
      campaign.id,
      campaign.name,
      ad_group.id,
      ad_group.name,
      ad_group_ad.ad.id,
      ad_group_ad.ad.image_ad.name,
      ad_group_ad.ad.text_ad.headline,
      ad_group_ad.ad.text_ad.description1,
      ad_group_ad.ad.text_ad.description2,
      ad_group_ad.ad.final_urls,
      metrics.impressions,
      metrics.clicks,
      metrics.conversions,
      metrics.cost_micros,
      metrics.conversions_value,
      metrics.video_views,
      metrics.video_quartile_p25_rate,
      metrics.video_quartile_p50_rate,
      metrics.video_quartile_p75_rate,
      metrics.video_quartile_p100_rate,
      metrics.absolute_top_impression_percentage,
      metrics.top_impression_percentage,
      ad_group_ad.ad.image_ad.pixel_height,
      ad_group_ad.ad.image_ad.pixel_width,
      ad_group_ad.ad.name
    FROM
      ad_group_ad
    WHERE
      segments.date BETWEEN '#{@start_date}' AND '#{@end_date}'
  QUERY

  search_query
end

リファレンスにある通り特定のresourceに対して複数のresouceが紐付いておりそれらの情報をまとめて取得することができます。
その為、例えばad, ad_group, campaign等の階層の違う属性情報を取得したい場合でも1つのクエリで完結させることができます。 クエリの整合性がとれているかどうかはリファレンスの中のクエリをチェックするGoogle Ads Query Builderや各種APIのコードサンプルが用意されており、これらを参考にクエリを作成することができます。 ただ、意図するレスポンスが返ってくるかどうかはこのツールではわからないため、必要な情報を取得するためにどのようなクエリを発行すべきかは実際にAPIを実行して実データを確認する必要がありました。

例外処理について

例外発生時の処理についてはこちらのリファレンスの通りに実装しています。
こちらについてはかなり細かくエラーコードが定められており、各エラー毎の処理が可能です。
例えば対象のアカウント(customer)が無効の場合の処理を以下のように行っています。

rescue Google::Ads::GoogleAds::Errors::GoogleAdsError => e
  e.failure.errors.each do |error|
    error.error_code.to_h.each do |type, code|
      next unless type == :authorization_error && code == :CUSTOMER_NOT_ENABLED

      # 処理
    end
  end
end

Yahoo!広告

続いてYahoo!広告についてになります。
こちらのレポート取得APIの特徴としては実行してすぐにレポート情報が取得できるわけでないところです。

レポート取得の流れ

レポート取得の流れとしては以下の手順になります。

  1. レポート作成のAPI実行
  2. 発行されたjob_idのステータスが「COMPLETED」になるまでステータスを確認するAPIを複数回実行
  3. ステータスが「COMPLETED」になったらレポートを取得するAPIを実行

以下、それぞれの手順について実装の一部を紹介します。
またYahoo!広告には検索広告とディスプレイ広告がありますが、今回の例は検索広告についてになります。

レポート作成

リファレンスの通りに必要な情報を渡してレポート作成のAPIを実行します。
その後のステータスの確認やレポート取得の為に、発行されたreportJobIdを控えます。

call_api(
  :report_definition_service_add,
  Report::YahooDisplayNetwork::Content.send(
    :report_definition_mutate_add,
    account_id,
    start_date,
    end_date
  ),
  token_id
) do |result|
    result_to_hash = JSON.parse(result)
    # 発行時に該当のアカウントでエラーがあるか確認
    error_handling(result_to_hash) if is_error?(result_to_hash)

    result_to_hash['rval']['values'][0]['reportDefinition']['reportJobId'].to_s
  end

ステータスの確認

前述した通り、ステータスが「COMPLETED」になるまではレポートは取得できません。
レポート取得のAPIを実行する前にステータスを確認するAPI(リファレンス)を実行して待機しています。

以前にステータスがいつまでたっても「COMPLETED」にならないという事象に遭遇しました。
そのようなケースが発生した場合にずっと待機状態のままになってしまうことを避けるために60秒 * 30回を上限として、超えてしまった場合はResqueエラーとする対応をとっています。
また、リトライ時にログを出力することで後から状況を確認できるようにしています。

  def wait_job_state_completed
    # 合計30回リトライする
    30.times do |i|
      break if scraper.get_job_id_state(@report_job_id) == 'COMPLETED'

      sleep 60

      logger.warn([Time.now, adnetwork_name.camelize, account_id, account_name, 'report wait:', i].join("\t")) if i > 5
      next unless i == 29

      logger.error([Time.now, adnetwork_name.camelize, account_id, account_name,
                    'Give up request state is not COMPLETED'].join("\t"))
      raise 'Give up request state is not COMPLETED'
    end
  end

レポート取得

ステータスが「COMPLETED」になったらレポート取得のAPI(リファレンス)を実行します。
これでやっとレポートのデータが取得できます。

  def download_report(report_job_id)
    call_api(
      :report_definition_service_download,
      Report::YahooDisplayNetwork::Content.report_get(account_id, report_job_id),
      token_id
    ) do |result|
      # NOTE: 戻り値 … 正常: CSV, 例外: JSON
      result_to_hash = JSON.parse(result)
      error_handling(result_to_hash) if is_error?(result_to_hash)
    rescue JSON::ParserError => e
      result
    end
  end

エラーが頻発するAPIに対していろいろがんばって対応した話

続いては様々な例外やエラー、突然の仕様変更等々が発生した場合に対応した方法をいくつか紹介します。
前提として発生した事象については先方へ問い合わせ等を行った上でクライアント側でできる限りの対応を行った内容になります。

特定のレスポンスが不正

レスポンスの内容がエラーでは無いのですが返ってくるべき値が入ってなく、空の要素もしくは0が返ってくるケースが発生しました。Resqueで90秒間隔でリトライ処理もしていましたが、それでも同じ状態でした。
この現象はバッチ処理を行っている早朝の時間帯で発生するものの、日中に再度リトライすると発生しないという状態でした。
この問題を解消する為に通常は90秒に設定しているResqueのリトライ間隔をこの事象が発生した場合にのみ限定して1時間にするという対応をとりました。
結果何度かのリトライで時間経過によってこの事象が解消されて意図するレスポンスが取得できる状態になりました。

Resqueリトライとは別に独自にリトライを実装

レポート取得の際に1回のResqueのジョブの中で複数回のAPIリクエストを実行しています。
Resqueのリトライ機能を利用していますが、複数回のAPIリクエストの内1回でも失敗してしまうとジョブを最初からやり直してしまうことになります。
この事象を回避する為にResqueのリトライとは別にリクエスト単位でエラーを検知してリトライする機能を実装しました。

workerを分離することによる他のジョブへの影響の回避

様々な対策を講じてきましたが、それでも急な仕様変更や突発的に発生する未知のエラーは避けきれません。
そのような状況になった場合に私達が一番困ることは他の媒体への影響が発生することでした。

これを回避する為に使用するworkerを分けるという対策をとっています。
こちらの公式のサンプルにある通り、Resqueではキューの名前を指定することができます。
一般的な媒体は主にcommon_workerという名前でキューを作っていますが、エラーの出やすい媒体では別名を定義してキューを作っています。
こうすることで最悪特定の媒体に不具合が発生してキュー詰まりが発生しても他の媒体に影響が出ないような作りになっています。

まとめ

今回はAPI仕様の話とエラーの対応について紹介させて頂きました。
広告媒体のレポート取得やAPIの例外、Resqueでのエラーの対応策等についての参考にできれば幸いです。