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
GLOBAL_API_VERSION =
'3.1'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ HttpWalmartSellerApiConnection

Returns a new instance of HttpWalmartSellerApiConnection.

Parameters:

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

    connection configuration

Options Hash (options):

  • :profile_data (Hash)

    a pre-loaded Walmart credential/profile
    hash (wins over :profile when present)

  • :profile (Symbol)

    configuration profile name; the Walmart
    credential set is loaded from Heatwave::Configuration under this key

  • :logger (Logger)

    logger to use (defaults to Rails.logger)



26
27
28
29
30
31
32
33
34
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 26

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.



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

def logger
  @logger
end

#profileObject (readonly)

Returns the value of attribute profile.



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

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:



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 256

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]
  body = response.body.to_s.strip

  raise "Walmart token fetch failed: status=#{response.status} body=#{body[0, 200].inspect}" unless (200..299).cover?(response.status) && body.present?

  # Faraday::Response#to_s is NOT the body (http.rb's was) — read #body.
  # A transient Walmart auth blip can return non-JSON (HTML/proxy page) or a
  # bare `null`/`[]` — route those through the same descriptive error instead
  # of a raw JSON::ParserError / NoMethodError.
  parsed =
    begin
      JSON.parse(body)
    rescue JSON::ParserError
      nil
    end
  raise "Walmart token fetch returned malformed JSON: status=#{response.status} body=#{body[0, 200].inspect}" unless parsed.is_a?(Hash)

  token = parsed['access_token']
  raise "Walmart token response missing access_token: #{parsed.inspect}" if token.blank?

  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 Global API headers for non-US markets) 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, correlation_id: String }+

Raises:

  • (HTTP::RateLimitExceededError)

    propagated from #send_request on a persistent 429



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 149

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
  # Returned to the caller so it can be persisted alongside the ECL: Walmart Seller
  # Support traces a submission by WM_QOS.CORRELATION_ID, and without it a case
  # about a specific feed stalls on "resubmit and send us the correlation ID".
  correlation_id = SecureRandom.uuid
  headers_for_request = headers.merge({
    'WM_SEC.ACCESS_TOKEN' => token,
    'WM_QOS.CORRELATION_ID' => correlation_id,
    'WM_SVC.NAME' => 'Walmart Marketplace',
    'accept' => ac,
    'Content-Type' => ct
  })
  # A configured market identifies a Global API profile. Keep the version
  # header off the existing US integration, which remains on the US APIs.
  if profile[:market].present?
    headers_for_request['WM_MARKET'] = profile[:market]
    headers_for_request['WM_GLOBAL_VERSION'] = GLOBAL_API_VERSION
  end
  # 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).merge(correlation_id: correlation_id)
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, correlation_id: String }+

Raises:

  • (HTTP::RateLimitExceededError)

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



45
46
47
48
49
50
51
52
53
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 45

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, correlation_id: res[:correlation_id] }
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, correlation_id: String }+

Raises:

  • (HTTP::RateLimitExceededError)

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



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

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, correlation_id: res[:correlation_id] }
  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



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 186

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



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 119

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