fulfil_api 0.6.3 → 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: e5088d4a6e17bfc91b7dbdbd841db0d649bbc7fc16e295fb63a1cf8d4e80436a
4
- data.tar.gz: 30f89d00763dde2c6d2b1573ded61440097b87904dae062c7f8ee147de9621e0
3
+ metadata.gz: 2971bc621921930210bce77c057d6da8ce549cfe476fb1fc29d72f10c4447303
4
+ data.tar.gz: 97658bf767d8909e359ad66441de0f81108982c6383070778eb04d3386c2fb3d
5
5
  SHA512:
6
- metadata.gz: 35aa477f5a29a3597d7567bcb8f6708fb89cb13d20c0f848cb06dd46f381bbff94dbddb36bf6fb95d9e544f01c7e94989f44ab872e1ed891f5ea254dfa358727
7
- data.tar.gz: de45ced7efb091fc5cfcce224310afab14b233f97197887d94137f2c31007b43b363db424a1478108a8469e1813eeb980a71febe0d4c55f6b6e63d101a57c35b
6
+ metadata.gz: 020e57b3a676a3c56f2d2b76e5b269010764d3a95f41ef19848f0477e4f77ac9db9871252c68efe77fe35af1fbd6f319d8283f43b578caf7e09e8de4651120a9
7
+ data.tar.gz: 51af4a10179668acf8977fca9dccfc133bbb25516c7464cb87bd05f9abf0a5926b1631620cf9265a9637b7b0ffa8c7b9a6618bf9ee7b75912005eb3f1722e1a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
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
+
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.
6
+ - Add a `connection_options` configuration option to tune the persistent connection (`max_retries`, `idle_timeout`, `pool_size`).
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.
8
+
3
9
  ## [0.1.0] - 2024-08-10
4
10
 
5
11
  - Initial release
data/README.md CHANGED
@@ -41,6 +41,8 @@ end
41
41
 
42
42
  #### Using a Dynamic Configuration
43
43
 
44
+ `with_config` temporarily applies options **on top of the currently active configuration** (per thread) and reverts when the block returns. The options you pass are merged over the active config, so you only need to specify what changes — credentials and other settings are inherited rather than reset to their defaults.
45
+
44
46
  ```ruby
45
47
  FulfilApi.with_config(
46
48
  access_token: FulfilApi::AccessToken.new(ENV["FULFIL_API_KEY"]),
@@ -50,6 +52,17 @@ FulfilApi.with_config(
50
52
  end
51
53
  ```
52
54
 
55
+ This makes it easy to use different settings in contexts with different constraints. For example, a web request bound by a 30s timeout can keep tight defaults globally, while a background job (which has more time) overrides just the timeouts and retries without re-passing credentials:
56
+
57
+ ```ruby
58
+ FulfilApi.with_config(
59
+ request_options: { open_timeout: 5, read_timeout: 60, write_timeout: 30 },
60
+ connection_options: { max_retries: 3, idle_timeout: 10 }
61
+ ) do
62
+ # Long-running work against the Fulfil API
63
+ end
64
+ ```
65
+
53
66
  #### Available Configuration Options
54
67
 
55
68
  The following configuration options are (currently) available throught both configuration methods:
@@ -60,7 +73,16 @@ The following configuration options are (currently) available throught both conf
60
73
 
61
74
  - `merchant_id` (`String`): The `merchant_id` is the subdomain that the Fulfil instance is hosted on. This configuration option is required to be able to query Fulfil's API endpoints.
62
75
 
