fulfil_api 0.7.1 → 0.8.0

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +15 -0
  5. data/README.md +193 -2
  6. data/app/controllers/concerns/fulfil_api/authenticated.rb +43 -0
  7. data/app/controllers/concerns/fulfil_api/installation_scoped.rb +56 -0
  8. data/app/controllers/fulfil_api/application_controller.rb +31 -0
  9. data/app/controllers/fulfil_api/installations_controller.rb +111 -0
  10. data/app/models/concerns/fulfil_api/installable.rb +47 -0
  11. data/app/models/fulfil_api/installation.rb +145 -0
  12. data/config/routes.rb +9 -0
  13. data/lib/fulfil_api/circuit_open.rb +9 -0
  14. data/lib/fulfil_api/client.rb +10 -6
  15. data/lib/fulfil_api/configuration.rb +72 -8
  16. data/lib/fulfil_api/engine.rb +14 -0
  17. data/lib/fulfil_api/error/notifiable.rb +90 -0
  18. data/lib/fulfil_api/error.rb +38 -0
  19. data/lib/fulfil_api/http_error.rb +15 -0
  20. data/lib/fulfil_api/middleware/circuit_breaker.rb +44 -0
  21. data/lib/fulfil_api/middleware.rb +59 -0
  22. data/lib/fulfil_api/oauth/authorization.rb +172 -0
  23. data/lib/fulfil_api/oauth/configuration.rb +96 -0
  24. data/lib/fulfil_api/oauth/token.rb +78 -0
  25. data/lib/fulfil_api/oauth.rb +37 -0
  26. data/lib/fulfil_api/relation.rb +2 -2
  27. data/lib/fulfil_api/resource/attribute_assignable.rb +12 -12
  28. data/lib/fulfil_api/tpl_client.rb +10 -6
  29. data/lib/fulfil_api/version.rb +1 -1
  30. data/lib/fulfil_api.rb +12 -0
  31. data/lib/generators/fulfil_api/install/install_generator.rb +72 -0
  32. data/lib/generators/fulfil_api/install/templates/create_fulfil_api_installations.rb +43 -0
  33. data/lib/generators/fulfil_api/install/templates/fulfil_api.rb +38 -0
  34. metadata +49 -11
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # An {FulfilApi::Installation} records that the OAuth app was installed on a
5
+ # merchant's Fulfil workspace, and holds the access token that came out of
6
+ # it.
7
+ #
8
+ # An installation without an owner is the application-wide installation, which
9
+ # is what a Rails application built for a single merchant needs. Assigning an
10
+ # owner scopes the installation to a record instead, which is what an
11
+ # application serving many merchants needs — see {FulfilApi::Installable}.
12
+ #
13
+ # An owner has at most one installation, so a record is always tied to a single
14
+ # Fulfil workspace. The same holds for the application-wide installation.
15
+ class Installation < ActiveRecord::Base
16
+ belongs_to :owner, polymorphic: true, optional: true
17
+
18
+ encrypts :access_token
19
+ encrypts :offline_access_token
20
+
21
+ normalizes :merchant_id, with: ->(merchant_id) { FulfilApi::OAuth::Authorization.normalize(merchant_id) }
22
+
23
+ validates :merchant_id, presence: true
24
+ validates :owner_id, uniqueness: { message: "is already connected to a Fulfil workspace", scope: :owner_type }
25
+
26
+ # Which of the two tokens is granted depends on the access mode, so neither
27
+ # column can be required on its own. An installation carrying neither is
28
+ # not an installation: it authenticates nothing, and {#usable?} would send
29
+ # the user back through the flow forever.
30
+ validates :access_token, presence: { message: "or an offline access token has to be present" }, unless: :offline?
31
+
32
+ scope :global, -> { where(owner: nil) }
33
+
34
+ serialize :scopes, coder: JSON, type: Array
35
+
36
+ # Records the outcome of a completed authorization flow.
37
+ #
38
+ # Installing replaces whatever the owner was connected to before, rather than
39
+ # leaving it connected to two Fulfil workspaces at once.
40
+ #
41
+ # @param token [FulfilApi::OAuth::Token] The granted access token.
42
+ # @param merchant_id [String] The workspace the app was installed on.
43
+ # @param owner [ActiveRecord::Base, nil] The record the installation belongs
44
+ # to, or `nil` for the application-wide installation.
45
+ # @return [FulfilApi::Installation]
46
+ def self.install(token, merchant_id:, owner: nil)
47
+ installation = find_or_initialize_by(owner: owner)
48
+ installation.merchant_id = merchant_id
49
+
50
+ # A token granted by one workspace is worthless on another, so moving the
51
+ # owner to a different workspace drops what the previous one granted.
52
+ installation.offline_access_token = nil if installation.merchant_id_changed?
53
+
54
+ installation.assign_token(token)
55
+ installation.save!
56
+ installation
57
+ end
58
+
59
+ # Looks up the installation to authenticate a Fulfil API request with.
60
+ #
61
+ # @param owner [ActiveRecord::Base, nil] The record the installation belongs to.
62
+ # @param merchant_id [String, nil] The workspace to find the installation for.
63
+ # @return [FulfilApi::Installation, nil]
64
+ def self.installed_on(merchant_id: FulfilApi.configuration.merchant_id, owner: nil)
65
+ return if merchant_id.blank?
66
+
67
+ find_by(owner: owner, merchant_id: merchant_id)
68
+ end
69
+
70
+ # Copies the granted token onto the installation.
71
+ #
72
+ # Re-authorizing in `user_session` mode does not return an
73
+ # `offline_access_token`, so an already granted permanent token is kept
74
+ # rather than wiped.
75
+ #
76
+ # @param token [FulfilApi::OAuth::Token] The granted access token.
77
+ # @return [void]
78
+ def assign_token(token)
79
+ assign_attributes(
80
+ access_token: token.access_token,
81
+ expires_at: token.expires_at,
82
+ scopes: token.scopes,
83
+ token_type: token.token_type
84
+ )
85
+
86
+ assign_associated_user(token.associated_user)
87
+ self.offline_access_token = token.offline_access_token if token.offline?
88
+ end
89
+
90
+ # Whether the short-lived token has passed its expiry. It says nothing about
91
+ # the permanent token, which is only revoked by uninstalling the app.
92
+ #
93
+ # @return [true, false]
94
+ def expired?
95
+ expires_at.present? && expires_at <= Time.current
96
+ end
97
+
98
+ # @return [true, false] Whether a permanent token was granted.
99
+ def offline?
100
+ offline_access_token.present?
101
+ end
102
+
103
+ # @return [FulfilApi::AccessToken]
104
+ def to_access_token
105
+ FulfilApi::AccessToken.new(offline_access_token.presence || access_token, type: :oauth)
106
+ end
107
+
108
+ # Whether the installation can still authenticate requests, or whether the
109
+ # user has to walk through the authorization flow again.
110
+ #
111
+ # @return [true, false]
112
+ def usable?
113
+ return true if offline?
114
+
115
+ access_token.present? && !expired?
116
+ end
117
+
118
+ # Runs a block against the Fulfil API as this installation.
119
+ #
120
+ # @example
121
+ # installation.with_config do |client|
122
+ # client.get("model/sale.sale")
123
+ # end
124
+ #
125
+ # @yieldparam [FulfilApi::Client] client A client authenticated as this installation.
126
+ # @return [void]
127
+ def with_config
128
+ FulfilApi.with_config(access_token: to_access_token, merchant_id: merchant_id) do
129
+ yield(FulfilApi.client)
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ # @param user [Hash] The user who walked through the authorization flow.
136
+ # @return [void]
137
+ def assign_associated_user(user)
138
+ assign_attributes(
139
+ associated_user_email: user[:email],
140
+ associated_user_id: user[:id],
141
+ associated_user_name: user[:name]
142
+ )
143
+ end
144
+ end
145
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ FulfilApi::Engine.routes.draw do
4
+ resource :installation, only: %i[new]
5
+
6
+ # Fulfil sends the user back with a `GET`, so the installation cannot be
7
+ # created through the resourceful `POST /installation` route.
8
+ get "callback", to: "installations#create", as: :callback
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # The {FulfilApi::CircuitOpen} is raised instead of sending a request while the
5
+ # circuit breaker has the Fulfil instance marked as failing.
6
+ #
7
+ # @see FulfilApi::Middleware::CircuitBreaker
8
+ class CircuitOpen < Error; end
9
+ end
@@ -113,6 +113,9 @@ module FulfilApi
113
113
  configure_persistent_connection(http)
