Class: Facebook::ApiClient

Inherits:
BaseService show all
Defined in:
app/services/facebook/api_client.rb

Overview

Faraday-backed HTTP client for the Facebook/Meta Conversions API (CAPI).

Mirrors Pinterest::ApiClient and OpenaiAds::ApiClient — keeps
ConversionReporter free of HTTP concerns so we can stub it
cleanly in tests.

CAPI events POST to a Pixel (not an ad account): the URL takes the
pixel id as a path segment (/{pixel_id}/events). Auth is a System
User access token; Meta's docs show it as ?access_token= query param
but Bearer header also works and avoids leaking the token into request
logs — we follow the Bearer pattern used by Pinterest and OpenAI.

Constant Summary collapse

API_VERSION =

Marketing Graph API version. Bump in lockstep with
Facebook::AdvertiserApiClient::API_VERSION.

'v25.0'
BASE_URL =

URL for base.

"https://graph.facebook.com/#{API_VERSION}".freeze
TRANSIENT_META_ERROR_CODES =

Meta Graph API error codes that mean "retry later", not "your request
is wrong" — so they get retried (via ConversionRetrySweepWorker)
rather than paged as a hard error. Two families:

  • Temporary Meta-side faults: 1 ("An unknown error has occurred.")
    and 2 ("Service temporarily unavailable") — these dominated
    AppSignal #5248 once PR #1172's diagnostics made the codes visible.
  • Throttling / rate limits: 4 (app-level), 17 (user-level),
    32 (page-level), 613 (custom-level) — all transient back-pressure.
    Any HTTP 5xx is likewise transient.
[1, 2, 4, 17, 32, 613].freeze

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger

Constructor Details

This class inherits a constructor from BaseService

Instance Method Details

#send_events(pixel_id:, token:, events:, test_event_code: nil) ⇒ Hash

Send one or more conversion events to a Facebook Pixel.

Parameters:

  • pixel_id (String)

    the Facebook Pixel ID (path segment).

  • token (String)

    the Facebook CAPI System User access token.

  • events (Array<Hash>)

    event objects per Meta's spec
    (event_name, event_time, event_id, action_source,
    user_data, custom_data, etc.).

  • test_event_code (String, nil) (defaults to: nil)

    when set, Meta routes the event
    to the Test Events tab in Events Manager and does NOT count it
    toward attribution — used during canary rollout to verify schema
    acceptance before flipping to real sends.

Returns:

  • (Hash)

    { status: :reported|:test_event_ok|:rate_limited|:failed, http_status:, error: }



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'app/services/facebook/api_client.rb', line 53

def send_events(pixel_id:, token:, events:, test_event_code: nil)
  body = { data: events }
  body[:test_event_code] = test_event_code if test_event_code.present?

  response = connection(token).post("#{pixel_id}/events") do |req|
    req.body = body.to_json
  end

  case response.status
  when 200
    if test_event_code.present?
      Rails.logger.info "Facebook::ApiClient: test_event_code=#{test_event_code} payload accepted (HTTP 200)"
      { status: :test_event_ok, http_status: 200 }
    else
      Rails.logger.info 'Facebook::ApiClient: events accepted (HTTP 200)'
      { status: :reported, http_status: 200 }
    end
  when 429
    error_msg = 'Rate limited (HTTP 429)'
    Rails.logger.warn "Facebook::ApiClient: #{error_msg}"
    { status: :rate_limited, http_status: 429, error: error_msg }
  else
    body       = safe_parse(response.body)
    meta_error = body['error'].is_a?(Hash) ? body['error'] : {}
    error_msg  = meta_error['message'] || body['message'] || "HTTP #{response.status}"
    transient  = transient_meta_error?(response.status, meta_error['code'])
    # Surface Meta's diagnostic fields. "An unknown error has occurred." is
    # Meta's OWN generic message (Graph error code 1/2 = transient, "retry
    # later"); without the code/subcode/fbtrace_id the report is undiagnosable
    # (AppSignal #5248). Pass them through so transient-vs-real is visible.
    # `transient` keeps status :failed (so ConversionRetrySweepWorker still
    # re-enqueues it) while letting the reporter skip AppSignal noise for
    # these Meta-side blips — see {Facebook::ConversionReporter#send_events}.
    Rails.logger.public_send(transient ? :warn : :error,
      "Facebook::ApiClient: #{transient ? 'Transient' : 'Failed'} -- #{error_msg} " \
      "(code=#{meta_error['code']} subcode=#{meta_error['error_subcode']} " \
      "fbtrace_id=#{meta_error['fbtrace_id']} http=#{response.status})")
    {
      status:        :failed,
      transient:     transient,
      http_status:   response.status,
      error:         error_msg,
      error_code:    meta_error['code'],
      error_subcode: meta_error['error_subcode'],
      fbtrace_id:    meta_error['fbtrace_id']
    }
  end
rescue Faraday::TimeoutError => e
  # Network blip to Meta — keep status :failed so the sweep re-enqueues it,
  # flag transient so the reporter doesn't page AppSignal per attempt (#5248).
  { status: :failed, transient: true, http_status: nil, error: "Timeout: #{e.message}" }
rescue Faraday::ConnectionFailed => e
  { status: :failed, transient: true, http_status: nil, error: "Connection failed: #{e.message}" }
rescue Faraday::Error => e
  # Catch-all for the rest of the Faraday hierarchy — Faraday::SSLError
  # (TLS/cert misconfig) and friends are client-side defects, NOT transient
  # blips. Leave `transient` unset so the reporter pages AppSignal instead
  # of silently retrying a broken config forever.
  { status: :failed, http_status: nil, error: e.message }
end