63
- - `request_options` (`Hash`): The `request_options` are the configuration options for the HTTP client. See [https://lostisland.github.io/faraday/#/customization/request-options](https://lostisland.github.io/faraday/#/customization/request-options) in `faraday`.
76
+ - `request_options` (`Hash`): The `request_options` are the per-request timeout options for the HTTP client. See [https://lostisland.github.io/faraday/#/customization/request-options](https://lostisland.github.io/faraday/#/customization/request-options) in `faraday`.
77
+
78
+ > **NOTE:** With the persistent (keep-alive) adapter there is no single whole-request `timeout`; Faraday resolves `read_timeout`, `open_timeout`, and `write_timeout` independently. `read_timeout` is the value that governs a slow or stalled response.
79
+
80
+ - `connection_options` (`Hash`): Tuning for the persistent (keep-alive) connection. Supported keys:
81
+ - `max_retries` (default `1`): Re-enables Ruby's built-in retry for **idempotent** requests (`GET`/`HEAD`/`PUT`/`DELETE`/`OPTIONS`). The `net_http_persistent` adapter disables this by forcing it to `0`, which makes a keep-alive socket the server has already dropped surface as a read timeout instead of being retried transparently on a fresh socket. `POST` is never auto-retried, so this is side-effect safe. Set to `0` to restore the adapter's default behaviour.
82
+ - `idle_timeout` (`Integer`, optional): Seconds a pooled socket may sit idle before it is recycled. Lower this towards your server's keep-alive window to shrink the stale-socket window for non-idempotent requests.
83
+ - `pool_size` (`Integer`, optional): Maximum number of concurrent connections kept in the pool.
84
+
85
+ > **NOTE:** When retries are enabled, the worst-case time for a request is roughly `(max_retries + 1) × read_timeout`. On platforms with a hard request cap (e.g. Heroku's 30s router limit), keep `read_timeout` low enough that this product stays under the cap.
64
86
 
65
87
  ### Querying the Fulfil API
66
88
 
@@ -127,6 +149,35 @@ line_items = FulfilApi::Resource.set(model_name: "sale.line").where(["id", "in",
127
149
  line_items = FulfilApi::Resource.set(model_name: "sale.line").find_by(["sale.id", "=", 100])
128
150
  ```
129
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
+
130
181
  ### Using the 3PL (TPL) Client
131
182
 
132
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.
@@ -109,7 +109,9 @@ module FulfilApi
109
109
  # @return [Faraday::Connection]
110
110
  def build_connection
111
111
  Faraday.new(url: api_endpoint, request: configuration.request_options) do |connection|
112
- connection.adapter :net_http_persistent # TODO: Allow passing configuration options
112
+ connection.adapter(:net_http_persistent, **adapter_options) do |http|
113
+ configure_persistent_connection(http)
114
+ end
113
115
 
114
116
  # Configuration of the request middleware
115
117
  connection.request :json
@@ -120,9 +122,40 @@ module FulfilApi
120
122
  end
121
123
  end
122
124
 
125
+ # Initialization options for the persistent adapter. Only `pool_size` is
126
+ # accepted here; `idle_timeout` and `max_retries` are applied to the live
127
+ # Net::HTTP::Persistent instance in {#configure_persistent_connection}.
128
+ #
129
+ # @return [Hash]
130
+ def adapter_options
131
+ options = {}
132
+ options[:pool_size] = configuration.connection_options[:pool_size] if configuration.connection_options[:pool_size]
133
+ options
134
+ end
135
+
136
+ # Tunes the underlying Net::HTTP::Persistent connection.
137
+ #
138
+ # The `net_http_persistent` adapter forces `max_retries` to 0 on every
139
+ # request, which disables Ruby's built-in retry for idempotent requests.
140
+ # Restoring it lets a stale keep-alive socket — one the server has already
141
+ # closed — be retried transparently on a fresh socket instead of surfacing
142
+ # as a read timeout. The config block runs after the adapter zeroes the
143
+ # value, so this takes effect.
144
+ #
145
+ # @param http [Net::HTTP::Persistent] The live persistent connection.
146
+ # @return [void]
147
+ def configure_persistent_connection(http)
148
+ if configuration.connection_options[:idle_timeout]
149
+ http.idle_timeout = configuration.connection_options[:idle_timeout]
150
+ end
151
+ return if configuration.connection_options[:max_retries].nil?
152
+
153
+ http.max_retries = configuration.connection_options[:max_retries]
154
+ end
155
+
123
156
  # @return [Array] The cache key identifying a unique connection.
124
157
  def connection_cache_key
125
- [configuration.merchant_id, configuration.request_options]
158
+ [configuration.merchant_id, configuration.request_options, configuration.connection_options]
126
159
  end
127
160
 
128
161
  # @param relative_path [String] The relative path to the API endpoint.
@@ -134,15 +167,10 @@ module FulfilApi
134
167
 
135
168
  # @param exception [Faraday::Error] Any error raised by Faraday during the execution
136
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.
137
172
  def handle_request_error(exception)
138
- raise FulfilApi::Error.new(
139
- exception.message,
140
- details: {
141
- response_body: exception.response_body,
142
- response_headers: exception.response_headers,
143
- response_status: exception.response_status
144
- }
145
- )
173
+ raise FulfilApi::HttpError.from_faraday_error(exception)
146
174
  end
147
175
 
148
176
  # @param method [Symbol, String] The HTTP verb for the HTTP request.
@@ -7,10 +7,23 @@ module FulfilApi
7
7
  # to these settings.
8
8
  class Configuration
9
9
  attr_accessor :access_token, :api_version, :merchant_id, :request_options, :tpl
10
+ attr_reader :connection_options
10
11
 
11
12
  DEFAULT_API_VERSION = "v2"
12
13
  DEFAULT_REQUEST_OPTIONS = { open_timeout: 1, read_timeout: 5, write_timeout: 5, timeout: 5 }.freeze
13
14
 
15
+ # Tuning for the persistent (keep-alive) HTTP connection.
16
+ #
17
+ # `max_retries` re-enables Ruby's built-in retry for idempotent requests
18
+ # (GET/HEAD/PUT/DELETE/OPTIONS). The `net_http_persistent` adapter forces
19
+ # it to 0, which means a keep-alive socket the server has already dropped
20
+ # surfaces as a read timeout instead of being transparently retried on a
21
+ # fresh socket. POST is never auto-retried, so this is side-effect safe.
22
+ #
23
+ # `idle_timeout` and `pool_size` are passed through to the underlying
24
+ # Net::HTTP::Persistent connection when set.
25
+ DEFAULT_CONNECTION_OPTIONS = { max_retries: 1 }.freeze
26
+
14
27
  # Initializes the configuration with optional settings.
15
28
  #
16
29
  # @param options [Hash, nil] An optional list of configuration options.
@@ -25,6 +38,16 @@ module FulfilApi
25
38
  set_default_options
26
39
  end
27
40
 
41
+ # Merges the provided connection options over the defaults so that, for
42
+ # example, setting only `idle_timeout` still keeps the default
43
+ # `max_retries`. Assigning `nil` resets to the defaults.
44
+ #
45
+ # @param options [Hash, nil] The connection options to apply.
46
+ # @return [void]
47
+ def connection_options=(options)
48
+ @connection_options = DEFAULT_CONNECTION_OPTIONS.merge(options || {})
49
+ end
50
+
28
51
  private
29
52
 
30
53
  # Sets the default options for the gem configuration.
@@ -36,6 +59,7 @@ module FulfilApi
36
59
  def set_default_options
37
60
  self.api_version = DEFAULT_API_VERSION if api_version.nil?
38
61
  self.request_options = DEFAULT_REQUEST_OPTIONS if request_options.nil?
62
+ self.connection_options = nil if connection_options.nil?
39
63
  end
40
64
  end
41
65
 
@@ -75,15 +99,23 @@ module FulfilApi
75
99
  end
76
100
  end
77
101
 
78
- # Temporarily applies the provided configuration options within a block,
79
- # and then reverts to the original configuration after the block executes.
102
+ # Temporarily applies the provided configuration options on top of the
103
+ # currently active configuration, and then reverts after the block executes.
104
+ #
105
+ # The temporary options are merged over a copy of the active configuration, so
106
+ # a block only needs to specify what it overrides — credentials and other
107
+ # settings (`access_token`, `merchant_id`, ...) are inherited rather than
108
+ # reset to their defaults.
80
109
  #
81
110
  # @param temporary_options [Hash] A hash of temporary configuration options.
82
111
  # @yield Executes the block with the temporary configuration.
83
112
  # @return [void]
84
113
  def self.with_config(temporary_options)
85
- original_configuration = configuration.dup
86
- self.configuration = temporary_options
114
+ original_configuration = configuration
115
+
116
+ self.configuration = original_configuration.dup.tap do |config|
117
+ temporary_options.each { |key, value| config.public_send(:"#{key}=", value) }
118
+ end
87
119
 
88
120
  yield
89
121
  ensure
@@ -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
@@ -123,11 +123,10 @@ module FulfilApi
123
123
  #
124
124
  # @return [Faraday::Connection]
125
125
  def build_connection
126
- Faraday.new(
127
- url: api_endpoint,
128
- request: configuration.request_options
129
- ) do |connection|
130
- connection.adapter :net_http_persistent
126
+ Faraday.new(url: api_endpoint, request: configuration.request_options) do |connection|
127
+ connection.adapter(:net_http_persistent, **adapter_options) do |http|
128
+ configure_persistent_connection(http)
129
+ end
131
130
 
132
131
  # Configuration of the request middleware
133
132
  connection.request :json
@@ -138,9 +137,40 @@ module FulfilApi
138
137
  end
139
138
  end
140
139
 
140
+ # Initialization options for the persistent adapter. Only `pool_size` is
141
+ # accepted here; `idle_timeout` and `max_retries` are applied to the live
142
+ # Net::HTTP::Persistent instance in {#configure_persistent_connection}.
143
+ #
144
+ # @return [Hash]
145
+ def adapter_options
146
+ options = {}
147
+ options[:pool_size] = configuration.connection_options[:pool_size] if configuration.connection_options[:pool_size]
148
+ options
149
+ end
150
+
151
+ # Tunes the underlying Net::HTTP::Persistent connection.
152
+ #
153
+ # The `net_http_persistent` adapter forces `max_retries` to 0 on every
154
+ # request, which disables Ruby's built-in retry for idempotent requests.
155
+ # Restoring it lets a stale keep-alive socket — one the server has already
156
+ # closed — be retried transparently on a fresh socket instead of surfacing
157
+ # as a read timeout. The config block runs after the adapter zeroes the
158
+ # value, so this takes effect.
159
+ #
160
+ # @param http [Net::HTTP::Persistent] The live persistent connection.
161
+ # @return [void]
162
+ def configure_persistent_connection(http)
163
+ if configuration.connection_options[:idle_timeout]
164
+ http.idle_timeout = configuration.connection_options[:idle_timeout]
165
+ end
166
+ return if configuration.connection_options[:max_retries].nil?
167
+
168
+ http.max_retries = configuration.connection_options[:max_retries]
169
+ end
170
+
141
171
  # @return [Array] The cache key identifying a unique connection.
142
172
  def connection_cache_key
143
- [merchant_id, api_version, configuration.request_options]
173
+ [merchant_id, api_version, configuration.request_options, configuration.connection_options]
144
174
  end
145
175
 
146
176
  # @param relative_path [String] The relative path to the API endpoint.
@@ -152,15 +182,10 @@ module FulfilApi
152
182
 
153
183
  # @param exception [Faraday::Error] Any error raised by Faraday during the execution
154
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.
155
187
  def handle_request_error(exception)
156
- raise FulfilApi::Error.new(
157
- exception.message,
158
- details: {
159
- response_body: exception.response_body,
160
- response_headers: exception.response_headers,
161
- response_status: exception.response_status
162
- }
163
- )
188
+ raise FulfilApi::HttpError.from_faraday_error(exception)
164
189
  end
165
190
 
166
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.6.3"
4
+ VERSION = "0.7.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fulfil_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.3
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Vermaas
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-18 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activesupport
@@ -85,6 +86,7 @@ files:
85
86
  - lib/fulfil_api/configuration.rb
86
87
  - lib/fulfil_api/customer_shipment.rb
87
88
  - lib/fulfil_api/error.rb
89
+ - lib/fulfil_api/http_error.rb
88
90
  - lib/fulfil_api/relation.rb
89
91
  - lib/fulfil_api/relation/batchable.rb
90
92
  - lib/fulfil_api/relation/countable.rb
@@ -111,6 +113,7 @@ metadata:
111
113
  source_code_uri: https://www.github.com/codeturebv/fulfil_api
112
114
  changelog_uri: https://www.github.com/codeturebv/fulfil_api/blob/main/CHANGELOG.md
113
115
  rubygems_mfa_required: 'true'
116
+ post_install_message:
114
117
  rdoc_options: []
115
118
  require_paths:
116
119
  - lib
@@ -125,7 +128,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
125
128
  - !ruby/object:Gem::Version
126
129
  version: '0'
127
130
  requirements: []
128
- rubygems_version: 4.0.6
131
+ rubygems_version: 3.5.11
132
+ signing_key:
129
133
  specification_version: 4
130
134
  summary: A HTTP client to interact the Fulfil.io API
131
135
  test_files: []