114
114
  end
115
115
 
116
+ # Retries and other safeguards wrap every request, so they're added first
117
+ FulfilApi::Middleware.apply(connection, configuration)
118
+
116
119
  # Configuration of the request middleware
117
120
  connection.request :json
118
121
 
@@ -136,11 +139,9 @@ module FulfilApi
136
139
  # Tunes the underlying Net::HTTP::Persistent connection.
137
140
  #
138
141
  # 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.
142
+ # request. Retries are handled by {FulfilApi::Middleware::Retry} instead, so
143
+ # `max_retries` is only restored when it's configured explicitly. The config
144
+ # block runs after the adapter zeroes the value, so this takes effect.
144
145
  #
145
146
  # @param http [Net::HTTP::Persistent] The live persistent connection.
146
147
  # @return [void]
@@ -155,7 +156,10 @@ module FulfilApi
155
156
 
156
157
  # @return [Array] The cache key identifying a unique connection.
157
158
  def connection_cache_key
158
- [configuration.merchant_id, configuration.request_options, configuration.connection_options]
159
+ [
160
+ configuration.merchant_id, configuration.request_options, configuration.connection_options,
161
+ configuration.retry_options, configuration.circuit_breaker
162
+ ]
159
163
  end
160
164
 
161
165
  # @param relative_path [String] The relative path to the API endpoint.
