Class: Transport::HttpGraphqlApiConnection

Inherits:
Object
  • Object
show all
Defined in:
app/services/transport/http_graphql_api_connection.rb

Overview

GraphQL transport over HTTP for partner APIs that authenticate via OAuth2
client-credentials and return JSON-RPC-style { data:, errors: [...] }
envelopes.

Currently used for Wayfair's developer API
(https://developer.wayfair.com/docs/#authentication). The transport handles:

  • Bearer-token retrieval and refresh in fetch_token.
  • GraphQL request envelope building in send_graphql_request.
  • One extra retry for inventory mutations, since the first call after a
    token refresh fails frequently on Wayfair's side.
  • Retry-aware error handling for HTTP 429 (with Retry-After) and 5xx
    server errors, with bounded exponential backoff.

Connection profiles live in Heatwave::Configuration and supply
:client_id, :client_secret, :api_url, and :auth_url.

See Also:

Constant Summary collapse

VALID_HTTP_METHODS =

HTTP verbs accepted by GraphQL transport methods; downcased on input.

%w[get put post patch delete head].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ HttpGraphqlApiConnection

Returns a new instance of HttpGraphqlApiConnection.

Parameters:

  • options (Hash) (defaults to: {})

    connection options.

Options Hash (options):

  • :profile (Symbol, String)

    name of a profile in
    Heatwave::Configuration to load (e.g. :wayfair).

  • :profile_attributes (Hash)

    inline profile hash, used when
    :profile is not provided.

  • :logger (ActiveSupport::Logger)

    logger instance; defaults
    to Rails.logger.



42
43
44
45
46
47
48
49
50
51
# File 'app/services/transport/http_graphql_api_connection.rb', line 42

def initialize(options = {})
  @options = options
  # if there's a profile, grab the headers to send from it
  if (profile = @options[:profile])
    @profile = Heatwave::Configuration.fetch(profile&.to_sym)
  elsif @options[:profile_attributes]
    @profile = @options[:profile_attributes]
  end
  @logger = options[:logger] || Rails.logger
end

Instance Attribute Details

#loggerActiveSupport::Logger (readonly)

Returns logger instance, defaults to Rails.logger.

Returns:

  • (ActiveSupport::Logger)

    logger instance, defaults to Rails.logger.



33
34
35
# File 'app/services/transport/http_graphql_api_connection.rb', line 33

def logger
  @logger
end

#profileObject (readonly)

Returns the value of attribute profile.



33
# File 'app/services/transport/http_graphql_api_connection.rb', line 33

attr_reader :logger, :profile

Instance Method Details

#build_request_headersHash{String => String}

Builds the canonical request headers for an authenticated call. Fetches
a fresh Bearer token on every invocation.

Returns:

  • (Hash{String => String})

    headers including content-type,
    cache-control, and authorization.



84
85
86
87
88
89
90
# File 'app/services/transport/http_graphql_api_connection.rb', line 84

def build_request_headers
  {
    'content-type': 'application/json',
    'cache-control': 'no-cache',
    authorization: format('Bearer %s', fetch_token)
  }.stringify_keys
end

#fetch_tokenString

Performs an OAuth2 client-credentials grant against @profile[:auth_url]
and returns the resulting access token.

Reads @profile[:client_id] and @profile[:client_secret] from the
connection's profile.

Logic Details

  • Honors HTTP 429 with Retry-After; sleeps the server-supplied seconds
    when ≤ 60, otherwise uses a 15s + 15s × attempt backoff.
  • Retries up to 5 attempts on 5xx, JSON parse failures, and the timeout
    classes already covered by send_request.
  • 4xx other than 429 (invalid creds, malformed request) is not
    retried; the underlying StandardError propagates.

Returns:

  • (String)

    OAuth2 access token.

Raises:

  • (HTTP::RateLimitExceededError)

    when 429 responses persist past 5
    attempts.

  • (JSON::ParserError)

    when the response body never parses to JSON.

  • (StandardError)

    when 5xx responses persist past 5 attempts, or
    for other non-retried failures.



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'app/services/transport/http_graphql_api_connection.rb', line 192

def fetch_token
  # Endpoint to authenticate for Wayfair’s API
  # Determine audience url based on the authentication url
  uri = Addressable::URI.parse(@profile[:api_url])
  audience = "#{uri.scheme}//#{uri.host}"
  auth_payload = {
    grant_type: 'client_credentials',
    client_id: @profile[:client_id],
    client_secret: @profile[:client_secret],
    audience:
  }.to_json
  # auth_payload = %{{
  #             "grant_type": "client_credentials",
  #             "client_id": "%s",
  #             "client_secret": "%s",
  #             "audience": "https://api.wayfair.com/"
  #           }} % [@profile[:client_id], @profile[:client_secret]]
  headers = {
    'content-type': 'application/json',
    'cache-control': 'no-cache'
  }.stringify_keys
  num_attempts = 0
  begin
    num_attempts += 1
    res = send_request('POST', @profile[:auth_url], auth_payload, headers)
    response = res[:http_res]

    # Basic guard
    raise StandardError, "Wayfair auth response was nil on attempt #{num_attempts}" if response.nil?

    # Honor explicit 429 backoff from server
    if response.status == 429
      retry_after = response.headers['Retry-After']&.to_i
      raise HTTP::RateLimitExceededError.new(
        status_code: 429,
        headers: response.headers,
        retry_after: retry_after,
        message: "429 from Wayfair auth; headers: #{response.headers.inspect} on attempt #{num_attempts}, retry_after=#{retry_after}"
      )
    end

    # Retry on 5xx server errors
    raise StandardError, "Wayfair auth non-success status #{response.status} on attempt #{num_attempts}" if response.status && response.status >= 500

    body_text = response.body.to_s
    token = JSON.parse(body_text)['access_token']
    raise JSON::ParserError, 'Missing access_token in Wayfair auth response' if token.blank?

    token
  rescue HTTP::RateLimitExceededError => e
    logger.error e if num_attempts.positive? && e
    raise unless num_attempts <= 5

    sleep_seconds = e.retry_after.to_i
    if sleep_seconds.positive? && sleep_seconds <= 60
      sleep(sleep_seconds)
    else
      sleep(15 + (15 * num_attempts))
    end
    retry
  rescue JSON::ParserError, *(Retryable::TIMEOUT_CLASSES - [HTTP::RateLimitExceededError]) => e
    # Defensive: send_request already retries timeouts; also retry parse errors for empty/invalid JSON
    logger.error e if num_attempts.positive? && e
    raise unless num_attempts <= 5

    sleep(15 + (15 * num_attempts))
    retry
  rescue StandardError => e
    # Retry generic 5xx-triggered errors; do not retry other 4xx (invalid creds, etc.)
    raise unless num_attempts <= 5 && e.message.to_s.include?('non-success status')

    logger.error e if num_attempts.positive?
    sleep(15 + (15 * num_attempts))
    retry
  end
end

#fetch_url_to_upload(url, file_name, category, resource) ⇒ Upload

Authenticates and forwards to Upload.uploadify_from_url, useful for
downloading and storing partner-hosted assets (e.g. packing slip PDFs).

Parameters:

  • url (String)

    remote URL to fetch.

  • file_name (String)

    desired file name on the local Upload record.

  • category (String)

    Upload category (mapped to Upload#category).

  • resource (ActiveRecord::Base)

    owning record for the Upload.

Returns:

  • (Upload)

    persisted Upload record.



100
101
102
# File 'app/services/transport/http_graphql_api_connection.rb', line 100

def fetch_url_to_upload(url, file_name, category, resource)
  Upload.uploadify_from_url(file_name:, url:, category:, resource:, headers: build_request_headers)
end

#send_data(data) ⇒ Hash{Symbol => Object}

Sends a GraphQL query or mutation against @profile[:api_url].

Inventory mutations get a single second attempt with the same token if
the first call fails — Wayfair frequently rejects the very first request
issued against a freshly minted token.

Parameters:

  • data (String)

    GraphQL query/mutation body (the query field, not
    the full JSON envelope).

Returns:

  • (Hash{Symbol => Object})

    response with :success (Boolean),
    :http_result (HTTP response or nil), and :attempt_number_reached
    (Integer).



64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'app/services/transport/http_graphql_api_connection.rb', line 64

def send_data(data)
  t = fetch_token
  res = send_graphql_request(t, data, 'null')
  http_res = res[:http_res]
  if data.index('inventory') && !successful?(http_res)
    # here (especially with inventory) it will fail often on the first use of a token,
    # so send request again using the same token
    res = send_graphql_request(t, data, 'null')
    http_res = res[:http_res]
  end
  attempt_number_reached = res[:attempt_number_reached]
  logger.debug("GraphQL request complete", method: 'POST', status: http_res&.status, attempts: attempt_number_reached)
  { success: successful?(http_res), http_result: http_res, attempt_number_reached: attempt_number_reached }
end

#send_graphql_request(token, query, variables) ⇒ Hash{Symbol => Object}

Wraps the GraphQL query/variables in the JSON envelope expected by the
partner GraphQL endpoint and posts it to @profile[:api_url].

The envelope is {"query": "…", "variables": …}; variables is passed
as a JSON string by callers ('null' for queries with no variables).

Parameters:

  • token (String)

    Bearer access token from fetch_token.

  • query (String)

    GraphQL query or mutation body.

  • variables (String)

    JSON-encoded variables object, or 'null'.

Returns:

  • (Hash{Symbol => Object})

    { http_res: Faraday::Response, attempt_number_reached: Integer }.



159
160
161
162
163
164
165
166
167
168
169
170
# File 'app/services/transport/http_graphql_api_connection.rb', line 159

def send_graphql_request(token, query, variables)
  # TODO: change this to prod when going live
  # Wayfair’s GraphQL API endpoint
  headers = {
    'content-type': 'application/json',
    'cache-control': 'no-cache',
    authorization: format('Bearer %s', token)
  }
  # Payload must be formatted as valid JSON, see https://www.json.org/
  graphql_payload = format(%({"query": "%s", "variables": %s}), query, variables)
  send_request('POST', @profile[:api_url], graphql_payload, headers)
end

#send_request(method, url, body = nil, headers = nil) ⇒ Hash{Symbol => Object}

Sends a single HTTP request through the Faraday #connection, whose
:retry middleware backs off and retries the timeout classes (3 attempts,
4ⁿ-second backoff). Body is required only for POST.

Parameters:

  • method (String)

    HTTP verb — 'GET' or 'POST'.

  • url (String)

    URL for the request.

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

    payload for the request, when applicable.

  • headers (Hash, nil) (defaults to: nil)

    additional headers (authorization,
    content-type, etc.).

Returns:

  • (Hash{Symbol => Object})

    { http_res: Faraday::Response, attempt_number_reached: Integer }.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'app/services/transport/http_graphql_api_connection.rb', line 133

def send_request(method, url, body = nil, headers = nil)
  body ||= ''
  # The :retry middleware on #connection replaces the old Retryable wrapper;
  # the retry_block counts retries so we still report attempt_number_reached.
  attempts = 1
  conn = connection(headers, retry_block: ->(**) { attempts += 1 })
  http_res =
    case method
    when 'POST' then conn.post(url, body)
    when 'GET'  then conn.get(url)
    end
  logger.debug("Transport::HttpGraphqlApiConnection send_request", url: url.to_s, method: method, attempts: attempts, status: http_res&.status)
  { http_res: http_res, attempt_number_reached: attempts }
end

#successful?(http_res) ⇒ Boolean

Treats a response as successful when its status is 2xx and the parsed
body has no GraphQL errors array.

Parameters:

  • http_res (Faraday::Response, nil)

    response object from a send_* call.

Returns:

  • (Boolean)

    true when the request both succeeded and had no GraphQL
    errors.



110
111
112
113
114
115
116
117
118
119
120
# File 'app/services/transport/http_graphql_api_connection.rb', line 110

def successful?(http_res)
  logger.info "successful? http_res.body: #{http_res.body}"
  return false unless http_res.status.between?(200, 299)

  data = begin
    JSON.parse(http_res.body).with_indifferent_access
  rescue StandardError
    {}
  end
  data[:errors].blank?
end