fulfil_api 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2dd30142d140730335b3f184bc28c0fd897af19246a8794ec5d416b1a335b2a
4
- data.tar.gz: b81436ad499dfdd14ea84f5651d4670395907ad8090ab4e9eed432c6195da86c
3
+ metadata.gz: 2971bc621921930210bce77c057d6da8ce549cfe476fb1fc29d72f10c4447303
4
+ data.tar.gz: 97658bf767d8909e359ad66441de0f81108982c6383070778eb04d3386c2fb3d
5
5
  SHA512:
6
- metadata.gz: 6dee8c45eea430b8f6e7bfa82b03c3d737dd424cdda363c74680f4acedc9e896c28cfc3bc746e35d33035a863ffed9f995877b267a59e0afae0f2e67f7047d20
7
- data.tar.gz: 5873899ac0f65bb6a9e1ecbd74aebd1f45806cee1faed5716c1649e158d83396de15139a93da9347b8b20ba080172c7e2cfa59aca36b6f89a89a4a57207e3167
6
+ metadata.gz: 020e57b3a676a3c56f2d2b76e5b269010764d3a95f41ef19848f0477e4f77ac9db9871252c68efe77fe35af1fbd6f319d8283f43b578caf7e09e8de4651120a9
7
+ data.tar.gz: 51af4a10179668acf8977fca9dccfc133bbb25516c7464cb87bd05f9abf0a5926b1631620cf9265a9637b7b0ffa8c7b9a6618bf9ee7b75912005eb3f1722e1a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,7 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Raise a `FulfilApi::HttpError` instead of a generic `FulfilApi::Error` when a request to Fulfil fails, with a dedicated subclass per HTTP status code (e.g. `FulfilApi::HttpError::TooManyRequests` for a 429). The exception message is now the description reported by Fulfil rather than its serialized response body, and the status code, body and headers are available through `#status_code`, `#response_body` and `#response_headers`. `FulfilApi::HttpError` inherits from `FulfilApi::Error`, so existing rescues keep working.
4
+
3
5
  - Re-enable Ruby's built-in retry for idempotent requests on the persistent connection, which the `net_http_persistent` adapter disables by forcing `max_retries` to `0`. This recovers stale keep-alive sockets transparently instead of surfacing them as read timeouts.
4
6
  - Add a `connection_options` configuration option to tune the persistent connection (`max_retries`, `idle_timeout`, `pool_size`).
5
7
  - `FulfilApi.with_config` now merges the temporary options over the active configuration instead of replacing it, so a block inherits credentials and other unspecified settings rather than resetting them to their defaults.