@@ -7,22 +7,43 @@ 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
+ attr_reader :connection_options, :oauth, :retry_options
11
+
12
+ # @!attribute [rw] circuit_breaker
13
+ # @return [Faulty, nil] The Faulty instance that runs every request through a
14
+ # circuit per Fulfil instance, or nil to turn the circuit breaker off. See
15
+ # {FulfilApi::Middleware::CircuitBreaker}.
16
+ attr_accessor :circuit_breaker
11
17
 
12
18
  DEFAULT_API_VERSION = "v2"
13
19
  DEFAULT_REQUEST_OPTIONS = { open_timeout: 1, read_timeout: 5, write_timeout: 5, timeout: 5 }.freeze
14
20
 
15
21
  # Tuning for the persistent (keep-alive) HTTP connection.
16
22
  #
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
23
  # `idle_timeout` and `pool_size` are passed through to the underlying
24
24
  # Net::HTTP::Persistent connection when set.
25
- DEFAULT_CONNECTION_OPTIONS = { max_retries: 1 }.freeze
25
+ #
26
+ # `max_retries` re-enables Ruby's built-in retry, which retries every request
27
+ # Net::HTTP considers idempotent (GET/HEAD/PUT/DELETE/OPTIONS) whatever its
28
+ # endpoint. Fulfil writes through PUT too, so it is off by default in favour
29
+ # of the endpoint scoped {#retry_options}.
30
+ DEFAULT_CONNECTION_OPTIONS = {}.freeze
31
+
32
+ # Retries for requests that failed before Fulfil could answer them: a dropped
33
+ # keep-alive socket, a refused connection or a timeout.
34
+ #
35
+ # `max_retries` caps the number of retries per request. `requests` maps an
36
+ # HTTP verb onto the endpoints that are safe to retry, either `true` for every
37
+ # endpoint or a list of patterns matched against the request path. Reads in
38
+ # Fulfil go out as PUT requests to `search_read` and `search_count`, so those
39
+ # are retried; every other PUT may write and is not.
40
+ DEFAULT_RETRY_OPTIONS = {
41
+ max_retries: 1,
42
+ requests: {
43
+ get: true,
44
+ put: [%r{/search_read\z}, %r{/search_count\z}]
45
+ }
46
+ }.freeze
26
47
 
