contractful 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +142 -0
  3. data/ARCHITECTURE.md +134 -0
  4. data/AUTHORS +106 -0
  5. data/CODE_OF_CONDUCT.md +128 -0
  6. data/LICENSE +21 -0
  7. data/README.md +1290 -0
  8. data/SECURITY.md +67 -0
  9. data/bin/contractful +128 -0
  10. data/history.md +801 -0
  11. data/lib/contractful/abstract_response.rb +301 -0
  12. data/lib/contractful/circuit_breaker.rb +221 -0
  13. data/lib/contractful/connection_pool.rb +166 -0
  14. data/lib/contractful/contract/auth.rb +257 -0
  15. data/lib/contractful/contract/config.rb +93 -0
  16. data/lib/contractful/contract/definition.rb +295 -0
  17. data/lib/contractful/contract/dsl.rb +501 -0
  18. data/lib/contractful/contract/executor.rb +492 -0
  19. data/lib/contractful/contract/openapi_import.rb +1135 -0
  20. data/lib/contractful/contract/pagination.rb +218 -0
  21. data/lib/contractful/contract/param.rb +505 -0
  22. data/lib/contractful/contract/postman_import/auth.rb +39 -0
  23. data/lib/contractful/contract/postman_import/body.rb +87 -0
  24. data/lib/contractful/contract/postman_import/operation.rb +107 -0
  25. data/lib/contractful/contract/postman_import/scope.rb +37 -0
  26. data/lib/contractful/contract/postman_import/url.rb +80 -0
  27. data/lib/contractful/contract/postman_import.rb +164 -0
  28. data/lib/contractful/contract/response_field.rb +167 -0
  29. data/lib/contractful/contract/sequence.rb +432 -0
  30. data/lib/contractful/contract/swagger2/components.rb +84 -0
  31. data/lib/contractful/contract/swagger2/operations.rb +81 -0
  32. data/lib/contractful/contract/swagger2/parameters.rb +93 -0
  33. data/lib/contractful/contract/swagger2/references.rb +55 -0
  34. data/lib/contractful/contract/swagger2.rb +55 -0
  35. data/lib/contractful/contract.rb +85 -0
  36. data/lib/contractful/exceptions.rb +328 -0
  37. data/lib/contractful/notifications/active_support.rb +40 -0
  38. data/lib/contractful/notifications.rb +138 -0
  39. data/lib/contractful/pacer.rb +161 -0
  40. data/lib/contractful/params_array.rb +85 -0
  41. data/lib/contractful/payload.rb +340 -0
  42. data/lib/contractful/platform.rb +52 -0
  43. data/lib/contractful/rate_limit.rb +133 -0
  44. data/lib/contractful/raw_response.rb +70 -0
  45. data/lib/contractful/request.rb +1483 -0
  46. data/lib/contractful/resource.rb +189 -0
  47. data/lib/contractful/response.rb +109 -0
  48. data/lib/contractful/utils.rb +202 -0
  49. data/lib/contractful/version.rb +11 -0
  50. data/lib/contractful/windows/root_certs.rb +107 -0
  51. data/lib/contractful/windows.rb +9 -0
  52. data/lib/contractful.rb +233 -0
  53. metadata +244 -0
