Class: Transport::HttpWalmartSellerApiConnection

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

Overview

Service object: http walmart seller api connection.

Constant Summary collapse

VALID_HTTP_METHODS =

Valid http methods.

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

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ HttpWalmartSellerApiConnection

Returns a new instance of HttpWalmartSellerApiConnection.



19
20
21
22
23
24
25
26
27
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 19

def initialize(options = {})
  @options = options
  if options[:profile_data]
    @profile = options[:profile_data]
  elsif (profile = @options[:profile])
    @profile = Heatwave::Configuration.fetch(profile&.to_sym)
  end
  @logger = options[:logger] || Rails.logger
end

Instance Attribute Details

#loggerObject (readonly)

Returns the value of attribute logger.



17
18
19
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 17

def logger
  @logger
end

#profileObject (readonly)

Returns the value of attribute profile.



17
18
19
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 17

def profile
  @profile
end

Instance Method Details

#fetch_access_tokenString

Fetches a short-lived Walmart access token (client-credentials grant) for the
+WM_SEC.ACCESS_TOKEN+ header. The detached block above shows a sample
request/response.

Returns:

  • (String)

    the Walmart access token

Raises:

  • (HTTP::RateLimitExceededError)

    if the token endpoint keeps returning 429

See Also:



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 242

def fetch_access_token
  auth_payload = 'grant_type=client_credentials'
  headers = {
    'accept' => 'application/json',
    'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials(profile[:client_id], profile[:client_secret]),
    'Content-Type' => 'application/x-www-form-urlencoded',
    'WM_QOS.CORRELATION_ID' => SecureRandom.uuid,
    'WM_SVC.NAME' => 'Walmart Marketplace'
  }
  # Add WM_MARKET header for non-US markets (e.g., 'ca' for Canada)
  # See: https://developer.walmart.com/ca-marketplace/docs/authentication
  headers['WM_MARKET'] = profile[:market] if profile[:market].present?
  # Add WM_CONSUMER.CHANNEL.TYPE header (required for Canada)
  headers['WM_CONSUMER.CHANNEL.TYPE'] = profile[:channel_type] if profile[:channel_type].present?

  res = send_request('POST', @profile[:auth_url], auth_payload, headers)
  response = res[:http_res]
  # Faraday::Response#to_s is NOT the body (http.rb's was) — read #body.
  JSON.parse(response.body)['access_token']
end

#send_authenticated_request(token, method, data, url, headers = {}) ⇒ Hash

Merges the Walmart authentication and QOS headers (access token, correlation
id, service name, and the Canada-specific WM_MARKET / WM_CONSUMER.CHANNEL.TYPE)
onto the caller's headers, then issues the request via #send_request.

Parameters:

  • token (String)

    the Walmart access token (WM_SEC.ACCESS_TOKEN)

  • method (String)

    the HTTP verb

  • data (String, Hash)

    the request body, or a multipart hash for feed uploads

  • url (String)

    the absolute Walmart endpoint

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

    caller headers (accept / Content-Type win over the defaults)

Returns:

  • (Hash)

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

Raises:

  • (HTTP::RateLimitExceededError)

    propagated from #send_request on a persistent 429



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 142

def send_authenticated_request(token, method, data, url, headers = {})
  # See: https://developer.walmart.com/us-marketplace/reference/tokenapi
  ac = headers.dig('accept') || 'application/json' # allow accept in headers to prevail
  ct = headers.dig('Content-Type') || 'application/json' # allow Content-Type in headers to prevail
  headers_for_request = headers.merge({
    'WM_SEC.ACCESS_TOKEN' => token,
    'WM_QOS.CORRELATION_ID' => SecureRandom.uuid,
    'WM_SVC.NAME' => 'Walmart Marketplace',
    'accept' => ac,
    'Content-Type' => ct
  })
  # Add WM_MARKET header for non-US markets (e.g., 'ca' for Canada)
  # See: https://developer.walmart.com/ca-marketplace/docs/authentication
  headers_for_request['WM_MARKET'] = profile[:market] if profile[:market].present?
  # Add WM_CONSUMER.CHANNEL.TYPE header (required for Canada)
  headers_for_request['WM_CONSUMER.CHANNEL.TYPE'] = profile[:channel_type] if profile[:channel_type].present?

  send_request(method, url, data, headers_for_request)
end

#send_data(data, url, method, headers = {}) ⇒ Hash

Sends +data+ to a Walmart Marketplace endpoint, authenticating with a freshly
fetched access token, and returns the parsed result envelope.

Parameters:

  • data (String)

    the request body (JSON for most endpoints)

  • url (String)

    the absolute Walmart Marketplace endpoint

  • method (String)

    the HTTP verb (e.g. 'POST', 'GET', 'delete')

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

    extra request headers, merged onto the Walmart auth headers

Returns:

  • (Hash)

    +{ success: Boolean, http_result: Faraday::Response, attempt_number_reached: Integer }+

Raises:

  • (HTTP::RateLimitExceededError)

    if the API keeps returning 429 after the in-band retries



38
39
40
41
42
43
44
45
46
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 38

def send_data(data, url, method, headers = {})
  t = fetch_access_token
  URI.parse(url)
  res = send_authenticated_request(t, method, data, url, headers)
  http_res = res[:http_res]
  attempt_number_reached = res[:attempt_number_reached]
  logger.debug { "Walmart Seller API request complete url=#{url} method=#{method} attempts=#{attempt_number_reached} status=#{http_res&.status}" }
  { success: successful?(http_res), http_result: http_res, attempt_number_reached: attempt_number_reached }