27
48
  # Initializes the configuration with optional settings.
28
49
  #
@@ -48,8 +69,49 @@ module FulfilApi
48
69
  @connection_options = DEFAULT_CONNECTION_OPTIONS.merge(options || {})
49
70
  end
50
71
 
72
+ # Assigns the credentials of the OAuth app that was registered in Fulfil's
73
+ # authentication dashboard. Assigning `nil` resets to the defaults.
74
+ #
75
+ # @param options [Hash, FulfilApi::OAuth::Configuration, nil] The OAuth options to apply.
76
+ # @return [void]
77
+ def oauth=(options)
78
+ @oauth =
79
+ case options
80
+ when OAuth::Configuration then options
81
+ when Hash, NilClass then OAuth::Configuration.new(options)
82
+ else raise ArgumentError, "Expected Hash or FulfilApi::OAuth::Configuration, got #{options.class} instead"
83
+ end
84
+ end
85
+
86
+ # Merges the provided retry options over the defaults. Assigning `nil` resets
87
+ # to the defaults.
88
+ #
89
+ # @example retrying reads three times, and one write endpoint as well
90
+ # config.retry_options = {
91
+ # max_retries: 3,
92
+ # requests: { get: true, put: [%r{/search_read\z}, %r{/stock.shipment.out/hold\z}] }
93
+ # }
94
+ #
95
+ # @param options [Hash, nil] The retry options to apply.
96
+ # @return [void]
97
+ def retry_options=(options)
98
+ @retry_options = DEFAULT_RETRY_OPTIONS.merge(options || {})
99
+ end
100
+
51
101
  private
52
102
 
103
+ # {FulfilApi.with_config} duplicates the active configuration, which would
104
+ # otherwise share the very same OAuth configuration object with the copy.
105
+ # A block that changes an OAuth option would then leak that change into
106
+ # the configuration it was supposed to be reverted to.
107
+ #
108
+ # @param source [FulfilApi::Configuration] The configuration being copied.
109
+ # @return [void]
110
+ def initialize_copy(source)
111
+ super
112
+ @oauth = source.oauth.dup
113
+ end
114
+
53
115
  # Sets the default options for the gem configuration.
54
116
  #
55
117
  # This method is called during initialization to ensure all configuration
@@ -60,6 +122,8 @@ module FulfilApi
60
122
  self.api_version = DEFAULT_API_VERSION if api_version.nil?
61
123
  self.request_options = DEFAULT_REQUEST_OPTIONS if request_options.nil?
62
124
  self.connection_options = nil if connection_options.nil?
125
+ self.oauth = nil if oauth.nil?
126
+ self.retry_options = nil if retry_options.nil?
63
127
  end
64
128
  end