@@ -0,0 +1,1483 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'cgi'
5
+ require 'netrc'
6
+ require 'set'
7
+ require 'timeout'
8
+
9
+ begin
10
+ # Use mime/types/columnar if available, for reduced memory usage
11
+ require 'mime/types/columnar'
12
+ rescue LoadError
13
+ require 'mime/types'
14
+ end
15
+
16
+ module Contractful
17
+ # This class is used internally by Contractful to send the request, but you can also
18
+ # call it directly if you'd like to use a method not supported by the
19
+ # main API. For example:
20
+ #
21
+ # Contractful::Request.execute(:method => :head, :url => 'http://example.com')
22
+ #
23
+ # Mandatory parameters:
24
+ # * :method
25
+ # * :url
26
+ # Optional parameters (have a look at ssl and/or uri for some explanations):
27
+ # * :headers a hash containing the request headers
28
+ # * :cookies may be a Hash{String/Symbol => String} of cookie values, an
29
+ # Array<HTTP::Cookie>, or an HTTP::CookieJar containing cookies. These
30
+ # will be added to a cookie jar before the request is sent.
31
+ # * :user and :password for basic auth, will be replaced by a user/password available in the :url
32
+ # * :block_response call the provided block with the HTTPResponse as parameter
33
+ # * :raw_response return a low-level RawResponse instead of a Response
34
+ # * :log Set the log for this request only, overriding Contractful.log, if
35
+ # any.
36
+ # * :stream_log_percent (Only relevant with :raw_response => true) Customize
37
+ # the interval at which download progress is logged. Defaults to every
38
+ # 10% complete.
39
+ # * :max_redirects maximum number of redirections (default to 10)
40
+ # * :max_retries number of times to retry the request when a transient
41
+ # network failure occurs (connection timeouts, broken/reset connections,
42
+ # EPIPE) or when the response carries a retryable HTTP status (see
43
+ # :retry_after). Defaults to 0 (disabled). Retries use exponential
44
+ # backoff (or the server's Retry-After for HTTP statuses).
45
+ # * :idempotent declares that repeating the request has the same effect as
46
+ # sending it once. Defaults to true for GET, HEAD, OPTIONS, TRACE, PUT and
47
+ # DELETE (RFC 9110 §9.2.2) and to false otherwise. A positive :max_retries
48
+ # requires an idempotent request: a timed-out POST may already have been
49
+ # processed by the server, so retrying it can repeat the operation.
50
+ # * :retry_interval base delay in seconds between retries (default 0.5).
51
+ # The delay doubles with each retry: retry_interval, retry_interval * 2,
52
+ # retry_interval * 4, ...
53
+ # * :retry_after honor Retry-After / classify retryable HTTP statuses
54
+ # (408, 425, 429, 500, 502, 503, 504 — see
55
+ # Contractful::Exceptions::RETRIABLE_STATUSES) and retry them like
56
+ # network failures when :max_retries is positive. Defaults to true.
57
+ # The delay between attempts honors a server-provided Retry-After
58
+ # header (delta-seconds or HTTP-date), falling back to exponential
59
+ # backoff.
60
+ # * :max_retry_after upper bound (seconds) for a Retry-After delay;
61
+ # larger values fall back to the exponential backoff (default 60).
62
+ # * :retry_jitter randomize retry delays so many concurrent clients do not
63
+ # retry in lockstep (the thundering-herd problem). false (default)
64
+ # keeps the plain exponential backoff; :full draws uniformly from
65
+ # [0, backoff]; :equal from [backoff, 2 * backoff]; :decorrelated
66
+ # draws from [retry_interval, min(cap, previous * 3)] per the AWS
67
+ # "Exponential Backoff and Jitter" recipe. Jitter applies only to
68
+ # exponential backoff — a server-provided Retry-After delay is
69
+ # honored exactly as sent.
70
+ # * :retry_jitter_cap upper bound (seconds) for the :decorrelated jitter
71
+ # draw (default 30).
72
+ # * :retry_rng source of randomness for :retry_jitter — any object
73
+ # responding to #rand(range) (a seeded <tt>Random.new(seed)</tt>
74
+ # makes tests deterministic). Defaults to the Random class.
75
+ # * :breaker host-level circuit breaker, opt-in. true uses the defaults
76
+ # ({Contractful::CircuitBreaker::DEFAULT_THRESHOLD} consecutive
77
+ # failures, then fail fast for
78
+ # {Contractful::CircuitBreaker::DEFAULT_COOLDOWN} seconds); a Hash
79
+ # customizes it as <tt>breaker: { threshold: 5, cooldown: 30 }</tt>.
80
+ # Failures are network errors and retryable statuses (408/425/429/5xx);
81
+ # a request that ultimately fails after its retries counts once.
82
+ # While open, requests fail fast with
83
+ # {Contractful::CircuitOpenError}. State transitions post a `circuit`
84
+ # notification.
85
+ # * :pace proactive rate-limit enforcement (opt-in). true uses the
86
+ # defaults ({Contractful::Pacer::DEFAULT_LIMIT} requests per
87
+ # {Contractful::Pacer::DEFAULT_WINDOW}s per scheme/host/port across
88
+ # the process); a Hash customizes it:
89
+ # <tt>pace: { limit: 100, window: 60, max_wait: 10 }</tt>. When the
90
+ # window is full the request sleeps until a slot frees (posting a
91
+ # `pace` notification); a wait beyond `max_wait` raises
92
+ # {Contractful::PacingTimeout}. Retries acquire a slot per attempt.
93
+ # * :proxy An HTTP proxy URI to use for this request. Any value here
94
+ # (including nil) will override Contractful.proxy.
95
+ # * :persistent reuse the underlying connection across requests via the
96
+ # process-wide Contractful.connection_pool (HTTP keep-alive). Defaults to
97
+ # false. See Contractful::ConnectionPool.
98
+ # * :verify_ssl enable ssl verification, possible values are constants from
99
+ # OpenSSL::SSL::VERIFY_*, defaults to OpenSSL::SSL::VERIFY_PEER
100
+ # * :read_timeout, :write_timeout and :open_timeout are how long to wait
101
+ # to read from, write to, and open a connection, in seconds. Pass nil to
102
+ # disable the timeout.
103
+ # * :timeout can be used to set read and open timeouts together
104
+ # * :ssl_client_cert, :ssl_client_key, :ssl_ca_file, :ssl_ca_path,
105
+ # :ssl_cert_store, :ssl_verify_callback, :ssl_verify_callback_warnings,
106
+ # :ssl_min_version, :ssl_max_version
107
+ # * :ssl_version specifies the SSL version for the underlying Net::HTTP connection
108
+ # * :ssl_ciphers sets SSL ciphers for the connection. See
109
+ # OpenSSL::SSL::SSLContext#ciphers=
110
+ # * :before_execution_proc a Proc to call before executing the request. This
111
+ # proc, like procs from Contractful.before_execution_procs, will be
112
+ # called with the HTTP request and request params.
113
+ class Request
114
+ attr_reader :method, :uri, :url, :headers, :payload, :proxy,
115
+ :user, :password, :read_timeout, :write_timeout, :max_redirects,
116
+ :open_timeout, :raw_response, :processed_headers, :args,
117
+ :ssl_opts, :max_retries, :retry_interval, :persistent,
118
+ :retry_after, :max_retry_after,
119
+ :retry_jitter, :retry_jitter_cap, :retry_rng,
120
+ :breaker,
121
+ :pace
122
+
123
+ # An array of previous redirection responses
124
+ attr_accessor :redirection_history
125
+
126
+ def self.execute(args, & block)
127
+ new(args).execute(& block)
128
+ end
129
+
130
+ SSL_OPTION_LIST = %w[client_cert client_key ca_file ca_path cert_store
131
+ version ciphers verify_callback verify_callback_warnings
132
+ min_version max_version]
133
+
134
+ # Maps user-friendly SSL version spellings to the integer constants
135
+ # accepted by OpenSSL::SSL::SSLContext#min_version=/max_version=. Both the
136
+ # "TLSv1_2" and "TLS1_2" spellings are normalized, since the exact symbol
137
+ # format accepted by OpenSSL has varied across OpenSSL gem releases.
138
+ # Integers (e.g. OpenSSL::SSL::TLS1_2_VERSION) and unknown values are
139
+ # passed through unchanged.
140
+ SSL_VERSION_CONSTANTS = {
141
+ 'SSLV2' => 'SSL2_VERSION',
142
+ 'SSLV3' => 'SSL3_VERSION',
143
+ 'SSL2' => 'SSL2_VERSION',
144
+ 'SSL3' => 'SSL3_VERSION',
145
+ 'TLSV1' => 'TLS1_VERSION',
146
+ 'TLSV1_1' => 'TLS1_1_VERSION',
147
+ 'TLSV1_2' => 'TLS1_2_VERSION',
148
+ 'TLSV1_3' => 'TLS1_3_VERSION',
149
+ 'TLS1' => 'TLS1_VERSION',
150
+ 'TLS1_1' => 'TLS1_1_VERSION',
151
+ 'TLS1_2' => 'TLS1_2_VERSION',
152
+ 'TLS1_3' => 'TLS1_3_VERSION'
153
+ }.freeze
154
+
155
+ # Request headers whose values are credentials. Their values are redacted
156
+ # from the request log (see docs/security/THREAT_MODEL.md T5 and
157
+ # docs/operations/OBSERVABILITY.md, which promise that auth headers are
158
+ # not logged).
159
+ REDACTED_HEADERS = %w[authorization proxy-authorization cookie].freeze
160
+
161
+ # Placeholder written to the log in place of a redacted header value.
162
+ REDACTED_VALUE = '[REDACTED]'
163
+
164
+ # Network-level failures that are considered transient. When the
165
+ # `:max_retries` request option is greater than zero, requests that fail
166
+ # with one of these errors are retried automatically (with exponential
167
+ # backoff). Retryable HTTP statuses (`Exceptions::RETRIABLE_STATUSES`)
168
+ # are retried by the pipeline as well; see #execute.
169
+ RETRIABLE_EXCEPTIONS = [
170
+ Contractful::Exceptions::Timeout, # includes Open/Read/WriteTimeout
171
+ Contractful::ServerBrokeConnection,
172
+ Errno::ECONNRESET,
173
+ Errno::ECONNABORTED,
174
+ Errno::EPIPE
175
+ ].freeze
176
+
177
+ # Methods RFC 9110 §9.2.2 defines as idempotent: repeating one has the same
178
+ # effect as sending it once, so a retry cannot duplicate it. POST and PATCH
179
+ # are deliberately absent.
180
+ IDEMPOTENT_METHODS = %w[get head options trace put delete].freeze
181
+
182
+ def inspect
183
+ "<Contractful::Request @method=#{@method.inspect}, @url=#{redacted_url.inspect}>"
184
+ end
185
+
186
+ def initialize args
187
+ @method = normalize_method(args[:method])
188
+ @headers = (args[:headers] || {}).dup
189
+ raise ArgumentError, 'must pass :url' unless args[:url]
190
+
191
+ @url = process_url_params(normalize_url(args[:url]), headers)
192
+
193
+
194
+
195
+
196
+ @user = @password = nil
197
+ parse_url_with_auth!(url)
198
+
199
+ # process cookie arguments found in headers or args
200
+ @cookie_jar = process_cookie_args!(@uri, @headers, args)
201
+
202
+ @payload = Payload.generate(args[:payload])
203
+
204
+ @user = args[:user] if args.include?(:user)
205
+ @password = args[:password] if args.include?(:password)
206
+
207
+ if args.include?(:timeout)
208
+ @read_timeout = args[:timeout]
209
+ @open_timeout = args[:timeout]
210
+ end
211
+ @read_timeout = args[:read_timeout] if args.include?(:read_timeout)
212
+ @open_timeout = args[:open_timeout] if args.include?(:open_timeout)
213
+ @write_timeout = args[:write_timeout] if args.include?(:write_timeout)
214
+ @block_response = args[:block_response]
215
+ @raw_response = args[:raw_response] || false
216
+ @persistent = args.fetch(:persistent, false)
217
+
218
+ @stream_log_percent = args[:stream_log_percent] || 10
219
+ if @stream_log_percent <= 0 || @stream_log_percent > 100
220
+ raise ArgumentError.new(
221
+ "Invalid :stream_log_percent #{@stream_log_percent.inspect}"
222
+ )
223
+ end
224
+
225
+ @proxy = args.fetch(:proxy) if args.include?(:proxy)
226
+
227
+ @ssl_opts = {}
228
+
229
+ if args.include?(:verify_ssl)
230
+ v_ssl = args.fetch(:verify_ssl)
231
+ @ssl_opts[:verify_ssl] =
232
+ if v_ssl == true
233
+ # interpret :verify_ssl => true as VERIFY_PEER
234
+ OpenSSL::SSL::VERIFY_PEER
235
+ elsif v_ssl
236
+ # otherwise pass through any truthy values
237
+ v_ssl
238
+ else
239
+ # interpret all falsy :verify_ssl values as VERIFY_NONE
240
+ OpenSSL::SSL::VERIFY_NONE
241
+ end
242
+ else
243
+ # if :verify_ssl was not passed, default to VERIFY_PEER
244
+ @ssl_opts[:verify_ssl] = OpenSSL::SSL::VERIFY_PEER
245
+ end
246
+
247
+ SSL_OPTION_LIST.each do |key|
248
+ source_key = ('ssl_' + key).to_sym
249
+ @ssl_opts[key.to_sym] = args.fetch(source_key) if args.has_key?(source_key)
250
+ end
251
+
252
+ # Set some other default SSL options, but only if we have an HTTPS URI.
253
+ # If there's no CA file, CA path, or cert store provided, use default
254
+ if use_ssl? && !ssl_ca_file && !ssl_ca_path && !@ssl_opts.include?(:cert_store)
255
+ @ssl_opts[:cert_store] = self.class.default_ssl_cert_store
256
+ end
257
+
258
+ @log = args[:log]
259
+ @max_redirects = args[:max_redirects] || 10
260
+
261
+ @max_retries = args.fetch(:max_retries, 0)
262
+ unless @max_retries.is_a?(Integer) && @max_retries >= 0
263
+ raise ArgumentError.new(
264
+ ':max_retries must be a non-negative Integer, ' +
265
+ "got #{@max_retries.inspect}"
266
+ )
267
+ end
268
+
269
+ @idempotent = args.fetch(:idempotent, IDEMPOTENT_METHODS.include?(@method))
270
+ unless [true, false].include?(@idempotent)
271
+ raise ArgumentError.new(
272
+ ':idempotent must be true or false, ' +
273
+ "got #{@idempotent.inspect}"
274
+ )
275
+ end
276
+ if @max_retries.positive? && !@idempotent
277
+ raise ArgumentError.new(
278
+ ':max_retries requires an idempotent operation, but this ' +
279
+ "#{@method.upcase} request is not. A timed-out or 5xx request may " +
280
+ 'already have been processed, so a retry can repeat it. Declare the ' +
281
+ 'operation idempotent (idempotent: true on Request, `idempotent true` ' +
282
+ 'on a contract) only if the server deduplicates requests, for example ' +
283
+ 'with an Idempotency-Key header.'
284
+ )
285
+ end
286
+
287
+ @retry_interval = args.fetch(:retry_interval, 0.5)
288
+ unless @retry_interval.is_a?(Numeric) && @retry_interval >= 0
289
+ raise ArgumentError.new(
290
+ ':retry_interval must be a non-negative Numeric, ' +
291
+ "got #{@retry_interval.inspect}"
292
+ )
293
+ end
294
+
295
+ @retry_jitter = args.fetch(:retry_jitter, false)
296
+ unless [false, :full, :equal, :decorrelated].include?(@retry_jitter)
297
+ raise ArgumentError.new(
298
+ ':retry_jitter must be false, :full, :equal or :decorrelated, ' +
299
+ "got #{@retry_jitter.inspect}"
300
+ )
301
+ end
302
+
303
+ @retry_jitter_cap = args.fetch(:retry_jitter_cap, 30.0)
304
+ unless @retry_jitter_cap.is_a?(Numeric) && @retry_jitter_cap.positive?
305
+ raise ArgumentError.new(
306
+ ':retry_jitter_cap must be a positive Numeric, ' +
307
+ "got #{@retry_jitter_cap.inspect}"
308
+ )
309
+ end
310
+
311
+ @retry_rng = args.fetch(:retry_rng, Random)
312
+ unless @retry_rng.respond_to?(:rand)
313
+ raise ArgumentError.new(
314
+ ':retry_rng must respond to #rand(range), ' +
315
+ "got #{@retry_rng.inspect}"
316
+ )
317
+ end
318
+
319
+ @breaker = args.fetch(:breaker, false)
320
+ case @breaker
321
+ when false, true, Hash
322
+ # ok
323
+ else
324
+ raise ArgumentError.new(
325
+ ':breaker must be true, false or a config Hash, ' +
326
+ "got #{@breaker.inspect}"
327
+ )
328
+ end
329
+
330
+ @pace = args.fetch(:pace, false)
331
+ case @pace
332
+ when false, true, Hash
333
+ # ok
334
+ else
335
+ raise ArgumentError.new(
336
+ ':pace must be true, false or a config Hash, ' +
337
+ "got #{@pace.inspect}"
338
+ )
339
+ end
340
+
341
+ @retry_after = args.fetch(:retry_after, true) ? true : false
342
+ @max_retry_after = args.fetch(:max_retry_after, 60)
343
+ unless @max_retry_after.is_a?(Numeric) && @max_retry_after >= 0
344
+ raise ArgumentError.new(
345
+ ':max_retry_after must be a non-negative Numeric, ' +
346
+ "got #{@max_retry_after.inspect}"
347
+ )
348
+ end
349
+
350
+ @processed_headers = make_headers headers
351
+ @processed_headers_lowercase = @processed_headers.map do |k, v|
352
+ [k.downcase, v]
353
+ end.to_h
354
+ @args = args
355
+
356
+ @before_execution_proc = args[:before_execution_proc]
357
+ end
358
+
359
+ def execute & block
360
+ # With 2.0.0+, net/http accepts URI objects in requests and handles wrapping
361
+ # IPv6 addresses in [] for use in the Host request header.
362
+ #
363
+ # The request is rebuilt on each attempt so that Net::HTTP mutations (for
364
+ # example basic auth headers) and consumed payload streams do not leak
365
+ # state between attempts.
366
+ attempts = 0
367
+ started = nil
368
+ event_url = redacted_url
369
+ response_for_attempt = nil
370
+ attempt_block =
371
+ if block
372
+ proc do |http_response, request, raw|
373
+ response_for_attempt = http_response
374
+ block.call(http_response, request, raw, &block)
375
+ end
376
+ end
377
+ breaker = @breaker ? CircuitBreaker.registry.breaker_for(uri, **breaker_options) : nil
378
+ breaker&.check!
379
+ pacer = @pace ? Pacer.registry.pacer_for(uri, **pace_options) : nil
380
+ begin
381
+ loop do
382
+ req = net_http_request_class(method).new(uri, processed_headers)
383
+ pacer&.acquire
384
+ Notifications.post('request', method: method, url: event_url, attempt: attempts + 1)
385
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
386
+ response_for_attempt = nil
387
+ result = transmit uri, req, payload, & attempt_block
388
+ response = result.is_a?(Contractful::Response) ? result : response_for_attempt
389
+ Notifications.post(
390
+ 'response', method: method, url: event_url,
391
+ status: response.respond_to?(:code) ? response.code : nil,
392
+ duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
393
+ )
394
+
395
+ # When a block handles responses itself (error-status suppression),
396
+ # retryable statuses never raise — judge the returned response.
397
+ if block_given? && retry_after && attempts < max_retries &&
398
+ response.is_a?(Contractful::Response) &&
399
+ Exceptions.retryable?(response.code) && rewind_payload!
400
+ attempts += 1
401
+ delay = retry_delay_for_response(response, attempts)
402
+ Notifications.post('retry', method: method, url: event_url,
403
+ attempt: attempts, delay: delay, cause: response.code)
404
+ log_response_retry(response, attempts, delay)
405
+ sleep_for_retry(delay)
406
+ next
407
+ end
408
+ breaker&.record_response(response)
409
+ break result
410
+ end
411
+ rescue *RETRIABLE_EXCEPTIONS => e
412
+ attempts += 1
413
+ if attempts <= max_retries && rewind_payload!
414
+ delay = retry_backoff_delay(attempts)
415
+ Notifications.post('retry', method: method, url: event_url,
416
+ attempt: attempts, delay: delay, cause: e.class.name)
417
+ log_retry(e, attempts, delay)
418
+ sleep_for_retry(delay)
419
+ retry
420
+ end
421
+ breaker&.record_failure
422
+ raise
423
+ rescue Contractful::ExceptionWithResponse => e
424
+ # Error statuses raise inside transmit, so the response event for a
425
+ # failed attempt posts here rather than after the transmit call.
426
+ Notifications.post(
427
+ 'response', method: method, url: event_url, status: e.http_code,
428
+ duration_ms: started && ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
429
+ )
430
+ attempts += 1
431
+ if retry_after && attempts <= max_retries && e.retryable? && rewind_payload!
432
+ delay = retry_delay_for_response(e.response, attempts)
433
+ Notifications.post('retry', method: method, url: event_url, attempt: attempts,
434
+ delay: delay,
435
+ cause: e.response ? e.http_code : e.class.name)
436
+ log_retry(e, attempts, delay)
437
+ sleep_for_retry(delay)
438
+ retry
439
+ end
440
+ breaker&.record_response(e.response) if e.response
441
+ breaker&.record_failure unless e.response
442
+ raise
443
+ end
444
+ ensure
445
+ payload&.close
446
+ end
447
+
448
+ # Exceptions that are retried automatically when `:max_retries` is used.
449
+ def self.retriable_exceptions
450
+ RETRIABLE_EXCEPTIONS
451
+ end
452
+
453
+ # SSL-related options
454
+ def verify_ssl
455
+ @ssl_opts.fetch(:verify_ssl)
456
+ end
457
+ SSL_OPTION_LIST.each do |key|
458
+ define_method('ssl_' + key) do
459
+ @ssl_opts[key.to_sym]
460
+ end
461
+ end
462
+
463
+ # Return true if the request URI will use HTTPS.
464
+ #
465
+ # @return [Boolean]
466
+ #
467
+ def use_ssl?
468
+ uri.is_a?(URI::HTTPS)
469
+ end
470
+
471
+ # Extract the query parameters and append them to the url
472
+ #
473
+ # Look through the headers hash for a :params option (case-insensitive,
474
+ # may be string or symbol). If present and the value is a Hash or
475
+ # Contractful::ParamsArray, *delete* the key/value pair from the headers
476
+ # hash and encode the value into a query string. Append this query string
477
+ # to the URL and return the resulting URL.
478
+ #
479
+ # @param [String] url
480
+ # @param [Hash] headers An options/headers hash to process. Mutation
481
+ # warning: the params key may be removed if present!
482
+ #
483
+ # @return [String] resulting url with query string
484
+ #
485
+ def process_url_params(url, headers)
486
+ url_params = nil
487
+
488
+ # find and extract/remove "params" key if the value is a Hash/ParamsArray
489
+ headers.delete_if do |key, value|
490
+ if key.to_s.downcase == 'params' &&
491
+ (value.is_a?(Hash) || value.is_a?(Contractful::ParamsArray))
492
+ raise ArgumentError.new("Multiple 'params' options passed") if url_params
493
+
494
+ url_params = value
495
+ true
496
+ else
497
+ false
498
+ end
499
+ end
500
+
501
+ # build resulting URL with query string
502
+ if url_params && !url_params.empty?
503
+ query_string = Contractful::Utils.encode_query_string(url_params)
504
+
505
+ if url.include?('?')
506
+ url + '&' + query_string
507
+ else
508
+ url + '?' + query_string
509
+ end
510
+ else
511
+ url
512
+ end
513
+ end
514
+
515
+ # Render a hash of key => value pairs for cookies in the Request#cookie_jar
516
+ # that are valid for the Request#uri. This will not necessarily include all
517
+ # cookies if there are duplicate keys. It's safer to use the cookie_jar
518
+ # directly if that's a concern.
519
+ #
520
+ # @see Request#cookie_jar
521
+ #
522
+ # @return [Hash]
523
+ #
524
+ def cookies
525
+ hash = {}
526
+
527
+ @cookie_jar.cookies(uri).each do |c|
528
+ hash[c.name] = c.value
529
+ end
530
+
531
+ hash
532
+ end
533
+
534
+ # @return [HTTP::CookieJar]
535
+ attr_reader :cookie_jar
536
+
537
+ # Render a Cookie HTTP request header from the contents of the @cookie_jar,
538
+ # or nil if the jar is empty.
539
+ #
540
+ # @see Request#cookie_jar
541
+ #
542
+ # @return [String, nil]
543
+ #
544
+ def make_cookie_header
545
+ return nil if cookie_jar.nil?
546
+
547
+ arr = cookie_jar.cookies(url)
548
+ return nil if arr.empty?
549
+
550
+ HTTP::Cookie.cookie_value(arr)
551
+ end
552
+
553
+ # Process cookies passed as hash or as HTTP::CookieJar. For backwards
554
+ # compatibility, these may be passed as a :cookies option masquerading
555
+ # inside the headers hash. To avoid confusion, if :cookies is passed in
556
+ # both headers and Request#initialize, raise an error.
557
+ #
558
+ # :cookies may be a:
559
+ # - Hash{String/Symbol => String}
560
+ # - Array<HTTP::Cookie>
561
+ # - HTTP::CookieJar
562
+ #
563
+ # Passing as a hash:
564
+ # Keys may be symbols or strings. Values must be strings.
565
+ # Infer the domain name from the request URI and allow subdomains (as
566
+ # though '.example.com' had been set in a Set-Cookie header). Assume a
567
+ # path of '/'.
568
+ #
569
+ # Contractful::Request.new(url: 'http://example.com', method: :get,
570
+ # :cookies => {:foo => 'Value', 'bar' => '123'}
571
+ # )
572
+ #
573
+ # results in cookies as though set from the server by:
574
+ # Set-Cookie: foo=Value; Domain=.example.com; Path=/
575
+ # Set-Cookie: bar=123; Domain=.example.com; Path=/
576
+ #
577
+ # which yields a client cookie header of:
578
+ # Cookie: foo=Value; bar=123
579
+ #
580
+ # Passing as HTTP::CookieJar, which will be passed through directly:
581
+ #
582
+ # jar = HTTP::CookieJar.new
583
+ # jar.add(HTTP::Cookie.new('foo', 'Value', domain: 'example.com',
584
+ # path: '/', for_domain: false))
585
+ #
586
+ # Contractful::Request.new(..., :cookies => jar)
587
+ #
588
+ # @param [URI::HTTP] uri The URI for the request. This will be used to
589
+ # infer the domain name for cookies passed as strings in a hash. To avoid
590
+ # this implicit behavior, pass a full cookie jar or use HTTP::Cookie hash
591
+ # values.
592
+ # @param [Hash] headers The headers hash from which to pull the :cookies
593
+ # option. MUTATION NOTE: This key will be deleted from the hash if
594
+ # present.
595
+ # @param [Hash] args The options passed to Request#initialize. This hash
596
+ # will be used as another potential source for the :cookies key.
597
+ # These args will not be mutated.
598
+ #
599
+ # @return [HTTP::CookieJar] A cookie jar containing the parsed cookies.
600
+ #
601
+ def process_cookie_args!(uri, headers, args)
602
+ # Avoid ambiguity in whether options from headers or options from
603
+ # Request#initialize should take precedence by raising ArgumentError when
604
+ # both are present. Prior versions of contractful claimed to give
605
+ # precedence to init options, but actually gave precedence to headers.
606
+ # Avoid that mess by erroring out instead.
607
+ if headers[:cookies] && args[:cookies]
608
+ raise ArgumentError.new(
609
+ 'Cannot pass :cookies in Request.new() and in headers hash'
610
+ )
611
+ end
612
+
613
+ cookies_data = headers.delete(:cookies) || args[:cookies]
614
+
615
+ # return copy of cookie jar as is
616
+ return cookies_data.dup if cookies_data.is_a?(HTTP::CookieJar)
617
+
618
+ # convert cookies hash into a CookieJar
619
+ jar = HTTP::CookieJar.new
620
+
621
+ (cookies_data || []).each do |key, val|
622
+ # Support for Array<HTTP::Cookie> mode:
623
+ # If key is a cookie object, add it to the jar directly and assert that
624
+ # there is no separate val.
625
+ if key.is_a?(HTTP::Cookie)
626
+ raise ArgumentError.new("extra cookie val: #{val.inspect}") if val
627
+
628
+ jar.add(key)
629
+ next
630
+ end
631
+
632
+ key = key.to_s if key.is_a?(Symbol)
633
+
634
+ # assume implicit domain from the request URI, and set for_domain to
635
+ # permit subdomains
636
+ jar.add(HTTP::Cookie.new(key, val, domain: uri.hostname.downcase,
637
+ path: '/', for_domain: true))
638
+ end
639
+
640
+ jar
641
+ end
642
+
643
+ # Generate headers for use by a request. Header keys will be stringified
644
+ # using `#stringify_headers` to normalize them as capitalized strings.
645
+ #
646
+ # The final headers consist of:
647
+ # - default headers from #default_headers
648
+ # - user_headers provided here
649
+ # - headers from the payload object (e.g. Content-Type, Content-Lenth)
650
+ # - cookie headers from #make_cookie_header
651
+ #
652
+ # stringify_headers only normalizes Symbol keys to the canonical
653
+ # `Content-Type` style capitalization; String keys are passed through
654
+ # verbatim for backwards compatibility with rest-client 1.x/2.x
655
+ # (https://github.com/rest-client/rest-client/issues/599). Because
656
+ # equivalent header names may therefore be spelled with different
657
+ # casing, the merges below are case-insensitive: a later header whose
658
+ # name matches an earlier one case-insensitively replaces it (keeping
659
+ # the casing of the later name) instead of coexisting with it. This both
660
+ # keeps the accidental-override warnings working across casings and
661
+ # prevents duplicate headers from reaching the wire.
662
+ #
663
+ # @param [Hash] user_headers User-provided headers to include
664
+ #
665
+ # @return [Hash<String, String>] A hash of HTTP headers => values
666
+ #
667
+ def make_headers(user_headers)
668
+ headers = merge_headers(stringify_headers(default_headers),
669
+ stringify_headers(user_headers))
670
+
671
+ # override headers from the payload (e.g. Content-Type, Content-Length)
672
+ if @payload
673
+ payload_headers = @payload.headers
674
+
675
+ # Warn the user if we override any headers that were previously
676
+ # present. This usually indicates that contractful was passed
677
+ # conflicting information, e.g. if it was asked to render a payload as
678
+ # x-www-form-urlencoded but a Content-Type application/json was
679
+ # also supplied by the user.
680
+ payload_headers.each_pair do |key, val|
681
+ next unless (existing = header_key_for(headers, key)) && headers[existing] != val
682
+
683
+ warn("warning: Overriding #{key.inspect} header " +
684
+ "#{headers[existing].inspect} with #{val.inspect} " +
685
+ 'due to payload')
686
+ end
687
+
688
+ headers = merge_headers(headers, payload_headers)
689
+ end
690
+
691
+ # merge in cookies
692
+ cookies = make_cookie_header
693
+ if cookies && !cookies.empty?
694
+ warn('warning: overriding "Cookie" header with :cookies option') if header_key_for(headers, 'Cookie')
695
+ headers = merge_headers(headers, { 'Cookie' => cookies })
696
+ end
697
+
698
+ headers
699
+ end
700
+
701
+ # The proxy URI for this request. If `:proxy` was provided on this request,
702
+ # use it over `Contractful.proxy`.
703
+ #
704
+ # Return false if a proxy was explicitly set and is falsy.
705
+ #
706
+ # @return [URI, false, nil]
707
+ #
708
+ def proxy_uri
709
+ if defined?(@proxy)
710
+ if @proxy
711
+ URI.parse(@proxy)
712
+ else
713
+ false
714
+ end
715
+ elsif Contractful.proxy_set?
716
+ if Contractful.proxy
717
+ URI.parse(Contractful.proxy)
718
+ else
719
+ false
720
+ end
721
+ end
722
+ end
723
+
724
+ def net_http_object(hostname, port)
725
+ p_uri = proxy_uri
726
+
727
+ if p_uri.nil?
728
+ # no proxy set
729
+ Net::HTTP.new(hostname, port)
730
+ elsif !p_uri
731
+ # proxy explicitly set to none
732
+ Net::HTTP.new(hostname, port, nil, nil, nil, nil)
733
+ else
734
+ Net::HTTP.new(hostname, port,
735
+ p_uri.hostname, p_uri.port, p_uri.user, p_uri.password)
736
+
737
+ end
738
+ end
739
+
740
+ def net_http_request_class(method)
741
+ Net::HTTP.const_get(method.capitalize, false)
742
+ end
743
+
744
+ def net_http_do_request(http, req, body = nil, &block)
745
+ return http.request(req, body, &block) unless body&.respond_to?(:read)
746
+
747
+ req.body_stream = body
748
+ http.request(req, nil, &block)
749
+
750
+
751
+
752
+ end
753
+
754
+ # Normalize a URL by adding a protocol if none is present.
755
+ #
756
+ # If the string has no HTTP-like scheme (i.e. scheme followed by '//'), a
757
+ # scheme of 'http' will be added. This mimics the behavior of browsers and
758
+ # user agents like cURL.
759
+ #
760
+ # @param [String] url A URL string.
761
+ #
762
+ # @return [String]
763
+ #
764
+ def normalize_url(url)
765
+ url = 'http://' + url unless url.match(%r{\A[a-z][a-z0-9+.-]*://}i)
766
+ url
767
+ end
768
+
769
+ # Return a certificate store that can be used to validate certificates with
770
+ # the system certificate authorities. This will probably not do anything on
771
+ # OS X, which monkey patches OpenSSL in terrible ways to insert its own
772
+ # validation. On most *nix platforms, this will add the system certifcates
773
+ # using OpenSSL::X509::Store#set_default_paths. On Windows, this will use
774
+ # Contractful::Windows::RootCerts to look up the CAs trusted by the system.
775
+ #
776
+ # The store is memoized as a class instance variable. The previous
777
+ # implementation rebuilt a fresh OpenSSL::X509::Store on every request
778
+ # that did not pass ssl_ca_file / ssl_ca_path / cert_store explicitly,
779
+ # and on Windows that re-loaded every trusted root certificate via FFI
780
+ # (hundreds of FFI calls + cert parses per request). The Windows cert
781
+ # store is process-stable; reusing it is a major perf win on Windows
782
+ # and a no-op on Linux (set_default_paths is cheap there too).
783
+ #
784
+ # Tests that want a fresh store can call
785
+ # Contractful::Request.reset_default_ssl_cert_store!
786
+ #
787
+ # @return [OpenSSL::X509::Store]
788
+ #
789
+ @default_ssl_cert_store_mutex = Mutex.new
790
+
791
+ def self.default_ssl_cert_store
792
+ return @default_ssl_cert_store if @default_ssl_cert_store
793
+
794
+ @default_ssl_cert_store_mutex.synchronize do
795
+ return @default_ssl_cert_store if @default_ssl_cert_store
796
+
797
+ @default_ssl_cert_store = build_default_ssl_cert_store
798
+ end
799
+ end
800
+
801
+ # Forget the memoized default cert store. Tests that fork the
802
+ # process or want to re-pick up changes to the Windows root
803
+ # certificate set call this between scenarios.
804
+ def self.reset_default_ssl_cert_store!
805
+ @default_ssl_cert_store_mutex.synchronize { @default_ssl_cert_store = nil }
806
+ end
807
+
808
+ def self.build_default_ssl_cert_store
809
+ cert_store = OpenSSL::X509::Store.new
810
+ cert_store.set_default_paths
811
+
812
+ # set_default_paths() doesn't do anything on Windows, so look up
813
+ # certificates using the win32 API.
814
+ if Contractful::Platform.windows?
815
+ Contractful::Windows::RootCerts.instance.to_a.uniq.each do |cert|
816
+ cert_store.add_cert(cert)
817
+ rescue OpenSSL::X509::StoreError => e
818
+ # ignore duplicate certs
819
+ raise unless e.message == 'cert already in hash table'
820
+ end
821
+ end
822
+
823
+ cert_store
824
+ end
825
+
826
+ def redacted_uri
827
+ return unless uri
828
+
829
+ if uri.password
830
+ sanitized_uri = uri.dup
831
+ sanitized_uri.password = 'REDACTED'
832
+ sanitized_uri
833
+ else
834
+ uri
835
+ end
836
+ end
837
+
838
+ def redacted_url
839
+ redacted_uri.to_s
840
+ end
841
+
842
+ # Default to the global logger if there's not a request-specific one
843
+ def log
844
+ @log || Contractful.log
845
+ end
846
+
847
+ def log_request
848
+ return unless log
849
+
850
+ out = []
851
+
852
+ out << "Contractful.#{method} #{redacted_url.inspect}"
853
+ out << payload.short_inspect if payload
854
+ out << processed_headers.to_a.sort.map { |(k, v)|
855
+ [k.inspect, log_header_value(k, v).inspect].join('=>')
856
+ }.join(', ')
857
+ log << (out.join(', ') + "\n")
858
+ end
859
+
860
+ # Value to render for a header in the request log. Credential-bearing
861
+ # headers (Authorization, Proxy-Authorization, Cookie) are redacted so
862
+ # that enabling logging cannot leak tokens or session cookies.
863
+ def log_header_value(key, value)
864
+ if REDACTED_HEADERS.include?(key.to_s.downcase)
865
+ REDACTED_VALUE
866
+ else
867
+ value
868
+ end
869
+ end
870
+ private :log_header_value
871
+
872
+ # Return a hash of headers whose keys are capitalized strings
873
+ #
874
+ # Design note: only Symbol keys are normalized to `Content-Type` style
875
+ # capitalization. String keys are passed through verbatim, matching
876
+ # rest-client 1.x/2.x behavior
877
+ # (https://github.com/rest-client/rest-client/issues/599). String keys
878
+ # are therefore not canonicalized (e.g. 'x-custom-header' stays
879
+ # lowercase on the wire), which some clients depend on; equivalent
880
+ # header names that differ in casing are treated as the same header at
881
+ # merge time by #make_headers/#merge_headers.
882
+ #
883
+ def stringify_headers headers
884
+ headers.each_with_object({}) do |(key, value), result|
885
+ key = key.to_s.split('_').map(&:capitalize).join('-') if key.is_a? Symbol
886
+ if 'CONTENT-TYPE' == key.upcase
887
+ result[key] = maybe_convert_extension(value.to_s)
888
+ elsif 'ACCEPT' == key.upcase
889
+ # Accept can be composed of several comma-separated values
890
+ if value.is_a? Array
891
+ target_values = value
892
+ else
893
+ target_values = value.to_s.split ','
894
+ end
895
+ result[key] = target_values.map do |ext|
896
+ maybe_convert_extension(ext.to_s.strip)
897
+ end.join(', ')
898
+ else
899
+ result[key] = value.to_s
900
+ end
901
+ end
902
+ end
903
+
904
+ # Return the key in `headers` that equals `key` case-insensitively, or
905
+ # nil if there is no match. HTTP header names are case-insensitive
906
+ # (RFC 7230), and because stringify_headers passes String keys through
907
+ # verbatim, equivalent names may be spelled with different casing.
908
+ #
909
+ # @return [String, nil] the matching key as present in `headers`
910
+ #
911
+ def header_key_for(headers, key)
912
+ headers.each_key do |existing|
913
+ return existing if existing.casecmp?(key)
914
+ end
915
+ nil
916
+ end
917
+ private :header_key_for
918
+
919
+ # Merge two header hashes, treating keys that differ only in case as the
920
+ # same header: entries from `later` replace entries from `earlier`,
921
+ # keeping the casing of the later key. This prevents, for example, a
922
+ # user-supplied 'content-type' from coexisting with the default
923
+ # 'Accept'-style capitalized headers and producing a duplicate header on
924
+ # the wire.
925
+ #
926
+ # @param [Hash] earlier headers merged in first
927
+ # @param [Hash] later headers that win on (case-insensitive) collision
928
+ #
929
+ # @return [Hash]
930
+ #
931
+ def merge_headers(earlier, later)
932
+ merged = earlier.dup
933
+ later.each do |key, val|
934
+ if (existing = header_key_for(merged, key)) && !existing.eql?(key)
935
+ merged.delete(existing)
936
+ end
937
+ merged[key] = val
938
+ end
939
+ merged
940
+ end
941
+ private :merge_headers
942
+
943
+ # Default headers set by Contractful. In addition to these headers, servers
944
+ # will receive headers set by Net::HTTP, such as Accept-Encoding and Host.
945
+ #
946
+ # @return [Hash<Symbol, String>]
947
+ def default_headers
948
+ {
949
+ accept: '*/*',
950
+ user_agent: Contractful::Platform.default_user_agent
951
+ }
952
+ end
953
+
954
+ private
955
+
956
+ # Rewind the payload so that a retried request can send the body again.
957
+ # Returns true when there is no payload or it was successfully rewound,
958
+ # false when the payload cannot be rewound (e.g. a non-rewindable request
959
+ # stream), in which case retrying would resend a truncated or empty body.
960
+ def rewind_payload!
961
+ return true unless payload
962
+
963
+ payload.rewind
964
+ end
965
+
966
+ # Exponential backoff between retries: `:retry_interval` for the first
967
+ # retry, then doubling for each subsequent attempt.
968
+ def retry_backoff_delay(attempt)
969
+ delay = retry_interval * (2**(attempt - 1))
970
+ apply_retry_jitter(delay, attempt)
971
+ end
972
+
973
+ # Randomize an exponential backoff per the configured jitter style.
974
+ # Called only for computed backoff — a server's Retry-After delay is
975
+ # honored exactly, so many clients still retry in unison only when the
976
+ # server itself asked them to.
977
+ def apply_retry_jitter(delay, attempt)
978
+ return delay if @retry_jitter == false || delay.zero?
979
+
980
+ case @retry_jitter
981
+ when :full then @retry_rng.rand(0..delay)
982
+ when :equal then delay + @retry_rng.rand(0..delay)
983
+ when :decorrelated then decorrelated_jitter_delay(attempt)
984
+ end
985
+ end
986
+
987
+ # Normalize the :pace option into registry kwargs.
988
+ def pace_options
989
+ case @pace
990
+ when Hash
991
+ { limit: @pace.fetch(:limit, Pacer::DEFAULT_LIMIT),
992
+ window: @pace.fetch(:window, Pacer::DEFAULT_WINDOW),
993
+ max_wait: @pace.fetch(:max_wait, Pacer::DEFAULT_MAX_WAIT) }
994
+ else
995
+ { limit: Pacer::DEFAULT_LIMIT, window: Pacer::DEFAULT_WINDOW,
996
+ max_wait: Pacer::DEFAULT_MAX_WAIT }
997
+ end
998
+ end
999
+
1000
+ # Normalize the :breaker option into registry kwargs.
1001
+ def breaker_options
1002
+ case @breaker
1003
+ when Hash
1004
+ { threshold: @breaker.fetch(:threshold, CircuitBreaker::DEFAULT_THRESHOLD),
1005
+ cooldown: @breaker.fetch(:cooldown, CircuitBreaker::DEFAULT_COOLDOWN) }
1006
+ else
1007
+ { threshold: CircuitBreaker::DEFAULT_THRESHOLD,
1008
+ cooldown: CircuitBreaker::DEFAULT_COOLDOWN }
1009
+ end
1010
+ end
1011
+
1012
+ # AWS "Exponential Backoff and Jitter" decorrelated draw: each attempt
1013
+ # samples [retry_interval, min(cap, previous * 3)], where the previous
1014
+ # value is the raw (unjittered) backoff of the preceding attempt —
1015
+ # stateless per request, deterministic under a seeded :retry_rng.
1016
+ def decorrelated_jitter_delay(attempt)
1017
+ base = retry_interval
1018
+ previous = attempt <= 1 ? base : base * (2**(attempt - 2))
1019
+ draw = @retry_rng.rand(base..[previous * 3, @retry_jitter_cap].min)
1020
+ [draw, @retry_jitter_cap].min
1021
+ end
1022
+
1023
+ def sleep_for_retry(secs)
1024
+ sleep(secs) if secs.positive?
1025
+ end
1026
+
1027
+ # Delay before retrying after a retryable HTTP response: honor the
1028
+ # server's Retry-After header (seconds or HTTP-date) when present and
1029
+ # within :max_retry_after; on a 429 without Retry-After, fall back to
1030
+ # the RateLimit-Reset quota window; otherwise exponential backoff.
1031
+ def retry_delay_for_response(response, attempt)
1032
+ header = response.headers[:retry_after] if response.respond_to?(:headers)
1033
+ delay = parse_retry_after(header)
1034
+
1035
+ if delay.nil? && response.respond_to?(:rate_limit) && response.code == 429
1036
+ delay = rate_limit_reset_delay(response)
1037
+ end
1038
+
1039
+ if delay.nil? || delay > max_retry_after
1040
+ retry_backoff_delay(attempt)
1041
+ else
1042
+ delay
1043
+ end
1044
+ end
1045
+
1046
+ # Seconds until the reported rate-limit quota resets, when the server
1047
+ # sent RateLimit-Reset (or the legacy X-RateLimit-Reset).
1048
+ def rate_limit_reset_delay(response)
1049
+ info = response.rate_limit
1050
+ return nil unless info&.reset
1051
+
1052
+ secs = info.reset_in
1053
+ secs&.positive? ? secs : nil
1054
+ end
1055
+
1056
+ # Retry-After: delta-seconds ("120") or HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
1057
+ def parse_retry_after(value)
1058
+ return nil if value.nil?
1059
+
1060
+ if value.match?(/\A\d+\z/)
1061
+ value.to_f
1062
+ else
1063
+ require 'time'
1064
+ delay = Time.httpdate(value) - Time.now
1065
+ delay.positive? ? delay : 0.0
1066
+ end
1067
+ rescue StandardError
1068
+ nil
1069
+ end
1070
+
1071
+ def log_response_retry(response, attempt, delay)
1072
+ return unless log
1073
+
1074
+ log << ("Contractful retry ##{attempt}/#{max_retries} for " +
1075
+ "#{redacted_url.inspect} after response HTTP #{response.code}: " +
1076
+ "sleeping #{delay}s\n")
1077
+ end
1078
+
1079
+ def log_retry(err, attempt, delay)
1080
+ return unless log
1081
+
1082
+ log << ("Contractful retry ##{attempt}/#{max_retries} for " +
1083
+ "#{redacted_url.inspect} after #{err.class}: " +
1084
+ "sleeping #{delay}s\n")
1085
+ end
1086
+
1087
+ # Parse the `@url` string into a URI object and save it as
1088
+ # `@uri`. Also save any basic auth user or password as @user and @password.
1089
+ # If no auth info was passed, check for credentials in a Netrc file.
1090
+ #
1091
+ # @param [String] url A URL string.
1092
+ #
1093
+ # @return [URI]
1094
+ #
1095
+ # @raise URI::InvalidURIError on invalid URIs
1096
+ #
1097
+ def parse_url_with_auth!(url)
1098
+ uri = URI.parse(url)
1099
+
1100
+ # Ruby >= 3.4 returns an empty hostname (rather than nil) for URIs
1101
+ # such as 'http:///'.
1102
+ raise URI::InvalidURIError.new("bad URI(no host provided): #{url}") if uri.hostname.nil? || uri.hostname.empty?
1103
+
1104
+ @user = CGI.unescape(uri.user) if uri.user
1105
+ @password = CGI.unescape(uri.password) if uri.password
1106
+ @user, @password = Netrc.read[uri.hostname] if !@user && !@password
1107
+
1108
+ @uri = uri
1109
+ end
1110
+
1111
+ def print_verify_callback_warnings
1112
+ warned = false
1113
+ if Contractful::Platform.mac_mri?
1114
+ warn('warning: ssl_verify_callback return code is ignored on OS X')
1115
+ warned = true
1116
+ end
1117
+ if Contractful::Platform.jruby?
1118
+ warn('warning: SSL verify_callback may not work correctly in jruby')
1119
+ warn('see https://github.com/jruby/jruby/issues/597')
1120
+ warned = true
1121
+ end
1122
+ warned
1123
+ end
1124
+
1125
+ # Parse a method and return a normalized string version.
1126
+ #
1127
+ # Raise ArgumentError if the method is falsy, but otherwise do no
1128
+ # validation.
1129
+ #
1130
+ # @param method [String, Symbol]
1131
+ #
1132
+ # @return [String]
1133
+ #
1134
+ # @see net_http_request_class
1135
+ #
1136
+ def normalize_method(method)
1137
+ raise ArgumentError.new('must pass :method') unless method
1138
+
1139
+ method.to_s.downcase
1140
+ end
1141
+
1142
+ def transmit uri, req, payload, & block
1143
+ # We set this to true in the net/http block so that we can distinguish
1144
+ # read_timeout from open_timeout. Now that we only support Ruby 2.0+,
1145
+ # this is only needed for Timeout exceptions thrown outside of Net::HTTP.
1146
+ established_connection = false
1147
+
1148
+ setup_credentials req
1149
+
1150
+ net = net_http_object(uri.hostname, uri.port)
1151
+ configure_net_http(net, uri)
1152
+
1153
+ Contractful.before_execution_procs.each do |before_proc|
1154
+ before_proc.call(req, args)
1155
+ end
1156
+
1157
+ @before_execution_proc.call(req, args) if @before_execution_proc
1158
+
1159
+ log_request
1160
+
1161
+ start_time = Time.now
1162
+ tempfile = nil
1163
+
1164
+ if persistent
1165
+ pool = Contractful.connection_pool
1166
+ key = persistent_connection_key(uri)
1167
+
1168
+ http = pool.checkout(key) { net.start }
1169
+ # The pooled connection may have been built by a request with different
1170
+ # timeout settings; always (re)apply this request's timeouts.
1171
+ apply_timeouts!(http)
1172
+
1173
+ begin
1174
+ established_connection = true
1175
+ res, tempfile = perform_net_http_request(http, req, payload)
1176
+ rescue ::Exception
1177
+ # The connection state cannot be trusted after any failure; never
1178
+ # return it to the pool.
1179
+ pool.invalidate(key, http)
1180
+ raise
1181
+ end
1182
+
1183
+ pool.checkin(key, http)
1184
+
1185
+ # Process the result only after the connection has been returned to
1186
+ # the pool: process_result may raise for HTTP error responses, and
1187
+ # those leave the connection perfectly reusable.
1188
+ process_result(res, start_time, tempfile, &block) unless @block_response
1189
+ else
1190
+ net.start do |http|
1191
+ established_connection = true
1192
+
1193
+ res, tempfile = perform_net_http_request(http, req, payload)
1194
+ process_result(res, start_time, tempfile, &block) unless @block_response
1195
+ end
1196
+ end
1197
+ rescue EOFError
1198
+ raise Contractful::ServerBrokeConnection
1199
+ rescue Net::OpenTimeout => e
1200
+ raise Contractful::Exceptions::OpenTimeout.new(nil, e)
1201
+ rescue Net::ReadTimeout => e
1202
+ raise Contractful::Exceptions::ReadTimeout.new(nil, e)
1203
+ rescue Net::WriteTimeout => e
1204
+ raise Contractful::Exceptions::WriteTimeout.new(nil, e)
1205
+ rescue Timeout::Error, Errno::ETIMEDOUT => e
1206
+ # handling for non-Net::HTTP timeouts
1207
+ raise Contractful::Exceptions::ReadTimeout.new(nil, e) if established_connection
1208
+
1209
+
1210
+ raise Contractful::Exceptions::OpenTimeout.new(nil, e)
1211
+
1212
+ rescue OpenSSL::SSL::SSLError => e
1213
+ # TODO: deprecate and remove Contractful::SSLCertificateNotVerified and just
1214
+ # pass through OpenSSL::SSL::SSLError directly.
1215
+ #
1216
+ # Exceptions in verify_callback are ignored [1], and jruby doesn't support
1217
+ # it at all [2]. Contractful has to catch OpenSSL::SSL::SSLError and either
1218
+ # re-throw it as is, or throw SSLCertificateNotVerified based on the
1219
+ # contents of the message field of the original exception.
1220
+ #
1221
+ # The client has to handle OpenSSL::SSL::SSLError exceptions anyway, so
1222
+ # we shouldn't make them handle both OpenSSL and Contractful exceptions.
1223
+ #
1224
+ # [1] https://github.com/ruby/ruby/blob/89e70fe8e7/ext/openssl/ossl.c#L238
1225
+ # [2] https://github.com/jruby/jruby/issues/597
1226
+
1227
+ raise SSLCertificateNotVerified.new(e.message) if e.message.include?('certificate verify failed')
1228
+
1229
+
1230
+ raise e
1231
+
1232
+ end
1233
+
1234
+ # Apply the request's SSL settings and timeouts to a Net::HTTP object.
1235
+ def configure_net_http(net, uri)
1236
+ net.use_ssl = uri.is_a?(URI::HTTPS)
1237
+ net.ssl_version = ssl_version if ssl_version
1238
+ net.min_version = normalize_ssl_version(ssl_min_version) if ssl_min_version
1239
+ net.max_version = normalize_ssl_version(ssl_max_version) if ssl_max_version
1240
+ net.ciphers = ssl_ciphers if ssl_ciphers
1241
+
1242
+ net.verify_mode = verify_ssl
1243
+
1244
+ net.cert = ssl_client_cert if ssl_client_cert
1245
+ net.key = ssl_client_key if ssl_client_key
1246
+ net.ca_file = ssl_ca_file if ssl_ca_file
1247
+ net.ca_path = ssl_ca_path if ssl_ca_path
1248
+ net.cert_store = ssl_cert_store if ssl_cert_store
1249
+
1250
+ # We no longer rely on net.verify_callback for the main SSL verification
1251
+ # because it's not well supported on all platforms (see comments below).
1252
+ # But do allow users to set one if they want.
1253
+ if ssl_verify_callback
1254
+ net.verify_callback = ssl_verify_callback
1255
+
1256
+ # Hilariously, jruby only calls the callback when cert_store is set to
1257
+ # something, so make sure to set one.
1258
+ # https://github.com/jruby/jruby/issues/597
1259
+ net.cert_store ||= OpenSSL::X509::Store.new if Contractful::Platform.jruby?
1260
+
1261
+ if (ssl_verify_callback_warnings != false) && print_verify_callback_warnings
1262
+ warn('pass :ssl_verify_callback_warnings => false to silence this')
1263
+ end
1264
+ end
1265
+
1266
+ if OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE
1267
+ warn('WARNING: OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE')
1268
+ warn('This dangerous monkey patch leaves you open to MITM attacks!')
1269
+ warn('Try passing :verify_ssl => false instead.')
1270
+ end
1271
+
1272
+ apply_timeouts!(net)
1273
+ end
1274
+
1275
+ # Apply the request's timeout settings to a Net::HTTP object. Safe to call
1276
+ # multiple times and on already-started connections, which is how pooled
1277
+ # connections pick up the timeouts of each request that reuses them.
1278
+ def apply_timeouts!(net)
1279
+ if defined? @read_timeout
1280
+ @read_timeout = normalize_timeout_value!(@read_timeout)
1281
+ net.read_timeout = @read_timeout
1282
+ end
1283
+ if defined? @open_timeout
1284
+ @open_timeout = normalize_timeout_value!(@open_timeout)
1285
+ net.open_timeout = @open_timeout
1286
+ end
1287
+ return unless defined? @write_timeout
1288
+
1289
+ @write_timeout = normalize_timeout_value!(@write_timeout)
1290
+ net.write_timeout = @write_timeout
1291
+
1292
+ end
1293
+
1294
+ def normalize_timeout_value!(value)
1295
+ if value == -1
1296
+ warn 'Deprecated: to disable timeouts, please use nil instead of -1'
1297
+ nil
1298
+ else
1299
+ value
1300
+ end
1301
+ end
1302
+
1303
+ # Issue the request on a started Net::HTTP connection and read the
1304
+ # response body. Returns a [response, tempfile] pair; response is nil when
1305
+ # a :block_response was provided, since the block then handles the raw
1306
+ # Net::HTTPResponse itself.
1307
+ def perform_net_http_request(http, req, payload)
1308
+ tempfile = nil
1309
+
1310
+ if @block_response
1311
+ net_http_do_request(http, req, payload, &@block_response)
1312
+ [nil, nil]
1313
+ else
1314
+ res = net_http_do_request(http, req, payload) do |http_response|
1315
+ if @raw_response
1316
+ # fetch body into tempfile
1317
+ tempfile = fetch_body_to_tempfile(http_response)
1318
+ else
1319
+ # fetch body
1320
+ http_response.read_body
1321
+ end
1322
+ http_response
1323
+ end
1324
+ [res, tempfile]
1325
+ end
1326
+ end
1327
+
1328
+ # Build the connection pool key for a persistent request. Connections are
1329
+ # pooled per scheme/host/port plus proxy and the SSL settings that cannot
1330
+ # be changed on a live connection. A custom :ssl_cert_store is assumed to
1331
+ # be equivalent for a given host (a fresh default cert store object is
1332
+ # built for every request, so object identity cannot be part of the key).
1333
+ def persistent_connection_key(uri)
1334
+ proxy_part = proxy_uri ? proxy_uri.to_s : 'direct'
1335
+ cert_fingerprint = (OpenSSL::Digest::SHA256.hexdigest(ssl_client_cert.to_der) if ssl_client_cert)
1336
+
1337
+ [
1338
+ uri.scheme, uri.host, uri.port, proxy_part, verify_ssl,
1339
+ ssl_version, ssl_min_version, ssl_max_version, ssl_ciphers,
1340
+ ssl_ca_file, ssl_ca_path, cert_fingerprint,
1341
+ ssl_verify_callback&.object_id
1342
+ ].join('|')
1343
+ end
1344
+
1345
+ def setup_credentials(req)
1346
+ return unless user && !@processed_headers_lowercase.include?('authorization')
1347
+
1348
+ req.basic_auth(user, password)
1349
+
1350
+ end
1351
+
1352
+ def fetch_body_to_tempfile(http_response)
1353
+ # Taken from Chef, which as in turn...
1354
+ # Stolen from http://www.ruby-forum.com/topic/166423
1355
+ # Kudos to _why!
1356
+ tf = Tempfile.new('contractful.')
1357
+ tf.binmode
1358
+
1359
+ size = 0
1360
+ total = http_response['Content-Length'].to_i
1361
+ stream_log_bucket = nil
1362
+
1363
+ # Wrap the read_body loop in begin/ensure so a write failure (disk
1364
+ # full, broken connection) cannot leak the tempfile's file
1365
+ # descriptor. Tempfile#close (without `true`) keeps the file on
1366
+ # disk for RawResponse to reopen; close(true) would unlink it,
1367
+ # which would break downstream code that reads the body later.
1368
+ begin
1369
+ http_response.read_body do |chunk|
1370
+ tf.write chunk
1371
+ size += chunk.size
1372
+ if log
1373
+ # Use the redacted URL here as everywhere else in the log: a URL
1374
+ # with embedded basic-auth credentials must not leak into the
1375
+ # streaming progress lines.
1376
+ if total == 0
1377
+ log << format("streaming %s %s (%d of unknown) [0 Content-Length]\n", @method.upcase, redacted_url, size)
1378
+ else
1379
+ percent = (size * 100) / total
1380
+ current_log_bucket, = percent.divmod(@stream_log_percent)
1381
+ if current_log_bucket != stream_log_bucket
1382
+ stream_log_bucket = current_log_bucket
1383
+ log << format("streaming %s %s %d%% done (%d of %d)\n", @method.upcase, redacted_url, (size * 100) / total, size, total)
1384
+ end
1385
+ end
1386
+ end
1387
+ end
1388
+ ensure
1389
+ # Defensive close: the tempfile may be a partial double in tests
1390
+ # or a custom IO that does not implement closed?/close, but the
1391
+ # common case (real Tempfile) does. Avoid leaking the FD on a
1392
+ # write/read failure even when the IO shape is unusual.
1393
+ if tf.respond_to?(:closed?) && !tf.closed?
1394
+ tf.close if tf.respond_to?(:close)
1395
+ elsif tf.respond_to?(:close)
1396
+ tf.close
1397
+ end
1398
+ end
1399
+ tf
1400
+ end
1401
+
1402
+ # @param res The Net::HTTP response object
1403
+ # @param start_time [Time] Time of request start
1404
+ def process_result(res, start_time, tempfile = nil, &block)
1405
+ if @raw_response
1406
+ raise ArgumentError.new('tempfile is required') unless tempfile
1407
+
1408
+ response = RawResponse.new(tempfile, res, self, start_time)
1409
+ else
1410
+ response = Response.create(res.body, res, self, start_time)
1411
+ end
1412
+
1413
+ response.log_response
1414
+
1415
+ # Public API (frozen since rest-client 1.x, see docs/adr/0004): the
1416
+ # user block receives the Response (a String subclass), this request,
1417
+ # and the raw Net::HTTP result; the block itself is passed through as
1418
+ # a fourth argument for callers that delegate. Do not change the
1419
+ # arity or the Response inheritance without a major-version plan.
1420
+ if block_given?
1421
+ block.call(response, self, res, & block)
1422
+ else
1423
+ response.return!(&block)
1424
+ end
1425
+ end
1426
+
1427
+ def parser
1428
+ URI.const_defined?(:Parser) ? URI::Parser.new : URI
1429
+ end
1430
+
1431
+ # Normalize an SSL version value into the form accepted by
1432
+ # OpenSSL::SSL::SSLContext#min_version=/max_version=. Symbols and strings
1433
+ # such as :TLSv1_2 or 'TLS1.3' are converted to the corresponding integer
1434
+ # protocol version constant when it is available; anything else (integers,
1435
+ # :SSLv23, unknown values) is passed through untouched so that OpenSSL can
1436
+ # apply its own validation.
1437
+ def normalize_ssl_version(value)
1438
+ return value unless value.is_a?(Symbol) || value.is_a?(String)
1439
+
1440
+ const_name = SSL_VERSION_CONSTANTS[value.to_s.upcase.tr('.', '_')]
1441
+ if const_name && OpenSSL::SSL.const_defined?(const_name)
1442
+ OpenSSL::SSL.const_get(const_name)
1443
+ else
1444
+ value
1445
+ end
1446
+ end
1447
+
1448
+ # Given a MIME type or file extension, return either a MIME type or, if
1449
+ # none is found, the input unchanged.
1450
+ #
1451
+ # >> maybe_convert_extension('json')
1452
+ # => 'application/json'
1453
+ #
1454
+ # >> maybe_convert_extension('unknown')
1455
+ # => 'unknown'
1456
+ #
1457
+ # >> maybe_convert_extension('application/xml')
1458
+ # => 'application/xml'
1459
+ #
1460
+ # @param ext [String]
1461
+ #
1462
+ # @return [String]
1463
+ #
1464
+ def maybe_convert_extension(ext)
1465
+ unless ext =~ /\A[a-zA-Z0-9_@-]+\z/
1466
+ # Don't look up strings unless they look like they could be a file
1467
+ # extension known to mime-types.
1468
+ #
1469
+ # There currently isn't any API public way to look up extensions
1470
+ # directly out of MIME::Types, but the type_for() method only strips
1471
+ # off after a period anyway.
1472
+ return ext
1473
+ end
1474
+
1475
+ types = MIME::Types.type_for(ext)
1476
+ if types.empty?
1477
+ ext
1478
+ else
1479
+ types.first.content_type
1480
+ end
1481
+ end
1482
+ end
1483
+ end