seekmodo-sdk 0.5.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 +7 -0
- data/.github/workflows/ci.yml +52 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.rubocop.yml +32 -0
- data/Gemfile +11 -0
- data/README.md +135 -0
- data/docs/tool-catalog.README.md +12 -0
- data/lib/seekmodo/sdk/admin/client.rb +120 -0
- data/lib/seekmodo/sdk/auto_promoter.rb +124 -0
- data/lib/seekmodo/sdk/browser_token.rb +62 -0
- data/lib/seekmodo/sdk/circuit_breaker.rb +123 -0
- data/lib/seekmodo/sdk/connector/client.rb +250 -0
- data/lib/seekmodo/sdk/events/click_beacon.rb +58 -0
- data/lib/seekmodo/sdk/events/events_queue.rb +50 -0
- data/lib/seekmodo/sdk/exceptions/breaker_open_error.rb +10 -0
- data/lib/seekmodo/sdk/exceptions/client_error.rb +66 -0
- data/lib/seekmodo/sdk/exceptions/over_quota_error.rb +10 -0
- data/lib/seekmodo/sdk/exceptions/seekmodo_error.rb +8 -0
- data/lib/seekmodo/sdk/exceptions/signature_mismatch_error.rb +10 -0
- data/lib/seekmodo/sdk/exceptions/tenant_unavailable_error.rb +10 -0
- data/lib/seekmodo/sdk/hmac_signer.rb +43 -0
- data/lib/seekmodo/sdk/mcp/client.rb +105 -0
- data/lib/seekmodo/sdk/mode.rb +41 -0
- data/lib/seekmodo/sdk/mode_fsm.rb +52 -0
- data/lib/seekmodo/sdk/pairing.rb +114 -0
- data/lib/seekmodo/sdk/signature_mismatch_tracker.rb +52 -0
- data/lib/seekmodo/sdk/storage/memory/stores.rb +100 -0
- data/lib/seekmodo/sdk/storage/protocols.rb +47 -0
- data/lib/seekmodo/sdk/storefront/client.rb +71 -0
- data/lib/seekmodo/sdk/storefront/transport.rb +198 -0
- data/lib/seekmodo/sdk/tenant_snapshot.rb +65 -0
- data/lib/seekmodo/sdk/tools/registry.rb +88 -0
- data/lib/seekmodo/sdk/version.rb +7 -0
- data/lib/seekmodo/sdk.rb +33 -0
- data/lib/seekmodo-sdk.rb +3 -0
- metadata +109 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "storage/protocols"
|
|
4
|
+
|
|
5
|
+
module Seekmodo
|
|
6
|
+
module Sdk
|
|
7
|
+
class CircuitBreaker
|
|
8
|
+
STATE_CLOSED = "closed"
|
|
9
|
+
STATE_OPEN = "open"
|
|
10
|
+
STATE_HALFOPEN = "half_open"
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
store,
|
|
14
|
+
key: "numinix.seekmodo.circuit",
|
|
15
|
+
failure_threshold: 5,
|
|
16
|
+
failure_window_seconds: 60,
|
|
17
|
+
open_cooldown_seconds: 30,
|
|
18
|
+
clock: nil
|
|
19
|
+
)
|
|
20
|
+
@store = store
|
|
21
|
+
@key = key
|
|
22
|
+
@failure_threshold = failure_threshold
|
|
23
|
+
@failure_window_seconds = failure_window_seconds
|
|
24
|
+
@open_cooldown_seconds = open_cooldown_seconds
|
|
25
|
+
@clock = clock || -> { Time.now.to_i }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def allow_request?
|
|
29
|
+
state = load_state
|
|
30
|
+
now = @clock.call
|
|
31
|
+
|
|
32
|
+
if state["state"] == STATE_OPEN
|
|
33
|
+
if now - state["opened_at"].to_i >= @open_cooldown_seconds
|
|
34
|
+
state["state"] = STATE_HALFOPEN
|
|
35
|
+
state["probe_in_flight"] = true
|
|
36
|
+
save_state(state)
|
|
37
|
+
return true
|
|
38
|
+
end
|
|
39
|
+
return false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if state["state"] == STATE_HALFOPEN
|
|
43
|
+
return false if state["probe_in_flight"]
|
|
44
|
+
|
|
45
|
+
state["probe_in_flight"] = true
|
|
46
|
+
save_state(state)
|
|
47
|
+
return true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def record_success
|
|
54
|
+
state = load_state
|
|
55
|
+
if [STATE_HALFOPEN, STATE_OPEN].include?(state["state"])
|
|
56
|
+
save_state(
|
|
57
|
+
"state" => STATE_CLOSED,
|
|
58
|
+
"failures" => [],
|
|
59
|
+
"opened_at" => 0,
|
|
60
|
+
"probe_in_flight" => false
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if state["failures"].any?
|
|
66
|
+
state["failures"] = []
|
|
67
|
+
save_state(state)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def record_failure
|
|
72
|
+
state = load_state
|
|
73
|
+
now = @clock.call
|
|
74
|
+
|
|
75
|
+
if state["state"] == STATE_HALFOPEN
|
|
76
|
+
save_state(
|
|
77
|
+
"state" => STATE_OPEN,
|
|
78
|
+
"failures" => [],
|
|
79
|
+
"opened_at" => now,
|
|
80
|
+
"probe_in_flight" => false
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
failures = state["failures"] + [now]
|
|
86
|
+
cutoff = now - @failure_window_seconds
|
|
87
|
+
failures = failures.select { |ts| ts >= cutoff }
|
|
88
|
+
|
|
89
|
+
if failures.length >= @failure_threshold
|
|
90
|
+
save_state(
|
|
91
|
+
"state" => STATE_OPEN,
|
|
92
|
+
"failures" => [],
|
|
93
|
+
"opened_at" => now,
|
|
94
|
+
"probe_in_flight" => false
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
state["failures"] = failures
|
|
100
|
+
save_state(state)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def state
|
|
104
|
+
load_state["state"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def snapshot
|
|
108
|
+
load_state
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def load_state
|
|
114
|
+
@store.load(@key)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def save_state(state)
|
|
118
|
+
ttl = [@open_cooldown_seconds * 2, @failure_window_seconds * 4].max
|
|
119
|
+
@store.save(@key, state, ttl)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
require_relative "../exceptions/client_error"
|
|
7
|
+
require_relative "../exceptions/over_quota_error"
|
|
8
|
+
require_relative "../exceptions/signature_mismatch_error"
|
|
9
|
+
require_relative "../exceptions/tenant_unavailable_error"
|
|
10
|
+
require_relative "../hmac_signer"
|
|
11
|
+
require_relative "../circuit_breaker"
|
|
12
|
+
|
|
13
|
+
module Seekmodo
|
|
14
|
+
module Sdk
|
|
15
|
+
module Connector
|
|
16
|
+
class Client
|
|
17
|
+
DEFAULT_GATEWAY_URL = "https://mcp.seekmodo.com"
|
|
18
|
+
INDEX_CHUNK_SIZE = 500
|
|
19
|
+
INDEX_HARD_CAP_PER_CALL = 1000
|
|
20
|
+
DEFAULT_REQUEST_TIMEOUT_MS = 1500
|
|
21
|
+
DEFAULT_CONNECT_TIMEOUT_MS = 250
|
|
22
|
+
|
|
23
|
+
attr_reader :signer, :gateway_url
|
|
24
|
+
|
|
25
|
+
def initialize(
|
|
26
|
+
signer,
|
|
27
|
+
gateway_url: DEFAULT_GATEWAY_URL,
|
|
28
|
+
breaker: nil,
|
|
29
|
+
user_agent: "seekmodo-ruby-sdk/0.5.0",
|
|
30
|
+
storefront_host: "",
|
|
31
|
+
connection: nil,
|
|
32
|
+
timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
|
33
|
+
connect_timeout_ms: DEFAULT_CONNECT_TIMEOUT_MS
|
|
34
|
+
)
|
|
35
|
+
@signer = signer
|
|
36
|
+
@gateway_url = gateway_url.to_s.delete_suffix("/")
|
|
37
|
+
@breaker = breaker
|
|
38
|
+
@user_agent = user_agent
|
|
39
|
+
@storefront_host = storefront_host.to_s.downcase.strip
|
|
40
|
+
@owns_connection = connection.nil?
|
|
41
|
+
@connection = connection || build_connection(timeout_ms, connect_timeout_ms)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def close
|
|
45
|
+
@connection.close if @owns_connection && @connection.respond_to?(:close)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def search(params)
|
|
49
|
+
post_json("/v1/search", params)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def index(documents, action: "upsert")
|
|
53
|
+
if documents.length > INDEX_HARD_CAP_PER_CALL
|
|
54
|
+
merged = { "ok" => true, "imported" => 0, "errors" => [] }
|
|
55
|
+
documents.each_slice(INDEX_CHUNK_SIZE) do |chunk|
|
|
56
|
+
res = post_json("/v1/index", { "documents" => chunk, "action" => action })
|
|
57
|
+
merged["imported"] += res.fetch("imported", 0).to_i
|
|
58
|
+
errors = res["errors"]
|
|
59
|
+
merged["errors"] = merged["errors"] + errors if errors.is_a?(Array)
|
|
60
|
+
merged["ok"] = false unless res.fetch("ok", true)
|
|
61
|
+
end
|
|
62
|
+
return merged
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
post_json("/v1/index", { "documents" => documents, "action" => action })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def events(events)
|
|
69
|
+
post_json("/v1/events", { "events" => events })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tenant_handshake
|
|
73
|
+
post_json("/v1/tenant/handshake", {})
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tenant_snapshot
|
|
77
|
+
post_json("/v1/tenant.snapshot", {})
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def browser_token(audience = nil)
|
|
81
|
+
body = audience.nil? ? {} : { "audience" => audience }
|
|
82
|
+
post_json("/v1/tenants/token", body)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tools
|
|
86
|
+
get_json("/v1/tools")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def health
|
|
90
|
+
get_json("/v1/health", signed: false)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def post_json(path, body, extra_headers = {})
|
|
94
|
+
raw = encode_body(body)
|
|
95
|
+
execute("POST", path, raw, extra_headers, signed: true)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def get_json(path, extra_headers = {}, signed: true)
|
|
99
|
+
execute("GET", path, "", extra_headers, signed: signed)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def build_connection(timeout_ms, connect_timeout_ms)
|
|
105
|
+
Faraday.new do |f|
|
|
106
|
+
f.options.timeout = timeout_ms / 1000.0
|
|
107
|
+
f.options.open_timeout = connect_timeout_ms / 1000.0
|
|
108
|
+
f.adapter Faraday.default_adapter
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def execute(method, path, body, extra_headers, signed:)
|
|
113
|
+
if signed && !@signer.configured?
|
|
114
|
+
raise ClientError.new(
|
|
115
|
+
"Seekmodo client is missing tenant_id or shared_secret; cannot sign request.",
|
|
116
|
+
ClientError::KIND_NOT_CONFIGURED
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
if @breaker && !@breaker.allow_request?
|
|
120
|
+
raise ClientError.new(
|
|
121
|
+
"Seekmodo circuit breaker is open; refusing to call gateway.",
|
|
122
|
+
ClientError::KIND_BREAKER_OPEN
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
headers = {
|
|
127
|
+
"Accept" => "application/json",
|
|
128
|
+
"User-Agent" => @user_agent
|
|
129
|
+
}
|
|
130
|
+
headers["Content-Type"] = "application/json" if body && !body.empty?
|
|
131
|
+
if signed
|
|
132
|
+
headers.merge!(@signer.headers(body))
|
|
133
|
+
if !@storefront_host.empty?
|
|
134
|
+
headers[HmacSigner::HEADER_STOREFRONT_HOST] = @storefront_host
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
headers.merge!(extra_headers)
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
response = @connection.run_request(
|
|
141
|
+
method.downcase.to_sym,
|
|
142
|
+
@gateway_url + path,
|
|
143
|
+
body.empty? ? nil : body,
|
|
144
|
+
headers
|
|
145
|
+
)
|
|
146
|
+
rescue Faraday::TimeoutError => e
|
|
147
|
+
on_failure
|
|
148
|
+
raise ClientError.new(
|
|
149
|
+
"Network failure calling Seekmodo gateway: #{e.message}",
|
|
150
|
+
ClientError::KIND_TIMEOUT,
|
|
151
|
+
cause: e
|
|
152
|
+
)
|
|
153
|
+
rescue Faraday::Error => e
|
|
154
|
+
on_failure
|
|
155
|
+
raise ClientError.new(
|
|
156
|
+
"Network failure calling Seekmodo gateway: #{e.message}",
|
|
157
|
+
ClientError::KIND_NETWORK,
|
|
158
|
+
cause: e
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
classify(response)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def classify(response)
|
|
166
|
+
status = response.status
|
|
167
|
+
body = {}
|
|
168
|
+
if response.body && !response.body.to_s.empty?
|
|
169
|
+
decoded = JSON.parse(response.body)
|
|
170
|
+
body = decoded if decoded.is_a?(Hash)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if status >= 200 && status < 300
|
|
174
|
+
on_success
|
|
175
|
+
return body
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
error_code = body["error"].is_a?(String) ? body["error"] : nil
|
|
179
|
+
kind = ClientError.classify_error_code(error_code)
|
|
180
|
+
|
|
181
|
+
if status >= 500
|
|
182
|
+
on_failure
|
|
183
|
+
raise ClientError.new(
|
|
184
|
+
"Gateway returned HTTP #{status}",
|
|
185
|
+
ClientError::KIND_HTTP_5XX,
|
|
186
|
+
status,
|
|
187
|
+
body: body
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if kind == ClientError::KIND_TENANT_UNAVAILABLE
|
|
192
|
+
on_success
|
|
193
|
+
raise TenantUnavailableError.new(
|
|
194
|
+
"Tenant unavailable (gateway HTTP #{status}, code=#{error_code})",
|
|
195
|
+
kind,
|
|
196
|
+
status,
|
|
197
|
+
body: body
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if kind == ClientError::KIND_OVER_QUOTA || status == 402
|
|
202
|
+
on_success
|
|
203
|
+
raise OverQuotaError.new(
|
|
204
|
+
"Over plan quota (gateway HTTP #{status}, code=#{error_code || 'over_quota'})",
|
|
205
|
+
ClientError::KIND_OVER_QUOTA,
|
|
206
|
+
status,
|
|
207
|
+
body: body
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
if kind == ClientError::KIND_SIGNATURE_MISMATCH || status == 401
|
|
212
|
+
on_failure
|
|
213
|
+
raise SignatureMismatchError.new(
|
|
214
|
+
"Gateway rejected HMAC (HTTP #{status}, code=#{error_code || 'signature_mismatch'})",
|
|
215
|
+
ClientError::KIND_SIGNATURE_MISMATCH,
|
|
216
|
+
status,
|
|
217
|
+
body: body
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
on_failure
|
|
222
|
+
raise ClientError.new(
|
|
223
|
+
"Gateway returned HTTP #{status}",
|
|
224
|
+
ClientError::KIND_HTTP_4XX,
|
|
225
|
+
status,
|
|
226
|
+
body: body
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def on_success
|
|
231
|
+
@breaker&.record_success
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def on_failure
|
|
235
|
+
@breaker&.record_failure
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def encode_body(body)
|
|
239
|
+
JSON.generate(body)
|
|
240
|
+
rescue JSON::GeneratorError, TypeError => e
|
|
241
|
+
raise ClientError.new(
|
|
242
|
+
"Failed to JSON-encode request body: #{e.message}",
|
|
243
|
+
ClientError::KIND_BAD_RESPONSE,
|
|
244
|
+
cause: e
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seekmodo
|
|
4
|
+
module Sdk
|
|
5
|
+
module Events
|
|
6
|
+
module ClickBeacon
|
|
7
|
+
SURFACE_SERP = "serp"
|
|
8
|
+
SURFACE_TYPEAHEAD = "typeahead"
|
|
9
|
+
SURFACE_RECOMMENDATIONS = "recommendations"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def click(query, doc_id, position, is_bot, surface: SURFACE_SERP, shopper_context: nil, extra: nil)
|
|
14
|
+
event = {
|
|
15
|
+
"type" => "click",
|
|
16
|
+
"q" => query,
|
|
17
|
+
"doc_id" => doc_id,
|
|
18
|
+
"position" => position,
|
|
19
|
+
"is_bot" => is_bot,
|
|
20
|
+
"surface" => surface,
|
|
21
|
+
"ts" => Time.now.to_i
|
|
22
|
+
}
|
|
23
|
+
event["shopper"] = shopper_context if shopper_context
|
|
24
|
+
event.merge!(extra) if extra
|
|
25
|
+
event
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def impression(query, doc_ids, is_bot, surface: SURFACE_SERP, shopper_context: nil, extra: nil)
|
|
29
|
+
event = {
|
|
30
|
+
"type" => "impression",
|
|
31
|
+
"q" => query,
|
|
32
|
+
"doc_ids" => doc_ids.dup,
|
|
33
|
+
"is_bot" => is_bot,
|
|
34
|
+
"surface" => surface,
|
|
35
|
+
"ts" => Time.now.to_i
|
|
36
|
+
}
|
|
37
|
+
event["shopper"] = shopper_context if shopper_context
|
|
38
|
+
event.merge!(extra) if extra
|
|
39
|
+
event
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def search(query, hits, is_bot, shopper_context: nil, extra: nil)
|
|
43
|
+
event = {
|
|
44
|
+
"type" => "search",
|
|
45
|
+
"q" => query,
|
|
46
|
+
"hits" => hits,
|
|
47
|
+
"is_bot" => is_bot,
|
|
48
|
+
"surface" => SURFACE_SERP,
|
|
49
|
+
"ts" => Time.now.to_i
|
|
50
|
+
}
|
|
51
|
+
event["shopper"] = shopper_context if shopper_context
|
|
52
|
+
event.merge!(extra) if extra
|
|
53
|
+
event
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../connector/client"
|
|
4
|
+
require_relative "../storage/protocols"
|
|
5
|
+
|
|
6
|
+
module Seekmodo
|
|
7
|
+
module Sdk
|
|
8
|
+
module Events
|
|
9
|
+
class EventsQueue
|
|
10
|
+
DEFAULT_AUTO_FLUSH_THRESHOLD = 50
|
|
11
|
+
DEFAULT_MAX_PER_FLUSH = 200
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
client,
|
|
15
|
+
store,
|
|
16
|
+
auto_flush_threshold: DEFAULT_AUTO_FLUSH_THRESHOLD,
|
|
17
|
+
max_per_flush: DEFAULT_MAX_PER_FLUSH
|
|
18
|
+
)
|
|
19
|
+
@client = client
|
|
20
|
+
@store = store
|
|
21
|
+
@auto_flush_threshold = auto_flush_threshold
|
|
22
|
+
@max_per_flush = max_per_flush
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def push(event)
|
|
26
|
+
@store.push(event)
|
|
27
|
+
flush if @store.count >= @auto_flush_threshold
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def flush
|
|
31
|
+
batch = @store.drain(@max_per_flush)
|
|
32
|
+
return 0 if batch.empty?
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
@client.events(batch)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
batch.each { |event| @store.push(event) }
|
|
38
|
+
return 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
batch.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pending_count
|
|
45
|
+
@store.count
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "seekmodo_error"
|
|
4
|
+
|
|
5
|
+
module Seekmodo
|
|
6
|
+
module Sdk
|
|
7
|
+
class ClientError < SeekmodoError
|
|
8
|
+
KIND_NOT_CONFIGURED = "not_configured"
|
|
9
|
+
KIND_BREAKER_OPEN = "breaker_open"
|
|
10
|
+
KIND_NETWORK = "network"
|
|
11
|
+
KIND_TIMEOUT = "timeout"
|
|
12
|
+
KIND_HTTP_4XX = "http_4xx"
|
|
13
|
+
KIND_HTTP_5XX = "http_5xx"
|
|
14
|
+
KIND_BAD_RESPONSE = "bad_response"
|
|
15
|
+
KIND_SIGNATURE_MISMATCH = "signature_mismatch"
|
|
16
|
+
KIND_RATE_LIMITED = "rate_limited"
|
|
17
|
+
KIND_OVER_QUOTA = "over_quota"
|
|
18
|
+
KIND_TENANT_UNAVAILABLE = "tenant_unavailable"
|
|
19
|
+
|
|
20
|
+
TENANT_UNAVAILABLE_ERROR_CODES = %w[
|
|
21
|
+
tenant_paused tenant_not_found tenant_unknown tenant_suspended tenant_disabled
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :kind, :status_code, :body
|
|
25
|
+
|
|
26
|
+
def initialize(message, kind, status_code = 0, body: nil, cause: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@kind = kind
|
|
29
|
+
@status_code = status_code
|
|
30
|
+
@body = body || {}
|
|
31
|
+
set_backtrace(cause&.backtrace) if cause
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def is_transient?
|
|
35
|
+
[
|
|
36
|
+
KIND_NETWORK,
|
|
37
|
+
KIND_TIMEOUT,
|
|
38
|
+
KIND_HTTP_5XX,
|
|
39
|
+
KIND_TENANT_UNAVAILABLE
|
|
40
|
+
].include?(kind)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def should_fallback?
|
|
44
|
+
kind != KIND_HTTP_4XX
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def error_code
|
|
48
|
+
err = body["error"]
|
|
49
|
+
err.is_a?(String) ? err : nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.classify_error_code(error_code)
|
|
53
|
+
return nil if error_code.nil? || error_code.empty?
|
|
54
|
+
|
|
55
|
+
if TENANT_UNAVAILABLE_ERROR_CODES.include?(error_code)
|
|
56
|
+
return KIND_TENANT_UNAVAILABLE
|
|
57
|
+
end
|
|
58
|
+
return KIND_SIGNATURE_MISMATCH if error_code == "signature_mismatch"
|
|
59
|
+
return KIND_RATE_LIMITED if error_code == "rate_limited"
|
|
60
|
+
return KIND_OVER_QUOTA if %w[over_quota feature_not_in_plan].include?(error_code)
|
|
61
|
+
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Seekmodo
|
|
6
|
+
module Sdk
|
|
7
|
+
class HmacSigner
|
|
8
|
+
HEADER_TENANT = "X-Seekmodo-Tenant"
|
|
9
|
+
HEADER_SIGNATURE = "X-Seekmodo-Signature"
|
|
10
|
+
HEADER_TIMESTAMP = "X-Seekmodo-Timestamp"
|
|
11
|
+
HEADER_SESSION = "X-Seekmodo-Session"
|
|
12
|
+
HEADER_STOREFRONT_HOST = "X-Seekmodo-Storefront-Host"
|
|
13
|
+
|
|
14
|
+
attr_reader :tenant_id
|
|
15
|
+
|
|
16
|
+
def initialize(tenant_id, shared_secret)
|
|
17
|
+
@tenant_id = tenant_id
|
|
18
|
+
@shared_secret = shared_secret
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def headers(raw_body, timestamp = nil)
|
|
22
|
+
ts = (timestamp || Time.now.to_i).to_s
|
|
23
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", @shared_secret, raw_body)
|
|
24
|
+
{
|
|
25
|
+
HEADER_TENANT => @tenant_id,
|
|
26
|
+
HEADER_SIGNATURE => signature,
|
|
27
|
+
HEADER_TIMESTAMP => ts
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify(raw_body, signature)
|
|
32
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", @shared_secret, raw_body)
|
|
33
|
+
return false if signature.nil? || signature.empty?
|
|
34
|
+
|
|
35
|
+
OpenSSL.secure_compare(expected, signature)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def configured?
|
|
39
|
+
!@tenant_id.to_s.empty? && !@shared_secret.to_s.empty?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|