65
129
 
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module FulfilApi
6
+ # Ships the OAuth flow as a mountable engine, so a Rails application only has
7
+ # to mount it and point Fulfil's whitelisted redirect URL at its callback.
8
+ #
9
+ # @example config/routes.rb
10
+ # mount FulfilApi::Engine => "/fulfil"
11
+ class Engine < ::Rails::Engine
12
+ isolate_namespace FulfilApi
13
+ end
14
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ class Error
5
+ # The {FulfilApi::Error::Notifiable} publishes an {ActiveSupport::Notifications}
6
+ # event every time a {FulfilApi::Error} is raised. It gives an application a
7
+ # single place to report a failure of the Fulfil API to its APM — a counter of
8
+ # rate limit hits, an error tracker, a log line — without having to wrap every
9
+ # call into the gem in a `begin`/`rescue`.
10
+ #
11
+ # Ruby routes every form of `raise` through {Exception.exception} (when raising a
12
+ # class) or {Exception#exception} (when raising an instance), which makes those
13
+ # two methods the hook for "an error is on its way up the stack". Building an
14
+ # error without raising it — {FulfilApi::HttpError.from_faraday_error}, for
15
+ # example — publishes nothing.
16
+ #
17
+ # @example counting the errors of Fulfil in an APM
18
+ # # config/initializers/fulfil_api.rb
19
+ # FulfilApi.on_error do |error|
20
+ # Appsignal.increment_counter("fulfil_api_errors", 1, error: error.class.name)
21
+ # end
22
+ #
23
+ # @example subscribing through ActiveSupport directly, for the full payload
24
+ # ActiveSupport::Notifications.subscribe(FulfilApi::Error::EVENT_NAME) do |event|
25
+ # Rails.logger.warn("Fulfil responded with #{event.payload[:status_code]}")
26
+ # end
27
+ #
28
+ # @note A subscriber must not change the behaviour of the application it observes.
29
+ # An exception raised by a subscriber is therefore swallowed and reported on
30
+ # `$stderr` rather than allowed to replace the {FulfilApi::Error} on its way up.
31
+ module Notifiable
32
+ extend ActiveSupport::Concern
33
+
34
+ # The name of the published event. It follows the `<action>.<library>` naming
35
+ # convention of Rails, so an APM that subscribes to a whole library through a
36
+ # `/\.fulfil_api\z/` regexp picks it up along with everything the gem may
37
+ # instrument in the future.
38
+ EVENT_NAME = "error.fulfil_api"
39
+
40
+ # The key of the fiber local flag guarding against infinite recursion. A
41
+ # subscriber that itself raises a {FulfilApi::Error} — one calling back into
42
+ # Fulfil, for instance — would otherwise publish an event from within the
43
+ # publication of an event, without end.
44
+ REENTRANCY_KEY = :fulfil_api_notifying_error
45
+
46
+ class_methods do
47
+ # Publishes {EVENT_NAME} for `raise SomeError` and `raise SomeError, "message"`.
48
+ #
49
+ # @return [FulfilApi::Error] The error being raised.
50
+ def exception(*, **)
51
+ super.tap(&:notify)
52
+ end
53
+ end
54
+
55
+ # Publishes {EVENT_NAME} for `raise error` and `raise error, "message"`.
56
+ #
57
+ # @return [FulfilApi::Error] The error being raised.
58
+ def exception(*)
59
+ super.tap(&:notify)
60
+ end
61
+
62
+ # Publishes {EVENT_NAME} for the receiver.
63
+ #
64
+ # @return [void]
65
+ def notify
66
+ return if Thread.current[REENTRANCY_KEY]
67
+
68
+ Thread.current[REENTRANCY_KEY] = true
69
+ ActiveSupport::Notifications.instrument(EVENT_NAME, notification_payload)
70
+ rescue StandardError => e
71
+ warn "[FulfilApi] a subscriber of #{EVENT_NAME} raised #{e.class}: #{e.message}"
72
+ ensure
73
+ Thread.current[REENTRANCY_KEY] = nil
74
+ end
75
+
76
+ private
77
+
78
+ # The payload published alongside {EVENT_NAME}.
79
+ #
80
+ # `:exception` and `:exception_object` follow the convention Rails uses for its
81
+ # own instrumentation, which is what an APM looks for to attribute an event to
82
+ # a failure.
83
+ #
84
+ # @return [Hash]
85
+ def notification_payload
86
+ { exception: [self.class.name, message], exception_object: self }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -3,7 +3,13 @@
3
3
  module FulfilApi