end

#send_feed_data(data, url, method = 'POST', headers = {}) ⇒ Hash

Uploads +data+ as a multipart feed file. The Walmart bulk-feed endpoints
require +multipart/form-data+, so the payload is written to a Tempfile and
sent as a Faraday::Multipart::FilePart (which faraday-retry rewinds and
re-sends on retry).

Parameters:

  • data (String)

    the feed payload (JSON or XML) to upload as a file

  • url (String)

    the absolute Walmart feed endpoint

  • method (String) (defaults to: 'POST')

    the HTTP verb (defaults to 'POST')

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

    extra request headers, merged onto the multipart feed headers

Returns:

  • (Hash)

    +{ success: Boolean, http_result: Faraday::Response, attempt_number_reached: Integer }+

Raises:

  • (HTTP::RateLimitExceededError)

    if the API keeps returning 429 after the in-band retries



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
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 59

def send_feed_data(data, url, method = 'POST', headers = {})
  t = fetch_access_token
  URI.parse(url)
  headers_for_feed_file_post = headers.merge({
    'accept' => 'application/json',
    'Content-Type' => 'multipart/form-data'
  })
  # Build JSON file to send
  filename = "walmart_feed_#{Time.current.to_i}.json"
  # Create a temporary file with the JSON data
  tempfile = Tempfile.new(filename)
  begin
    tempfile.write(data)
    tempfile.rewind
    tempfile.flush
    tempfile.fsync

    # Faraday multipart part — 'application/octet-stream' matches what
    # HTTP::FormData::File produced for this .json feed file before the migration.
    file = Faraday::Multipart::FilePart.new(tempfile.path, 'application/octet-stream', filename)
    feed_data = { file: file }

    res = send_authenticated_request(t, method, feed_data, url, headers_for_feed_file_post)
    http_res = res[:http_res]
    attempt_number_reached = res[:attempt_number_reached]
    logger.debug { "Walmart Seller API request complete url=#{url} method=#{method} attempts=#{attempt_number_reached} status=#{http_res&.status}" }
    { success: successful?(http_res), http_result: http_res, attempt_number_reached: attempt_number_reached }
  ensure
    tempfile.close
    tempfile.unlink # Clean up the temporary file
  end
end

#send_request(method, url, data = '', headers = {}) ⇒ Hash

Issues a single request through the Faraday #connection, letting the +:retry+
middleware handle transient-failure and 429 retries (Retry-After aware). A 429
that survives the in-band retries is raised so the job reschedules.

Parameters:

  • method (String)

    the HTTP verb

  • url (String)

    the absolute endpoint

  • data (String, Hash) (defaults to: '')

    the request body, or a multipart hash for feed uploads

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

    the fully-resolved request headers

Returns:

  • (Hash)

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

Raises:

  • (HTTP::RateLimitExceededError)

    when the response is still 429 after the in-band retries



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 172

def send_request(method, url, data = '', headers = {})
  # The :retry middleware on #connection now does what the hand-rolled rescue
  # loop did: it retries the timeout classes AND 429s in-band. Unlike the old
  # fixed 15+15·n sleep, faraday-retry honors the server's Retry-After header
  # (rate_limit_retry_header) for 429s. retry_block counts attempts.
  attempts = 1
  http_res = connection(headers, retry_block: ->(**) { attempts += 1 }).run_request(method.downcase.to_sym, url, request_body(data), nil)

  # A 429 that survives the in-band retries still bubbles up so the job
  # reschedules — same contract as before (callers don't rescue it), now
  # Retry-After-aware. Throttling is per SP-API profile/credential and can
  # persist; better to fail and let the worker retry than spin here.
  if http_res.status == 429
    retry_after = http_res.headers['Retry-After']&.to_i
    raise HTTP::RateLimitExceededError.new(
      status_code: 429,
      headers: http_res.headers,
      retry_after: retry_after,
      message: "429 returned with result headers: #{http_res.headers.inspect} after #{attempts} attempt(s), retry after #{retry_after}"
    )
  end

  { http_res: http_res, attempt_number_reached: attempts }
end

#successful?(http_res) ⇒ Boolean

Whether +http_res+ is a successful Walmart response: a 2xx status with no
+errors+ array in a JSON body. A non-JSON 2xx body (e.g. a label PDF) is
treated as success on status alone.

Parameters:

  • http_res (Faraday::Response)

    the response to evaluate

Returns:

  • (Boolean)

    true when the status is 2xx and the body carries no errors



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 112

def successful?(http_res)
  res = false
  errs = []
  # Only try to parse JSON if Content-Type indicates JSON
  # For binary responses (PDF, images, etc.), check HTTP status code only
  content_type = http_res.headers['Content-Type'].to_s
  if content_type.include?('application/json') && http_res.body.present?
    begin
      errs = JSON.parse(http_res.body.to_s).with_indifferent_access[:errors] || []
    rescue JSON::ParserError
      # If JSON parsing fails, treat as non-JSON response (e.g., PDF)
      errs = []
    end
  end
  code = http_res.status.to_i
  res = true if (code >= 200) && (code < 300) && errs.empty?
  res
end