data/README.md CHANGED
@@ -149,6 +149,35 @@ line_items = FulfilApi::Resource.set(model_name: "sale.line").where(["id", "in",
149
149
  line_items = FulfilApi::Resource.set(model_name: "sale.line").find_by(["sale.id", "=", 100])
150
150
  ```
151
151
 
152
+ ### Handling Errors
153
+
154
+ Whenever a request to Fulfil fails, the gem raises a `FulfilApi::HttpError`. Every HTTP status code has its own subclass, named after the status code it represents, so you can rescue the exact failure you care about:
155
+
156
+ ```ruby
157
+ begin
158
+ FulfilApi::Resource.set(model_name: "sale.sale").find_by(["id", "=", 100])
159
+ rescue FulfilApi::HttpError::TooManyRequests => exception
160
+ puts exception.message # => "This user has exceeded an allotted request count. Try again later."
161
+ puts exception.status_code # => 429
162
+
163
+ sleep exception.response_headers["retry-after"].to_i
164
+ retry
165
+ end
166
+ ```
167
+
168
+ The message is the description reported by Fulfil. The raw response is available through `#response_body`, `#response_headers` and `#status_code`.
169
+
170
+ To catch anything that went wrong, rescue the base class instead:
171
+
172
+ ```ruby
173
+ rescue FulfilApi::HttpError => exception
174
+ Rails.logger.error("Fulfil responded with #{exception.status_code}: #{exception.message}")
175
+ ```
176
+
177
+ A request that never reached Fulfil — a connection reset, a DNS failure, a timeout — has no status code to name and raises a `FulfilApi::HttpError` itself.
178
+
179
+ `FulfilApi::HttpError` inherits from `FulfilApi::Error`, the base class of every error in this gem, so code that already rescues `FulfilApi::Error` keeps working unchanged.
180
+
152
181
  ### Using the 3PL (TPL) Client
153
182
 
154
183
  The gem also includes a client for Fulfil's [3PL Integration API](https://fulfil-3pl-integration-api.readme.io/reference/getting-started-with-your-api). This is a separate API that allows third-party logistics providers to interact with Fulfil on behalf of a merchant.
@@ -167,15 +167,10 @@ module FulfilApi
167
167
 
168
168
  # @param exception [Faraday::Error] Any error raised by Faraday during the execution
169
169
  # of the HTTP request to the API endpoint.
170
+ # @raise [FulfilApi::HttpError] The error dedicated to the HTTP status code of the
171
+ # response of Fulfil.
170
172
  def handle_request_error(exception)
171
- raise FulfilApi::Error.new(
172
- exception.message,
173
- details: {
174
- response_body: exception.response_body,
175
- response_headers: exception.response_headers,
176
- response_status: exception.response_status
177
- }
178
- )
173
+ raise FulfilApi::HttpError.from_faraday_error(exception)
179
174
  end
180
175
 
181
176
  # @param method [Symbol, String] The HTTP verb for the HTTP request.
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # The {FulfilApi::HttpError} is raised whenever a request to an API endpoint of
5
+ # Fulfil fails.
6
+ #
7
+ # Every HTTP status code Fulfil can respond with has a dedicated subclass, named
8
+ # after the status code it represents. A 429 response raises a
9
+ # {FulfilApi::HttpError::TooManyRequests}, a 422 an
10
+ # {FulfilApi::HttpError::UnprocessableEntity}, and so on. See {STATUS_CODES} for
11
+ # the full list. This lets callers rescue the exact failure they care about
12
+ # instead of rescuing everything and inspecting the status code themselves.
13
+ #
14
+ # Requests that never reached Fulfil — a connection reset, a DNS failure, a
15
+ # timeout — have no status code to name and raise a {FulfilApi::HttpError}.
16
+ #
17
+ # @example rescuing one specific HTTP status code
18
+ # begin
19
+ # FulfilApi::Resource.set(model_name: "sale.sale").find_by(["id", "=", 100])
20
+ # rescue FulfilApi::HttpError::TooManyRequests => exception
21
+ # sleep exception.response_headers["retry-after"].to_i
22
+ # retry
23
+ # end
24
+ #
25
+ # @example rescuing any failed request
26
+ # begin
27
+ # FulfilApi::Resource.set(model_name: "sale.sale").find_by(["id", "=", 100])
28
+ # rescue FulfilApi::HttpError => exception
29
+ # Rails.logger.error("Fulfil responded with #{exception.status_code}: #{exception.message}")
30
+ # end
31
+ #
32
+ # @note {FulfilApi::HttpError} inherits from {FulfilApi::Error}, so any code that
33
+ # already rescues {FulfilApi::Error} keeps catching these exceptions.
34
+ class HttpError < Error
35
+ # The keys of an error response of Fulfil that can hold the human readable
36
+ # message, in the order they're preferred.
37
+ #
38
+ # Fulfil is not consistent in how it reports failures: HTTP level errors carry a
39
+ # `description`, application level errors a `message`, and a handful of
40
+ # endpoints only return an `error`.
41
+ MESSAGE_KEYS = %w[description message error].freeze
42
+
43
+ # Maps an HTTP status code onto the name of the {FulfilApi::HttpError} subclass
44
+ # representing it.
45
+ STATUS_CODES = {
46
+ 400 => :BadRequest,
47
+ 401 => :Unauthorized,
48
+ 402 => :PaymentRequired,
49
+ 403 => :Forbidden,
50
+ 404 => :NotFound,
51
+ 405 => :MethodNotAllowed,
52
+ 406 => :NotAcceptable,
53
+ 407 => :ProxyAuthenticationRequired,
54
+ 408 => :RequestTimeout,
55
+ 409 => :Conflict,
56
+ 410 => :Gone,
57
+ 411 => :LengthRequired,
58
+ 412 => :PreconditionFailed,
59
+ 413 => :PayloadTooLarge,
60
+ 414 => :UriTooLong,
61
+ 415 => :UnsupportedMediaType,
62
+ 416 => :RangeNotSatisfiable,
63
+ 417 => :ExpectationFailed,
64
+ 418 => :ImATeapot,
65
+ 421 => :MisdirectedRequest,
66
+ 422 => :UnprocessableEntity,
67
+ 423 => :Locked,
68
+ 424 => :FailedDependency,
69
+ 425 => :TooEarly,
70
+ 426 => :UpgradeRequired,
71
+ 428 => :PreconditionRequired,
72
+ 429 => :TooManyRequests,
73
+ 431 => :RequestHeaderFieldsTooLarge,
74
+ 451 => :UnavailableForLegalReasons,
75
+ 500 => :InternalServerError,
76
+ 501 => :NotImplemented,
77
+ 502 => :BadGateway,
78
+ 503 => :ServiceUnavailable,
79
+ 504 => :GatewayTimeout,
80
+ 505 => :HttpVersionNotSupported,
81
+ 506 => :VariantAlsoNegotiates,
82
+ 507 => :InsufficientStorage,
83
+ 508 => :LoopDetected,
84
+ 510 => :NotExtended,
85
+ 511 => :NetworkAuthenticationRequired
86
+ }.freeze
87
+
88
+ # Defines a dedicated exception class for every status code in {STATUS_CODES} and
89
+ # indexes them by their status code, so {.for_status_code} can look one up
90
+ # without going through {Module#const_get}.
91
+ CLASSES_BY_STATUS_CODE = STATUS_CODES.transform_values do |class_name|
92
+ const_set(class_name, Class.new(self))
93
+ end.freeze
94
+
95
+ class << self
96
+ # Looks up the {FulfilApi::HttpError} subclass representing the given HTTP
97
+ # status code.
98
+ #
99
+ # @param status_code [Integer, nil] The HTTP status code of the response.
100
+ # @return [Class<FulfilApi::HttpError>] The subclass for the status code, or
101
+ # {FulfilApi::HttpError} itself when the status code is unknown or absent.
102
+ def for_status_code(status_code)
103
+ CLASSES_BY_STATUS_CODE.fetch(status_code, HttpError)
104
+ end
105
+
106
+ # Builds the most specific {FulfilApi::HttpError} for the given Faraday exception.
107
+ #
108
+ # @param exception [Faraday::Error] Any error raised by Faraday during the
109
+ # execution of the HTTP request to the API endpoint.
110
+ # @return [FulfilApi::HttpError]
111
+ def from_faraday_error(exception)
112
+ details = {
113
+ response_body: exception.response_body,
114
+ response_headers: exception.response_headers,
115
+ response_status: exception.response_status
116
+ }
117
+
118
+ for_status_code(exception.response_status).new(
119
+ message_from(exception.response_body) || exception.message, details: details
120
+ )
121
+ end
122
+
123
+ private
124
+
125
+ # Extracts the human readable error message out of the response body of Fulfil.
126
+ #
127
+ # @param response_body [String, Hash, nil] The response body of the API endpoint.
128
+ # @return [String, nil] The message, or nil when the body holds none. An HTML
129
+ # error page and an empty body both yield nil.
130
+ def message_from(response_body)
131
+ body = parse(response_body)
132
+ return if body.nil?
133
+
134
+ MESSAGE_KEYS.map { |key| body[key] }.find { |message| message.is_a?(String) && message.present? }
135
+ end
136
+
137
+ # @param response_body [String, Hash, nil] The response body of the API endpoint.
138
+ # @return [ActiveSupport::HashWithIndifferentAccess, nil]
139
+ def parse(response_body)
140
+ return response_body.with_indifferent_access if response_body.is_a?(Hash)
141
+ return if response_body.blank?
142
+
143
+ parsed_body = JSON.parse(response_body)
144
+ parsed_body.with_indifferent_access if parsed_body.is_a?(Hash)
145
+ rescue JSON::ParserError
146
+ nil
147
+ end
148
+ end
149
+
150
+ # Unlike {FulfilApi::Error}, the message is returned as-is. The name of the
151
+ # exception class already tells you what went wrong, so prefixing it only gets
152
+ # in the way of the description reported by Fulfil.
153
+ #
154
+ # @note {StandardError#message} delegates to {StandardError#to_s}, which still
155
+ # holds the message passed to the constructor.
156
+ #
157
+ # @return [String]
158
+ def message
159
+ to_s
160
+ end
161
+
162
+ # @return [String, Hash, nil] The raw response body of the API endpoint of Fulfil.
163
+ def response_body
164
+ details&.dig(:response_body)
165
+ end
166
+
167
+ # @return [Hash, nil] The response headers of the API endpoint of Fulfil.
168
+ def response_headers
169
+ details&.dig(:response_headers)
170
+ end
171
+
172
+ # @return [Integer, nil] The HTTP status code of the response, or nil when the
173
+ # request never reached Fulfil.
174
+ def status_code
175
+ details&.dig(:response_status)
176
+ end
177
+ end
178
+ end
@@ -49,7 +49,7 @@ module FulfilApi
49
49
  # @yield [FulfilApi::Relation] Yields FulfilApi::Relation
50
50
  # objects to work with a batch of records.
51
51
  # @return [FulfilApi::Relation]
52
- def in_batches(of: 500, retries: :unlimited) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
52
+ def in_batches(of: 500, retries: :unlimited) # rubocop:disable Metrics/MethodLength
53
53
  current_retry = 0
54
54
  current_offset = request_offset.presence || 0
55
55
  batch_size = of
@@ -64,18 +64,14 @@ module FulfilApi
64
64
 
65
65
  current_offset += 1
66
66
  current_retry = 0 # Reset the retries back to the default
67
- rescue FulfilApi::Error => e
68
- if e.details[:response_status] == 429
69
- if retries != :unlimited && current_retry > retries
70
- raise RetryLimitExceeded, "the maximum number of #{retries} retries has been reached."
71
- end
72
-
73
- current_retry += 1
74
- sleep 0.25
75
- retry
67
+ rescue FulfilApi::HttpError::TooManyRequests
68
+ if retries != :unlimited && current_retry > retries
69
+ raise RetryLimitExceeded, "the maximum number of #{retries} retries has been reached."
76
70
  end
77
71
 
78
- raise e
72
+ current_retry += 1
73
+ sleep 0.25
74
+ retry
79
75
  end
80
76
 
81
77
  self
@@ -182,15 +182,10 @@ module FulfilApi
182
182
 
183
183
  # @param exception [Faraday::Error] Any error raised by Faraday during the execution
184
184
  # of the HTTP request to the API endpoint.
185
+ # @raise [FulfilApi::HttpError] The error dedicated to the HTTP status code of the
186
+ # response of Fulfil.
185
187
  def handle_request_error(exception)
186
- raise FulfilApi::Error.new(
187
- exception.message,
188
- details: {
189
- response_body: exception.response_body,
190
- response_headers: exception.response_headers,
191
- response_status: exception.response_status
192
- }
193
- )
188
+ raise FulfilApi::HttpError.from_faraday_error(exception)
194
189
  end
195
190
 
196
191
  # @param method [Symbol, String] The HTTP verb for the HTTP request.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FulfilApi
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fulfil_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Vermaas
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-29 00:00:00.000000000 Z
11
+ date: 2026-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -86,6 +86,7 @@ files:
86
86
  - lib/fulfil_api/configuration.rb
87
87
  - lib/fulfil_api/customer_shipment.rb
88
88
  - lib/fulfil_api/error.rb
89
+ - lib/fulfil_api/http_error.rb
89
90
  - lib/fulfil_api/relation.rb
90
91
  - lib/fulfil_api/relation/batchable.rb
91
92
  - lib/fulfil_api/relation/countable.rb