coolhand 0.5.0 → 0.6.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/AGENTS.harness.md +139 -0
- data/CHANGELOG.md +64 -0
- data/README.md +46 -105
- data/docs/configuration.md +40 -0
- data/docs/openai.md +91 -0
- data/docs/template-search.md +218 -0
- data/docs/vertex.md +54 -0
- data/lib/coolhand/api_service.rb +56 -16
- data/lib/coolhand/base_interceptor.rb +58 -38
- data/lib/coolhand/configuration.rb +39 -6
- data/lib/coolhand/default_exclude_api_patterns.yml +3 -2
- data/lib/coolhand/default_intercept_addresses.yml +10 -5
- data/lib/coolhand/default_intercept_path_patterns.yml +12 -0
- data/lib/coolhand/errors.rb +19 -0
- data/lib/coolhand/logger_service.rb +1 -1
- data/lib/coolhand/net_http_interceptor.rb +154 -24
- data/lib/coolhand/open_ai/batch_result_processor.rb +3 -1
- data/lib/coolhand/open_ai/webhook_id_store.rb +45 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +50 -12
- data/lib/coolhand/pagination.rb +82 -0
- data/lib/coolhand/read_requests.rb +77 -0
- data/lib/coolhand/template_service.rb +76 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +97 -40
- data/lib/coolhand/webhook_interceptor.rb +11 -0
- data/lib/coolhand.rb +10 -5
- metadata +16 -9
- data/.claude/skills/loop-review/SKILL.md +0 -112
- data/.claude/skills/prep-release/SKILL.md +0 -160
- data/.idea/coolhand-ruby.iml +0 -6
- data/CLAUDE.md +0 -47
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised by the gem's read methods when the Coolhand API answers with a non-2xx status.
|
|
7
|
+
#
|
|
8
|
+
# `status` is carried so callers can branch on it (404 vs retryable 504) without matching the
|
|
9
|
+
# message text.
|
|
10
|
+
class HttpError < Error
|
|
11
|
+
attr_reader :status, :body
|
|
12
|
+
|
|
13
|
+
def initialize(message, status:, body: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status = status
|
|
16
|
+
@body = body
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -44,7 +44,7 @@ module Coolhand
|
|
|
44
44
|
headers: sanitize_headers(headers),
|
|
45
45
|
request_body: clean_webhook_body(webhook_body, source),
|
|
46
46
|
response_body: options[:response_body],
|
|
47
|
-
response_headers: options[:response_headers],
|
|
47
|
+
response_headers: options[:response_headers] && sanitize_headers(options[:response_headers]),
|
|
48
48
|
status_code: options[:status_code] || 200,
|
|
49
49
|
source: "#{source}_webhook"
|
|
50
50
|
}.merge(options.slice(:metadata, :conversation_id, :agent_id))
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
3
5
|
module Coolhand
|
|
4
6
|
module NetHttpInterceptor
|
|
5
7
|
include BaseInterceptor
|
|
@@ -7,7 +9,12 @@ module Coolhand
|
|
|
7
9
|
# Response streaming interceptor nested under NetHttpInterceptor
|
|
8
10
|
module ResponseInterceptor
|
|
9
11
|
def read_body(dest = nil, &block)
|
|
10
|
-
|
|
12
|
+
# Only buffer while a #request call below is actively capturing —
|
|
13
|
+
# otherwise every block-form read_body in the process (including
|
|
14
|
+
# ones on responses this gem never intercepted) accumulates into a
|
|
15
|
+
# thread-local that's never freed, which is an unbounded memory
|
|
16
|
+
# leak on any thread that streams large non-LLM responses.
|
|
17
|
+
return super unless block && Thread.current[:coolhand_capturing_stream]
|
|
11
18
|
|
|
12
19
|
super do |chunk|
|
|
13
20
|
Thread.current[:coolhand_stream_buffer] ||= +""
|
|
@@ -17,27 +24,69 @@ module Coolhand
|
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
|
|
27
|
+
@patch_mutex = Mutex.new
|
|
28
|
+
@patch_count = 0
|
|
29
|
+
@patched = false
|
|
30
|
+
|
|
31
|
+
# patch!/unpatch! are reference-counted so concurrent/nested callers (e.g. overlapping
|
|
32
|
+
# Coolhand.capture blocks across threads) compose safely — the interceptor only actually
|
|
33
|
+
# unpatches once every outstanding caller has released it. Coolhand.configure's patch! is
|
|
34
|
+
# never balanced by an unpatch!, so it permanently holds the count at >=1 for the process
|
|
35
|
+
# lifetime once the gem is enabled; that's intentional, not a leak.
|
|
36
|
+
#
|
|
37
|
+
# Unlike before, patch! is no longer idempotent on its own — every call must be matched by
|
|
38
|
+
# exactly one unpatch! to release it. A host app that calls Coolhand.configure more than once
|
|
39
|
+
# (e.g. a reloader re-running an initializer) will hold an extra, permanent reference each
|
|
40
|
+
# time rather than no-op'ing; harmless (interception simply stays on), but worth knowing.
|
|
20
41
|
def self.patch!
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
@patch_mutex.synchronize do
|
|
43
|
+
@patch_count += 1
|
|
44
|
+
next if @patched
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
Net::HTTP.prepend(self)
|
|
48
|
+
Net::HTTPResponse.prepend(ResponseInterceptor)
|
|
49
|
+
|
|
50
|
+
@patched = true
|
|
51
|
+
Coolhand.log "🔗 Net::HTTP interceptor patched"
|
|
52
|
+
rescue StandardError
|
|
53
|
+
# Roll back this call's hold — it never actually took effect, so it must not count
|
|
54
|
+
# toward the refcount or a legitimate later unpatch! would underflow against it. This
|
|
55
|
+
# branch only runs when @patched was false on entry (see `next if @patched` above), so
|
|
56
|
+
# forcing it back to false here is correct regardless of which line above raised —
|
|
57
|
+
# including a failure in the log call itself, after prepend already succeeded.
|
|
58
|
+
@patch_count -= 1
|
|
59
|
+
@patched = false
|
|
60
|
+
raise
|
|
61
|
+
end
|
|
62
|
+
end
|
|
28
63
|
end
|
|
29
64
|
|
|
30
65
|
def self.unpatch!
|
|
31
66
|
# NOTE: With prepend, there's no clean way to unpatch
|
|
32
67
|
# We'll mark it as unpatched so it can be re-patched
|
|
33
|
-
@
|
|
34
|
-
|
|
68
|
+
@patch_mutex.synchronize do
|
|
69
|
+
@patch_count -= 1 if @patch_count.positive?
|
|
70
|
+
next if @patch_count.positive? || !@patched
|
|
71
|
+
|
|
72
|
+
@patched = false
|
|
73
|
+
Coolhand.log "🔌 Faraday monitoring disabled ..."
|
|
74
|
+
end
|
|
35
75
|
end
|
|
36
76
|
|
|
37
77
|
def self.patched?
|
|
38
78
|
@patched
|
|
39
79
|
end
|
|
40
80
|
|
|
81
|
+
# Testing-only: force a clean slate. Real callers should only ever use balanced
|
|
82
|
+
# patch!/unpatch! pairs.
|
|
83
|
+
def self.reset!
|
|
84
|
+
@patch_mutex.synchronize do
|
|
85
|
+
@patch_count = 0
|
|
86
|
+
@patched = false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
41
90
|
def request(req, body = nil, &block)
|
|
42
91
|
return super unless NetHttpInterceptor.patched?
|
|
43
92
|
|
|
@@ -49,8 +98,16 @@ module Coolhand
|
|
|
49
98
|
return super unless should_capture?
|
|
50
99
|
|
|
51
100
|
# Capture body before setting the guard — if this raises we skip logging cleanly
|
|
52
|
-
# and the guard is never set, so there is no leak.
|
|
53
|
-
|
|
101
|
+
# and the guard is never set, so there is no leak. A failure here (e.g. an
|
|
102
|
+
# already-consumed body_stream) must never prevent the real request below
|
|
103
|
+
# from being attempted — this gem must never be the reason the host
|
|
104
|
+
# app's actual LLM call doesn't happen.
|
|
105
|
+
captured_body = begin
|
|
106
|
+
capture_request_body(req, body)
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
Coolhand.log "❌ Error capturing request body: #{e.message}"
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
54
111
|
|
|
55
112
|
active[self] = true
|
|
56
113
|
start_time = Time.now
|
|
@@ -59,7 +116,14 @@ module Coolhand
|
|
|
59
116
|
status_code = nil
|
|
60
117
|
response_body = nil
|
|
61
118
|
|
|
119
|
+
# Save/restore rather than just nil-ing: a request made from inside
|
|
120
|
+
# this request's own streaming block (nested interception) would
|
|
121
|
+
# otherwise clobber this request's in-progress buffer with its own
|
|
122
|
+
# chunks, mixing one request's content into another's log.
|
|
123
|
+
previous_stream_buffer = Thread.current[:coolhand_stream_buffer]
|
|
124
|
+
previous_capturing_stream = Thread.current[:coolhand_capturing_stream]
|
|
62
125
|
Thread.current[:coolhand_stream_buffer] = nil
|
|
126
|
+
Thread.current[:coolhand_capturing_stream] = true
|
|
63
127
|
|
|
64
128
|
begin
|
|
65
129
|
response = super
|
|
@@ -73,7 +137,8 @@ module Coolhand
|
|
|
73
137
|
raise
|
|
74
138
|
ensure
|
|
75
139
|
active.delete(self)
|
|
76
|
-
Thread.current[:coolhand_stream_buffer] =
|
|
140
|
+
Thread.current[:coolhand_stream_buffer] = previous_stream_buffer
|
|
141
|
+
Thread.current[:coolhand_capturing_stream] = previous_capturing_stream
|
|
77
142
|
end_time = Time.now
|
|
78
143
|
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
79
144
|
|
|
@@ -108,16 +173,41 @@ module Coolhand
|
|
|
108
173
|
end
|
|
109
174
|
|
|
110
175
|
def capture_request_body(req, body)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
176
|
+
# Check content-type before touching body_stream at all — for a binary
|
|
177
|
+
# upload (multipart/form-data, audio/*, etc.) this avoids reading the
|
|
178
|
+
# stream into memory a second time just to build a log entry no one
|
|
179
|
+
# can read anyway.
|
|
180
|
+
return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
|
|
181
|
+
|
|
182
|
+
content = body || req.body
|
|
183
|
+
if content.nil? && req.respond_to?(:body_stream) && req.body_stream
|
|
115
184
|
content = req.body_stream.read
|
|
116
185
|
req.body_stream = StringIO.new(content)
|
|
117
|
-
return parse_json(content)
|
|
118
186
|
end
|
|
187
|
+
return nil if content.nil?
|
|
119
188
|
|
|
120
|
-
|
|
189
|
+
cap_and_parse(content, req)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def binary_upload?(req)
|
|
193
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
194
|
+
content_type && !content_type.match?(/json/i)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def cap_and_parse(content, req)
|
|
198
|
+
max_bytes = Coolhand.configuration.max_captured_body_bytes
|
|
199
|
+
if max_bytes && content.bytesize > max_bytes
|
|
200
|
+
return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
parse_json(content)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def skipped_capture_marker(req, reason, extra = {})
|
|
207
|
+
marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
|
|
208
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
209
|
+
marker["content_type"] = content_type if content_type
|
|
210
|
+
marker
|
|
121
211
|
end
|
|
122
212
|
|
|
123
213
|
def extract_status_from_exception(e)
|
|
@@ -130,22 +220,62 @@ module Coolhand
|
|
|
130
220
|
|
|
131
221
|
def intercept?(url)
|
|
132
222
|
return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
|
|
133
|
-
return false if excluded_by_pattern?(url)
|
|
134
223
|
|
|
135
|
-
|
|
224
|
+
uri = safe_parse(url)
|
|
225
|
+
return false unless uri&.host
|
|
226
|
+
|
|
227
|
+
return false if excluded_by_pattern?(uri)
|
|
228
|
+
|
|
229
|
+
host = uri.host.downcase
|
|
230
|
+
addresses = Coolhand.configuration.intercept_addresses
|
|
231
|
+
return true if addresses.any? { |a| host_matches?(host, a) }
|
|
232
|
+
|
|
233
|
+
return false unless google_api_host_configured?(addresses)
|
|
234
|
+
return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
|
|
235
|
+
|
|
236
|
+
path = uri.path.to_s
|
|
237
|
+
Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
|
|
136
238
|
end
|
|
137
239
|
|
|
138
|
-
def excluded_by_pattern?(
|
|
240
|
+
def excluded_by_pattern?(uri)
|
|
139
241
|
patterns = Coolhand.configuration.exclude_api_patterns
|
|
140
242
|
return false if patterns.nil? || patterns.empty?
|
|
141
243
|
|
|
142
|
-
|
|
244
|
+
path = uri.path.to_s
|
|
245
|
+
matched = patterns.find { |pattern| path.include?(pattern) }
|
|
143
246
|
if matched && Coolhand.configuration.debug_mode
|
|
144
|
-
Coolhand.log "🚫 Skipping capture for #{sanitize_url(
|
|
247
|
+
Coolhand.log "🚫 Skipping capture for #{sanitize_url(uri.to_s)} (matched exclude_api_pattern: \"#{matched}\")"
|
|
145
248
|
end
|
|
146
249
|
!!matched
|
|
147
250
|
end
|
|
148
251
|
|
|
252
|
+
# intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
|
|
253
|
+
# user still wants Google API traffic intercepted at all — otherwise overriding
|
|
254
|
+
# intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
|
|
255
|
+
def google_api_host_configured?(addresses)
|
|
256
|
+
addresses.any? do |a|
|
|
257
|
+
a = a.to_s.downcase
|
|
258
|
+
a == "googleapis.com" || a.end_with?(".googleapis.com")
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
|
|
263
|
+
# A single "*" in `pattern` matches exactly one host label, e.g.
|
|
264
|
+
# "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
|
|
265
|
+
def host_matches?(host, pattern)
|
|
266
|
+
pattern = pattern.to_s.downcase
|
|
267
|
+
return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
|
|
268
|
+
|
|
269
|
+
regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
|
|
270
|
+
!!(host =~ regex)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def safe_parse(url)
|
|
274
|
+
URI.parse(url)
|
|
275
|
+
rescue URI::InvalidURIError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
149
279
|
def build_url_for_request(http, req)
|
|
150
280
|
return req.path if %r{\Ahttps?://}.match?(req.path)
|
|
151
281
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../coolhand"
|
|
4
|
+
|
|
3
5
|
module Coolhand
|
|
4
6
|
module OpenAi
|
|
5
7
|
class BatchResultProcessor
|
|
@@ -96,7 +98,7 @@ module Coolhand
|
|
|
96
98
|
id: request_id,
|
|
97
99
|
timestamp: timestamp,
|
|
98
100
|
method: method.to_s.downcase,
|
|
99
|
-
url: url,
|
|
101
|
+
url: BaseInterceptor.sanitize_url(url),
|
|
100
102
|
headers: {},
|
|
101
103
|
request_body: request_body,
|
|
102
104
|
response_headers: {},
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
module OpenAi
|
|
5
|
+
# Thread-safe, in-memory, TTL-bounded store used to detect replayed
|
|
6
|
+
# `webhook-id` values. This is the default for
|
|
7
|
+
# `Coolhand.configuration.webhook_id_store` and only dedupes within a
|
|
8
|
+
# single process - deployments running multiple processes/dynos should
|
|
9
|
+
# supply their own store (e.g. backed by Rails.cache) via configuration.
|
|
10
|
+
class WebhookIdStore
|
|
11
|
+
def initialize
|
|
12
|
+
@entries = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Atomically checks-and-records `id` in a single critical section, so
|
|
17
|
+
# two concurrent replays of the same id can't both observe "unseen"
|
|
18
|
+
# before either records it. Returns true the first time `id` is
|
|
19
|
+
# claimed, false if it was already claimed within its TTL.
|
|
20
|
+
def claim!(id, ttl_seconds)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
prune
|
|
23
|
+
return false if @entries.key?(id)
|
|
24
|
+
|
|
25
|
+
@entries[id] = Time.now.to_i + ttl_seconds
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def seen?(id)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
prune
|
|
33
|
+
@entries.key?(id)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def prune
|
|
40
|
+
now = Time.now.to_i
|
|
41
|
+
@entries.delete_if { |_id, expires_at| expires_at < now }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
require_relative "webhook_id_store"
|
|
6
|
+
|
|
3
7
|
module Coolhand
|
|
4
8
|
module OpenAi
|
|
5
9
|
class WebhookValidator
|
|
6
|
-
attr_reader :request, :errors, :payload
|
|
10
|
+
attr_reader :request, :errors, :payload
|
|
7
11
|
|
|
8
12
|
def initialize(request, webhook_secret)
|
|
9
13
|
@request = request
|
|
@@ -16,12 +20,12 @@ module Coolhand
|
|
|
16
20
|
@payload = request.raw_post || request.body.read
|
|
17
21
|
|
|
18
22
|
return false unless payload_valid?
|
|
19
|
-
return validate_in_non_production_env unless webhook_secret
|
|
23
|
+
return validate_in_non_production_env unless Coolhand.required_field?(webhook_secret)
|
|
20
24
|
|
|
21
25
|
secret_bytes = extract_secret_bytes
|
|
22
26
|
webhook_signature, webhook_timestamp, webhook_id = extract_webhook_headers
|
|
23
27
|
|
|
24
|
-
return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp
|
|
28
|
+
return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp && webhook_id
|
|
25
29
|
|
|
26
30
|
verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
|
|
27
31
|
end
|
|
@@ -32,11 +36,14 @@ module Coolhand
|
|
|
32
36
|
|
|
33
37
|
private
|
|
34
38
|
|
|
39
|
+
attr_reader :webhook_secret
|
|
40
|
+
|
|
35
41
|
def payload_valid?
|
|
36
42
|
return true if @payload
|
|
37
43
|
|
|
38
44
|
if should_enforce_strict_validation?
|
|
39
|
-
@errors << "Empty webhook payload - rejecting webhook
|
|
45
|
+
@errors << "Empty webhook payload - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
|
|
46
|
+
"not in development/test allowlist)"
|
|
40
47
|
Rails.logger.error(@errors.last)
|
|
41
48
|
false
|
|
42
49
|
else
|
|
@@ -47,7 +54,8 @@ module Coolhand
|
|
|
47
54
|
|
|
48
55
|
def validate_in_non_production_env
|
|
49
56
|
if should_enforce_strict_validation?
|
|
50
|
-
@errors << "OpenAI webhook secret not configured - rejecting webhook
|
|
57
|
+
@errors << "OpenAI webhook secret not configured - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
|
|
58
|
+
"not in development/test allowlist)"
|
|
51
59
|
Rails.logger.error(@errors.last)
|
|
52
60
|
false
|
|
53
61
|
else
|
|
@@ -76,8 +84,8 @@ module Coolhand
|
|
|
76
84
|
|
|
77
85
|
def validate_headers_in_non_production_env
|
|
78
86
|
if should_enforce_strict_validation?
|
|
79
|
-
@errors << "Missing OpenAI webhook signature or
|
|
80
|
-
"rejecting webhook in
|
|
87
|
+
@errors << "Missing OpenAI webhook signature, timestamp, or id headers - " \
|
|
88
|
+
"rejecting webhook (Rails.env=#{Rails.env.inspect} not in development/test allowlist)"
|
|
81
89
|
Rails.logger.error(@errors.last)
|
|
82
90
|
false
|
|
83
91
|
else
|
|
@@ -92,13 +100,43 @@ module Coolhand
|
|
|
92
100
|
|
|
93
101
|
signature_valid = webhook_signature.start_with?("v1,") &&
|
|
94
102
|
secure_compare(webhook_signature[3..], expected_signature)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
else
|
|
103
|
+
|
|
104
|
+
unless signature_valid
|
|
98
105
|
@errors << "OpenAI webhook signature verification failed"
|
|
99
106
|
Rails.logger.error(@errors.last)
|
|
100
|
-
false
|
|
107
|
+
return false
|
|
101
108
|
end
|
|
109
|
+
|
|
110
|
+
return false unless timestamp_fresh?(webhook_timestamp)
|
|
111
|
+
return false unless webhook_id_unused?(webhook_id)
|
|
112
|
+
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Checked only after the signature is confirmed valid, so an
|
|
117
|
+
# unsigned/forged request can't poison the id-dedup store (or fail a
|
|
118
|
+
# freshness check) and DoS a later legitimate webhook with the same id.
|
|
119
|
+
def timestamp_fresh?(webhook_timestamp)
|
|
120
|
+
tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
|
|
121
|
+
age = (Time.now.to_i - webhook_timestamp.to_i).abs
|
|
122
|
+
return true if age <= tolerance
|
|
123
|
+
|
|
124
|
+
@errors << "OpenAI webhook timestamp outside replay-protection tolerance window"
|
|
125
|
+
Rails.logger.error(@errors.last)
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def webhook_id_unused?(webhook_id)
|
|
130
|
+
tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
|
|
131
|
+
return true if id_store.claim!(webhook_id, tolerance)
|
|
132
|
+
|
|
133
|
+
@errors << "OpenAI webhook id already processed (replay protection)"
|
|
134
|
+
Rails.logger.error(@errors.last)
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def id_store
|
|
139
|
+
Coolhand.configuration.webhook_id_store
|
|
102
140
|
end
|
|
103
141
|
|
|
104
142
|
def secure_compare(a, b)
|
|
@@ -120,7 +158,7 @@ module Coolhand
|
|
|
120
158
|
end
|
|
121
159
|
|
|
122
160
|
def should_enforce_strict_validation?
|
|
123
|
-
[
|
|
161
|
+
!%w[development test].include?(Rails.env)
|
|
124
162
|
end
|
|
125
163
|
end
|
|
126
164
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Paging state for a v2 list endpoint. These send a bare JSON array and carry paging in the
|
|
5
|
+
# `X-Page`, `X-Per-Page`, `X-Total-Count` and `X-Total-Pages` headers, never in the body.
|
|
6
|
+
Pagination = Struct.new(
|
|
7
|
+
:current_page,
|
|
8
|
+
:per_page,
|
|
9
|
+
:total_count,
|
|
10
|
+
:total_pages,
|
|
11
|
+
:has_next_page,
|
|
12
|
+
:has_prev_page,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
class Pagination
|
|
17
|
+
# Mirrors of the v2 controllers' values, used only to fill a header the server did not send.
|
|
18
|
+
DEFAULT_PER_PAGE = 25
|
|
19
|
+
MAX_PER_PAGE = 100
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def from_headers(response, items:, page: nil, per: nil)
|
|
23
|
+
requested_page = positive_int(page) || 1
|
|
24
|
+
requested_per = [positive_int(per) || DEFAULT_PER_PAGE, MAX_PER_PAGE].min
|
|
25
|
+
|
|
26
|
+
current_page = header_int(response, "X-Page") || requested_page
|
|
27
|
+
per_page = header_int(response, "X-Per-Page") || requested_per
|
|
28
|
+
reported_total_pages = header_int(response, "X-Total-Pages")
|
|
29
|
+
total_count = header_int(response, "X-Total-Count") || fallback_total_count(current_page, per_page, items)
|
|
30
|
+
total_pages = reported_total_pages || fallback_total_pages(total_count, per_page)
|
|
31
|
+
|
|
32
|
+
new(
|
|
33
|
+
current_page: current_page,
|
|
34
|
+
per_page: per_page,
|
|
35
|
+
total_count: total_count,
|
|
36
|
+
total_pages: total_pages,
|
|
37
|
+
has_next_page: next_page?(reported_total_pages, current_page, per_page, items),
|
|
38
|
+
has_prev_page: current_page > 1
|
|
39
|
+
).freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Falling back to the computed totals here would report "no next page" for a full page, and
|
|
45
|
+
# silently truncate a caller's loop.
|
|
46
|
+
def next_page?(reported_total_pages, current_page, per_page, items)
|
|
47
|
+
return current_page < reported_total_pages if reported_total_pages
|
|
48
|
+
return false unless per_page.positive?
|
|
49
|
+
|
|
50
|
+
items.size >= per_page
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A lower bound, not a count: every earlier page assumed full, plus this page.
|
|
54
|
+
def fallback_total_count(current_page, per_page, items)
|
|
55
|
+
return items.size unless per_page.positive?
|
|
56
|
+
|
|
57
|
+
[((current_page - 1) * per_page) + items.size, items.size].max
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fallback_total_pages(total_count, per_page)
|
|
61
|
+
return total_count.positive? ? 1 : 0 unless per_page.positive?
|
|
62
|
+
|
|
63
|
+
(total_count.to_f / per_page).ceil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Neither `Integer()` nor `to_i` is safe alone: the first raises on `""`, the second turns
|
|
67
|
+
# `"3.5"` into `3` and `"nonsense"` into `0` — a fabricated, legitimate-looking count.
|
|
68
|
+
def header_int(response, name)
|
|
69
|
+
raw = response[name]
|
|
70
|
+
return nil if raw.nil?
|
|
71
|
+
|
|
72
|
+
value = raw.strip
|
|
73
|
+
value.match?(/\A\d+\z/) ? value.to_i : nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def positive_int(value)
|
|
77
|
+
integer = Integer(value, exception: false)
|
|
78
|
+
integer&.positive? ? integer : nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Coolhand
|
|
9
|
+
# The GET half of {ApiService}, split out to keep that class inside this repo's 200-line budget.
|
|
10
|
+
#
|
|
11
|
+
# Reads raise where writes log-and-return-nil, on purpose: a write is instrumentation inline in
|
|
12
|
+
# the host app's request, while a read's caller must tell a 404 from a timeout from an empty result.
|
|
13
|
+
module ReadRequests
|
|
14
|
+
# 60s is deliberate, not a slip: writes allow 5, but the server bounds each *statement* at 10s
|
|
15
|
+
# and one response runs several. A tighter read timeout pre-empts the 504 callers should retry.
|
|
16
|
+
READ_OPEN_TIMEOUT = 5
|
|
17
|
+
READ_TIMEOUT = 60
|
|
18
|
+
|
|
19
|
+
# ERROR_BODY_LIMIT caps the message; this caps the body the exception object itself carries.
|
|
20
|
+
RETAINED_ERROR_BODY_LIMIT = 8_000
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
def get_json(url, noun)
|
|
25
|
+
body, = get_json_with_headers(url, noun)
|
|
26
|
+
body
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Also returns the response, for endpoints that carry pagination in headers rather than the body.
|
|
30
|
+
def get_json_with_headers(url, noun)
|
|
31
|
+
raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
|
|
32
|
+
|
|
33
|
+
response = perform_get(url, noun)
|
|
34
|
+
|
|
35
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
36
|
+
raise HttpError.new(
|
|
37
|
+
"#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
|
|
38
|
+
status: response.code.to_i,
|
|
39
|
+
body: retained_error_body(response.body)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
[parse_json_body(response.body, noun), response]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def perform_get(url, noun)
|
|
49
|
+
uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
|
|
50
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
51
|
+
http.use_ssl = (uri.scheme == "https")
|
|
52
|
+
http.open_timeout = READ_OPEN_TIMEOUT
|
|
53
|
+
http.read_timeout = READ_TIMEOUT
|
|
54
|
+
|
|
55
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
56
|
+
apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
|
|
57
|
+
|
|
58
|
+
# Net::HTTP does not follow redirects, so a 3xx raises rather than replaying the API key at
|
|
59
|
+
# an unapproved host. without_capture is the same recursion guard send_request uses.
|
|
60
|
+
Coolhand.without_capture { http.request(request) }
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
raise Error, "#{noun} request failed: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def retained_error_body(body)
|
|
66
|
+
return body if body.nil? || body.length <= RETAINED_ERROR_BODY_LIMIT
|
|
67
|
+
|
|
68
|
+
"#{body[0, RETAINED_ERROR_BODY_LIMIT]}... [truncated]"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_json_body(body, noun)
|
|
72
|
+
JSON.parse(body.to_s, symbolize_names: true)
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
raise Error, "#{noun} response was not valid JSON: #{format_error_body(body)}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|