4
4
  # The {FulfilApi::Error} is the base class for all FulfilApi errors, also used
5
5
  # for generic or unexpected errors.
6
+ #
7
+ # Raising any of them publishes an {ActiveSupport::Notifications} event, so an
8
+ # application can report the failure to its APM without rescuing every call into
9
+ # the gem. See {FulfilApi::Error::Notifiable} and {FulfilApi.on_error}.
6
10
  class Error < StandardError
11
+ include Notifiable
12
+
7
13
  attr_reader :details
8
14
 
9
15
  # @param message [String] The displayable error message for the receiver.
@@ -32,4 +38,36 @@ module FulfilApi
32
38
  nil
33
39
  end
34
40
  end
41
+
42
+ # Subscribes to every {FulfilApi::Error} raised by the gem.
43
+ #
44
+ # This is the shorthand for the common case: reporting the failure somewhere. To
45
+ # reach the HTTP status code, response body and response headers of a
46
+ # {FulfilApi::HttpError} as well, subscribe to {FulfilApi::Error::EVENT_NAME}
47
+ # through {ActiveSupport::Notifications} directly.
48
+ #
49
+ # @example incrementing a counter of an APM
50
+ # # config/initializers/fulfil_api.rb
51
+ # FulfilApi.on_error do |error|
52
+ # Appsignal.increment_counter("fulfil_api_errors", 1, error: error.class.name)
53
+ # end
54
+ #
55
+ # @example reporting only the rate limit hits
56
+ # FulfilApi.on_error do |error|
57
+ # next unless error.is_a?(FulfilApi::HttpError::TooManyRequests)
58
+ #
59
+ # Appsignal.increment_counter("fulfil_api_rate_limit_exceeded")
60
+ # end
61
+ #
62
+ # @note The block runs while the error travels up the stack, on the thread that
63
+ # raised it. Keep it cheap, and hand anything slow to a background job.
64
+ #
65
+ # @yieldparam error [FulfilApi::Error] The error being raised.
66
+ # @return [Object] The subscriber, to hand back to
67
+ # `ActiveSupport::Notifications.unsubscribe` to stop listening.
68
+ def self.on_error
69
+ ActiveSupport::Notifications.subscribe(Error::EVENT_NAME) do |event|
70
+ yield(event.payload[:exception_object])
71
+ end
72
+ end
35
73
  end
@@ -174,5 +174,20 @@ module FulfilApi
174
174
  def status_code
175
175
  details&.dig(:response_status)
176
176
  end
177
+
178
+ private
179
+
180
+ # Enriches the payload published by {FulfilApi::Error::Notifiable} with what
181
+ # Fulfil reported, so a subscriber can tag its metrics by status code without
182
+ # having to unpack the exception itself.
183
+ #
184
+ # @return [Hash]
185
+ def notification_payload
186
+ super.merge(
187
+ response_body: response_body,
188
+ response_headers: response_headers,
189
+ status_code: status_code
190
+ )
191
+ end
177
192
  end
