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.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +15 -0
- data/README.md +193 -2
- data/app/controllers/concerns/fulfil_api/authenticated.rb +43 -0
- data/app/controllers/concerns/fulfil_api/installation_scoped.rb +56 -0
- data/app/controllers/fulfil_api/application_controller.rb +31 -0
- data/app/controllers/fulfil_api/installations_controller.rb +111 -0
- data/app/models/concerns/fulfil_api/installable.rb +47 -0
- data/app/models/fulfil_api/installation.rb +145 -0
- data/config/routes.rb +9 -0
- data/lib/fulfil_api/circuit_open.rb +9 -0
- data/lib/fulfil_api/client.rb +10 -6
- data/lib/fulfil_api/configuration.rb +72 -8
- data/lib/fulfil_api/engine.rb +14 -0
- data/lib/fulfil_api/error/notifiable.rb +90 -0
- data/lib/fulfil_api/error.rb +38 -0
- data/lib/fulfil_api/http_error.rb +15 -0
- data/lib/fulfil_api/middleware/circuit_breaker.rb +44 -0
- data/lib/fulfil_api/middleware.rb +59 -0
- data/lib/fulfil_api/oauth/authorization.rb +172 -0
- data/lib/fulfil_api/oauth/configuration.rb +96 -0
- data/lib/fulfil_api/oauth/token.rb +78 -0
- data/lib/fulfil_api/oauth.rb +37 -0
- data/lib/fulfil_api/relation.rb +2 -2
- data/lib/fulfil_api/resource/attribute_assignable.rb +12 -12
- data/lib/fulfil_api/tpl_client.rb +10 -6
- data/lib/fulfil_api/version.rb +1 -1
- data/lib/fulfil_api.rb +12 -0
- data/lib/generators/fulfil_api/install/install_generator.rb +72 -0
- data/lib/generators/fulfil_api/install/templates/create_fulfil_api_installations.rb +43 -0
- data/lib/generators/fulfil_api/install/templates/fulfil_api.rb +38 -0
- 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
|
data/lib/fulfil_api/client.rb
CHANGED
|
@@ -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
|
|
140
|
-
#
|
|
141
|
-
#
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
data/lib/fulfil_api/error.rb
CHANGED
|
@@ -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
|