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.
@@ -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
- return super unless block
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
- return if @patched
22
-
23
- Net::HTTP.prepend(self)
24
- Net::HTTPResponse.prepend(ResponseInterceptor)
25
-
26
- @patched = true
27
- Coolhand.log "🔗 Net::HTTP interceptor patched"
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
- @patched = false
34
- Coolhand.log "🔌 Faraday monitoring disabled ..."
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
- captured_body = capture_request_body(req, body)
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] = nil
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
- return parse_json(body) if body
112
- return parse_json(req.body) if req.body
113
-
114
- if req.respond_to?(:body_stream) && req.body_stream
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
- nil
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
- Coolhand.configuration.intercept_addresses.any? { |a| url.include?(a) }
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?(url)
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
- matched = patterns.find { |pattern| url.include?(pattern) }
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(url)} (matched exclude_api_pattern: \"#{matched}\")"
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, :webhook_secret
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 in production/staging"
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 in production/staging"
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 timestamp headers - " \
80
- "rejecting webhook in production/staging"
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
- if signature_valid
96
- true
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
- ["production", "staging"].include?(Rails.env)
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