Class: Transport::HttpGraphqlApiConnection
- Inherits:
-
Object
- Object
- Transport::HttpGraphqlApiConnection
- 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.
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
-
#logger ⇒ ActiveSupport::Logger
readonly
Logger instance, defaults to
Rails.logger. -
#profile ⇒ Object
readonly
Returns the value of attribute profile.
Instance Method Summary collapse
-
#build_request_headers ⇒ Hash{String => String}
Builds the canonical request headers for an authenticated call.
-
#fetch_token ⇒ String
Performs an OAuth2 client-credentials grant against
@profile[:auth_url]and returns the resulting access token. -
#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). -
#initialize(options = {}) ⇒ HttpGraphqlApiConnection
constructor
A new instance of HttpGraphqlApiConnection.
-
#send_data(data) ⇒ Hash{Symbol => Object}
Sends a GraphQL query or mutation against
@profile[:api_url]. -
#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]. -
#send_request(method, url, body = nil, headers = nil) ⇒ Hash{Symbol => Object}
Sends a single HTTP request through the Faraday #connection, whose
:retrymiddleware backs off and retries the timeout classes (3 attempts, 4ⁿ-second backoff). -
#successful?(http_res) ⇒ Boolean
Treats a response as successful when its status is 2xx and the parsed body has no GraphQL
errorsarray.
Constructor Details
#initialize(options = {}) ⇒ HttpGraphqlApiConnection
Returns a new instance of HttpGraphqlApiConnection.
42 43 44 45 46 47 48 49 50 51 |
# File 'app/services/transport/http_graphql_api_connection.rb', line 42 def initialize( = {}) @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 = [:logger] || Rails.logger end |
Instance Attribute Details
#logger ⇒ ActiveSupport::Logger (readonly)
Returns logger instance, defaults to Rails.logger.
33 34 35 |
# File 'app/services/transport/http_graphql_api_connection.rb', line 33 def logger @logger end |
#profile ⇒ Object (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_headers ⇒ Hash{String => String}
Builds the canonical request headers for an authenticated call. Fetches
a fresh Bearer token on every invocation.
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_token ⇒ String
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 bysend_request. - 4xx other than 429 (invalid creds, malformed request) is not
retried; the underlyingStandardErrorpropagates.
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..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).
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.
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).
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.
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.
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 |