178
193
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ module Middleware
5
+ # The {FulfilApi::Middleware::CircuitBreaker} runs every request through a
6
+ # [Faulty](https://github.com/ParentSquare/faulty) circuit, so a Fulfil instance
7
+ # that keeps failing is skipped for a while instead of tying up the threads of an
8
+ # application that talks to several of them.
9
+ #
10
+ # Each Fulfil instance gets its own circuit, shared by the {FulfilApi::Client} and
11
+ # the {FulfilApi::TplClient}. The thresholds and the storage come from the
12
+ # configured Faulty instance.
13
+ #
14
+ # It wraps the retries, so a request that failed after retrying counts as one
15
+ # failure. A response with a 4xx status code is an answer, and doesn't count.
16
+ class CircuitBreaker < Faraday::Middleware
17
+ # The errors that count as a failure of the Fulfil instance.
18
+ FAILURES = [Faraday::ConnectionFailed, Faraday::ServerError, Faraday::TimeoutError].freeze
19
+
20
+ # Faulty wraps the error of a failed request into one of its own. Raising the
21
+ # original error instead keeps the gem's errors the same with or without a
22
+ # circuit breaker. Only an open circuit, which has no original error, raises
23
+ # a {FulfilApi::CircuitOpen}.
24
+ ERROR_MAPPER = lambda do |_error_name, cause, circuit|
25
+ cause || FulfilApi::CircuitOpen.new("#{circuit.name} is open after repeated failures", details: {})
26
+ end
27
+
28
+ # @param env [Faraday::Env] The environment of the request.
29
+ # @raise [FulfilApi::CircuitOpen] When the circuit of the Fulfil instance is open.
30
+ # @return [Faraday::Response]
31
+ def call(env)
32
+ circuit_for(env).run { @app.call(env) }
33
+ end
34
+
35
+ private
36
+
37
+ # @param env [Faraday::Env] The environment of the request.
38
+ # @return [Faulty::Circuit]
39
+ def circuit_for(env)
40
+ options[:faulty].circuit("fulfil_api:#{env.url.host}", errors: FAILURES, error_mapper: ERROR_MAPPER)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # The {FulfilApi::Middleware} sets up the Faraday middleware that both the
5
+ # {FulfilApi::Client} and the {FulfilApi::TplClient} run their requests through.
6
+ module Middleware
7
+ # The errors raised when a request never got an answer from Fulfil. An HTTP
8
+ # error response is an answer, and is never retried.
9
+ RETRYABLE_ERRORS = [Faraday::ConnectionFailed, Faraday::TimeoutError].freeze
10
+
11
+ class << self
12
+ # Adds the gem's middleware to a Faraday connection. Middleware runs in the
13
+ # order it's added, so the first one wraps everything after it.
14
+ #
15
+ # @param connection [Faraday::Connection] The connection being built.
16
+ # @param configuration [FulfilApi::Configuration] The configuration of the client.
17
+ # @return [void]
18
+ def apply(connection, configuration)
19
+ connection.use CircuitBreaker, faulty: configuration.circuit_breaker if configuration.circuit_breaker
20
+ connection.request :retry, retry_middleware_options(configuration.retry_options)
21
+ end
22
+
23
+ private
24
+
25
+ # Translates the {FulfilApi::Configuration#retry_options} into the options of
26
+ # the `faraday-retry` middleware.
27
+ #
28
+ # Ruby's built-in retry, and `faraday-retry` by default, treat every PUT as
29
+ # idempotent, while Fulfil uses PUT both to read (`search_read`,
30
+ # `search_count`) and to write (updating a record, holding a shipment).
31
+ # Retrying a write that timed out could apply it twice, so no HTTP verb is
32
+ # retried wholesale and every request is matched against the configured
33
+ # endpoints instead.
34
+ #
35
+ # @param options [Hash] The retry options of the configuration.
36
+ # @return [Hash]
37
+ def retry_middleware_options(options)
38
+ requests = options.fetch(:requests, {})
39
+
40
+ {
41
+ max: options.fetch(:max_retries, 0),
42
+ methods: [],
43
+ exceptions: RETRYABLE_ERRORS,
44
+ retry_if: ->(env, _exception) { retryable_request?(env, requests) }
45
+ }
46
+ end
47
+
48
+ # @param env [Faraday::Env] The environment of the request.
49
+ # @param requests [Hash] The HTTP verbs mapped onto their retryable endpoints.
50
+ # @return [true, false] Whether the endpoint of the request is safe to retry.
51
+ def retryable_request?(env, requests)
52
+ patterns = requests[env.method]
53
+ return true if patterns == true
54
+
55
+ Array(patterns).any? { |pattern| pattern.match?(env.url.path) }
56
+ end
57
+ end
58
+ end
59
+ end