vault-kv 0.12.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +42 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +29 -0
  5. data/CHANGELOG.md +228 -0
  6. data/Gemfile +3 -0
  7. data/LICENSE +362 -0
  8. data/README.md +212 -0
  9. data/Rakefile +6 -0
  10. data/lib/vault.rb +49 -0
  11. data/lib/vault/api.rb +13 -0
  12. data/lib/vault/api/approle.rb +218 -0
  13. data/lib/vault/api/auth.rb +316 -0
  14. data/lib/vault/api/auth_tls.rb +92 -0
  15. data/lib/vault/api/auth_token.rb +242 -0
  16. data/lib/vault/api/help.rb +33 -0
  17. data/lib/vault/api/kv.rb +207 -0
  18. data/lib/vault/api/logical.rb +150 -0
  19. data/lib/vault/api/secret.rb +168 -0
  20. data/lib/vault/api/sys.rb +25 -0
  21. data/lib/vault/api/sys/audit.rb +91 -0
  22. data/lib/vault/api/sys/auth.rb +116 -0
  23. data/lib/vault/api/sys/health.rb +63 -0
  24. data/lib/vault/api/sys/init.rb +83 -0
  25. data/lib/vault/api/sys/leader.rb +48 -0
  26. data/lib/vault/api/sys/lease.rb +49 -0
  27. data/lib/vault/api/sys/mount.rb +103 -0
  28. data/lib/vault/api/sys/policy.rb +92 -0
  29. data/lib/vault/api/sys/seal.rb +81 -0
  30. data/lib/vault/client.rb +447 -0
  31. data/lib/vault/configurable.rb +48 -0
  32. data/lib/vault/defaults.rb +197 -0
  33. data/lib/vault/encode.rb +19 -0
  34. data/lib/vault/errors.rb +72 -0
  35. data/lib/vault/persistent.rb +1158 -0
  36. data/lib/vault/persistent/connection.rb +42 -0
  37. data/lib/vault/persistent/pool.rb +48 -0
  38. data/lib/vault/persistent/timed_stack_multi.rb +70 -0
  39. data/lib/vault/request.rb +43 -0
  40. data/lib/vault/response.rb +89 -0
  41. data/lib/vault/vendor/connection_pool.rb +150 -0
  42. data/lib/vault/vendor/connection_pool/timed_stack.rb +178 -0
  43. data/lib/vault/vendor/connection_pool/version.rb +5 -0
  44. data/lib/vault/version.rb +3 -0
  45. data/vault.gemspec +30 -0
  46. metadata +186 -0
@@ -0,0 +1,103 @@
1
+ require "json"
2
+
3
+ module Vault
4
+ class Mount < Response
5
+ # @!attribute [r] config
6
+ # Arbitrary configuration for the backend.
7
+ # @return [Hash<Symbol, Object>]
8
+ field :config
9
+
10
+ # @!attribute [r] description
11
+ # Description of the mount.
12
+ # @return [String]
13
+ field :description
14
+
15
+ # @!attribute [r] type
16
+ # Type of the mount.
17
+ # @return [String]
18
+ field :type
19
+ end
20
+
21
+ class Sys < Request
22
+ # List all mounts in the vault.
23
+ #
24
+ # @example
25
+ # Vault.sys.mounts #=> { :secret => #<struct Vault::Mount type="generic", description="generic secret storage"> }
26
+ #
27
+ # @return [Hash<Symbol, Mount>]
28
+ def mounts
29
+ json = client.get("/v1/sys/mounts")
30
+ json = json[:data] if json[:data]
31
+ return Hash[*json.map do |k,v|
32
+ [k.to_s.chomp("/").to_sym, Mount.decode(v)]
33
+ end.flatten]
34
+ end
35
+
36
+ # Create a mount at the given path.
37
+ #
38
+ # @example
39
+ # Vault.sys.mount("pg", "postgresql", "Postgres user management") #=> true
40
+ #
41
+ # @param [String] path
42
+ # the path to mount at
43
+ # @param [String] type
44
+ # the type of mount
45
+ # @param [String] description
46
+ # a human-friendly description (optional)
47
+ def mount(path, type, description = nil, options = {})
48
+ payload = options.merge type: type
49
+ payload[:description] = description if !description.nil?
50
+
51
+ client.post("/v1/sys/mounts/#{encode_path(path)}", JSON.fast_generate(payload))
52
+ return true
53
+ end
54
+
55
+ # Tune a mount at the given path.
56
+ #
57
+ # @example
58
+ # Vault.sys.mount_tune("pki", max_lease_ttl: '87600h') #=> true
59
+ #
60
+ # @param [String] path
61
+ # the path to write
62
+ # @param [Hash] data
63
+ # the data to write
64
+ def mount_tune(path, data = {})
65
+ json = client.post("/v1/sys/mounts/#{encode_path(path)}/tune", JSON.fast_generate(data))
66
+ return true
67
+ end
68
+
69
+ # Unmount the thing at the given path. If the mount does not exist, an error
70
+ # will be raised.
71
+ #
72
+ # @example
73
+ # Vault.sys.unmount("pg") #=> true
74
+ #
75
+ # @param [String] path
76
+ # the path to unmount
77
+ #
78
+ # @return [true]
79
+ def unmount(path)
80
+ client.delete("/v1/sys/mounts/#{encode_path(path)}")
81
+ return true
82
+ end
83
+
84
+ # Change the name of the mount
85
+ #
86
+ # @example
87
+ # Vault.sys.remount("pg", "postgres") #=> true
88
+ #
89
+ # @param [String] from
90
+ # the origin mount path
91
+ # @param [String] to
92
+ # the new mount path
93
+ #
94
+ # @return [true]
95
+ def remount(from, to)
96
+ client.post("/v1/sys/remount", JSON.fast_generate(
97
+ from: from,
98
+ to: to,
99
+ ))
100
+ return true
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,92 @@
1
+ require "json"
2
+
3
+ module Vault
4
+ class Policy < Response
5
+ # @!attribute [r] name
6
+ # Name of the policy.
7
+ #
8
+ # @example Get the name of the policy
9
+ # policy.name #=> "default"
10
+ #
11
+ # @return [String]
12
+ field :name
13
+
14
+ # @!attribute [r] rules
15
+ # Raw HCL policy.
16
+ #
17
+ # @example Display the list of rules
18
+ # policy.rules #=> "path \"secret/foo\" {}"
19
+ #
20
+ # @return [String]
21
+ field :rules
22
+ end
23
+
24
+ class Sys
25
+ # The list of policies in vault.
26
+ #
27
+ # @example
28
+ # Vault.sys.policies #=> ["root"]
29
+ #
30
+ # @return [Array<String>]
31
+ def policies
32
+ client.get("/v1/sys/policy")[:policies]
33
+ end
34
+
35
+ # Get the policy by the given name. If a policy does not exist by that name,
36
+ # +nil+ is returned.
37
+ #
38
+ # @example
39
+ # Vault.sys.policy("root") #=> #<Vault::Policy rules="">
40
+ #
41
+ # @return [Policy, nil]
42
+ def policy(name)
43
+ json = client.get("/v1/sys/policy/#{encode_path(name)}")
44
+ return Policy.decode(json)
45
+ rescue HTTPError => e
46
+ return nil if e.code == 404
47
+ raise
48
+ end
49
+
50
+ # Create a new policy with the given name and rules.
51
+ #
52
+ # @example
53
+ # policy = <<-EOH
54
+ # path "sys" {
55
+ # policy = "deny"
56
+ # }
57
+ # EOH
58
+ # Vault.sys.put_policy("dev", policy) #=> true
59
+ #
60
+ # It is recommend that you load policy rules from a file:
61
+ #
62
+ # @example
63
+ # policy = File.read("/path/to/my/policy.hcl")
64
+ # Vault.sys.put_policy("dev", policy)
65
+ #
66
+ # @param [String] name
67
+ # the name of the policy
68
+ # @param [String] rules
69
+ # the policy rules
70
+ #
71
+ # @return [true]
72
+ def put_policy(name, rules)
73
+ client.put("/v1/sys/policy/#{encode_path(name)}", JSON.fast_generate(
74
+ rules: rules,
75
+ ))
76
+ return true
77
+ end
78
+
79
+ # Delete the policy with the given name. If a policy does not exist, vault
80
+ # will not return an error.
81
+ #
82
+ # @example
83
+ # Vault.sys.delete_policy("dev") #=> true
84
+ #
85
+ # @param [String] name
86
+ # the name of the policy
87
+ def delete_policy(name)
88
+ client.delete("/v1/sys/policy/#{encode_path(name)}")
89
+ return true
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,81 @@
1
+ require "json"
2
+
3
+ module Vault
4
+ class SealStatus < Response
5
+ # @!method sealed?
6
+ # Returns if the Vault is sealed.
7
+ #
8
+ # @example Check if the Vault is sealed
9
+ # status.sealed? #=> true
10
+ #
11
+ # @return [Boolean]
12
+ field :sealed, as: :sealed?
13
+
14
+ # @!attribute t
15
+ # Threshold of keys required to unseal the Vault.
16
+ #
17
+ # @example Get the threshold of keys
18
+ # status.t #=> 3
19
+ #
20
+ # @return [Fixnum]
21
+ field :t
22
+
23
+ # @!attribute n
24
+ # Total number of unseal keys.
25
+ #
26
+ # @example Get the total number of keys
27
+ # status.n #=> 5
28
+ #
29
+ # @return [Fixnum]
30
+ field :n
31
+
32
+ # @!attribute progress
33
+ # Number of keys that have been entered.
34
+ #
35
+ # @example Get the current unseal progress
36
+ # status.progress #=> 2
37
+ #
38
+ # @return [Fixnum]
39
+ field :progress
40
+ end
41
+
42
+ class Sys
43
+ # Get the current seal status.
44
+ #
45
+ # @example
46
+ # Vault.sys.seal_status #=> #<Vault::SealStatus sealed=false, t=1, n=1, progress=0>
47
+ #
48
+ # @return [SealStatus]
49
+ def seal_status
50
+ json = client.get("/v1/sys/seal-status")
51
+ return SealStatus.decode(json)
52
+ end
53
+
54
+ # Seal the vault. Warning: this will seal the vault!
55
+ #
56
+ # @example
57
+ # Vault.sys.seal #=> true
58
+ #
59
+ # @return [true]
60
+ def seal
61
+ client.put("/v1/sys/seal", nil)
62
+ return true
63
+ end
64
+
65
+ # Unseal the vault with the given shard.
66
+ #
67
+ # @example
68
+ # Vault.sys.unseal("abcd-1234") #=> #<Vault::SealStatus sealed=true, t=3, n=5, progress=1>
69
+ #
70
+ # @param [String] shard
71
+ # the key to use
72
+ #
73
+ # @return [SealStatus]
74
+ def unseal(shard)
75
+ json = client.put("/v1/sys/unseal", JSON.fast_generate(
76
+ key: shard,
77
+ ))
78
+ return SealStatus.decode(json)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,447 @@
1
+ require "cgi"
2
+ require "json"
3
+ require "uri"
4
+
5
+ require_relative "persistent"
6
+ require_relative "configurable"
7
+ require_relative "errors"
8
+ require_relative "version"
9
+ require_relative "encode"
10
+
11
+ module Vault
12
+ class Client
13
+ # The user agent for this client.
14
+ USER_AGENT = "VaultRuby/#{Vault::VERSION} (+github.com/hashicorp/vault-ruby)".freeze
15
+
16
+ # The name of the header used to hold the Vault token.
17
+ TOKEN_HEADER = "X-Vault-Token".freeze
18
+
19
+ # The name of the header used to hold the wrapped request ttl.
20
+ WRAP_TTL_HEADER = "X-Vault-Wrap-TTL".freeze
21
+
22
+ # The name of the header used for redirection.
23
+ LOCATION_HEADER = "location".freeze
24
+
25
+ # The default headers that are sent with every request.
26
+ DEFAULT_HEADERS = {
27
+ "Content-Type" => "application/json",
28
+ "Accept" => "application/json",
29
+ "User-Agent" => USER_AGENT,
30
+ }.freeze
31
+
32
+ # The default list of options to use when parsing JSON.
33
+ JSON_PARSE_OPTIONS = {
34
+ max_nesting: false,
35
+ create_additions: false,
36
+ symbolize_names: true,
37
+ }.freeze
38
+
39
+ RESCUED_EXCEPTIONS = [].tap do |a|
40
+ # Failure to even open the socket (usually permissions)
41
+ a << SocketError
42
+
43
+ # Failed to reach the server (aka bad URL)
44
+ a << Errno::ECONNREFUSED
45
+
46
+ # Failed to read body or no response body given
47
+ a << EOFError
48
+
49
+ # Timeout (Ruby 1.9-)
50
+ a << Timeout::Error
51
+
52
+ # Timeout (Ruby 1.9+) - Ruby 1.9 does not define these constants so we
53
+ # only add them if they are defiend
54
+ a << Net::ReadTimeout if defined?(Net::ReadTimeout)
55
+ a << Net::OpenTimeout if defined?(Net::OpenTimeout)
56
+
57
+ a << PersistentHTTP::Error
58
+ end.freeze
59
+
60
+ # Indicates a requested operation is not possible due to security
61
+ # concerns.
62
+ class SecurityError < RuntimeError
63
+ end
64
+
65
+ include Vault::Configurable
66
+
67
+ # Create a new Client with the given options. Any options given take
68
+ # precedence over the default options.
69
+ #
70
+ # @return [Vault::Client]
71
+ def initialize(options = {})
72
+ # Use any options given, but fall back to the defaults set on the module
73
+ Vault::Configurable.keys.each do |key|
74
+ value = options.key?(key) ? options[key] : Defaults.public_send(key)
75
+ instance_variable_set(:"@#{key}", value)
76
+ end
77
+
78
+ @lock = Mutex.new
79
+ @nhp = nil
80
+ end
81
+
82
+ def pool
83
+ @lock.synchronize do
84
+ return @nhp if @nhp
85
+
86
+ @nhp = PersistentHTTP.new("vault-ruby", nil, pool_size)
87
+
88
+ if proxy_address
89
+ proxy_uri = URI.parse "http://#{proxy_address}"
90
+
91
+ proxy_uri.port = proxy_port if proxy_port
92
+
93
+ if proxy_username
94
+ proxy_uri.user = proxy_username
95
+ proxy_uri.password = proxy_password
96
+ end
97
+
98
+ @nhp.proxy = proxy_uri
99
+ end
100
+
101
+ # Use a custom open timeout
102
+ if open_timeout || timeout
103
+ @nhp.open_timeout = (open_timeout || timeout).to_i
104
+ end
105
+
106
+ # Use a custom read timeout
107
+ if read_timeout || timeout
108
+ @nhp.read_timeout = (read_timeout || timeout).to_i
109
+ end
110
+
111
+ @nhp.verify_mode = OpenSSL::SSL::VERIFY_PEER
112
+
113
+ # Vault requires TLS1.2
114
+ @nhp.ssl_version = "TLSv1_2"
115
+
116
+ # Only use secure ciphers
117
+ @nhp.ciphers = ssl_ciphers
118
+
119
+ # Custom pem files, no problem!
120
+ pem = ssl_pem_contents || (ssl_pem_file ? File.read(ssl_pem_file) : nil)
121
+ if pem
122
+ @nhp.cert = OpenSSL::X509::Certificate.new(pem)
123
+ @nhp.key = OpenSSL::PKey::RSA.new(pem, ssl_pem_passphrase)
124
+ end
125
+
126
+ # Use custom CA cert for verification
127
+ if ssl_ca_cert
128
+ @nhp.ca_file = ssl_ca_cert
129
+ end
130
+
131
+ # Use custom CA path that contains CA certs
132
+ if ssl_ca_path
133
+ @nhp.ca_path = ssl_ca_path
134
+ end
135
+
136
+ if ssl_cert_store
137
+ @nhp.cert_store = ssl_cert_store
138
+ end
139
+
140
+ # Naughty, naughty, naughty! Don't blame me when someone hops in
141
+ # and executes a MITM attack!
142
+ if !ssl_verify
143
+ @nhp.verify_mode = OpenSSL::SSL::VERIFY_NONE
144
+ end
145
+
146
+ # Use custom timeout for connecting and verifying via SSL
147
+ if ssl_timeout || timeout
148
+ @nhp.ssl_timeout = (ssl_timeout || timeout).to_i
149
+ end
150
+
151
+ @nhp
152
+ end
153
+ end
154
+
155
+ private :pool
156
+
157
+ # Shutdown any open pool connections. Pool will be recreated upon next request.
158
+ def shutdown
159
+ @nhp.shutdown()
160
+ @nhp = nil
161
+ end
162
+
163
+ # Creates and yields a new client object with the given token. This may be
164
+ # used safely in a threadsafe manner because the original client remains
165
+ # unchanged. The value of the block is returned.
166
+ #
167
+ # @yield [Vault::Client]
168
+ def with_token(token)
169
+ client = self.dup
170
+ client.token = token
171
+ return yield client if block_given?
172
+ return nil
173
+ end
174
+
175
+ # Determine if the given options are the same as ours.
176
+ # @return [true, false]
177
+ def same_options?(opts)
178
+ options.hash == opts.hash
179
+ end
180
+
181
+ # Perform a GET request.
182
+ # @see Client#request
183
+ def get(path, params = {}, headers = {})
184
+ request(:get, path, params, headers)
185
+ end
186
+
187
+ # Perform a LIST request.
188
+ # @see Client#request
189
+ def list(path, params = {}, headers = {})
190
+ params = params.merge(list: true)
191
+ request(:get, path, params, headers)
192
+ end
193
+
194
+ # Perform a POST request.
195
+ # @see Client#request
196
+ def post(path, data = {}, headers = {})
197
+ request(:post, path, data, headers)
198
+ end
199
+
200
+ # Perform a PUT request.
201
+ # @see Client#request
202
+ def put(path, data, headers = {})
203
+ request(:put, path, data, headers)
204
+ end
205
+
206
+ # Perform a PATCH request.
207
+ # @see Client#request
208
+ def patch(path, data, headers = {})
209
+ request(:patch, path, data, headers)
210
+ end
211
+
212
+ # Perform a DELETE request.
213
+ # @see Client#request
214
+ def delete(path, params = {}, headers = {})
215
+ request(:delete, path, params, headers)
216
+ end
217
+
218
+ # Make an HTTP request with the given verb, data, params, and headers. If
219
+ # the response has a return type of JSON, the JSON is automatically parsed
220
+ # and returned as a hash; otherwise it is returned as a string.
221
+ #
222
+ # @raise [HTTPError]
223
+ # if the request is not an HTTP 200 OK
224
+ #
225
+ # @param [Symbol] verb
226
+ # the lowercase symbol of the HTTP verb (e.g. :get, :delete)
227
+ # @param [String] path
228
+ # the absolute or relative path from {Defaults.address} to make the
229
+ # request against
230
+ # @param [#read, Hash, nil] data
231
+ # the data to use (varies based on the +verb+)
232
+ # @param [Hash] headers
233
+ # the list of headers to use
234
+ #
235
+ # @return [String, Hash]
236
+ # the response body
237
+ def request(verb, path, data = {}, headers = {})
238
+ # Build the URI and request object from the given information
239
+ uri = build_uri(verb, path, data)
240
+ request = class_for_request(verb).new(uri.request_uri)
241
+ if uri.userinfo()
242
+ request.basic_auth uri.user, uri.password
243
+ end
244
+
245
+ if proxy_address and uri.scheme.downcase == "https"
246
+ raise SecurityError, "no direct https connection to vault"
247
+ end
248
+
249
+ # Get a list of headers
250
+ headers = DEFAULT_HEADERS.merge(headers)
251
+
252
+ # Add the Vault token header - users could still override this on a
253
+ # per-request basis
254
+ if !token.nil?
255
+ headers[TOKEN_HEADER] ||= token
256
+ end
257
+
258
+ # Add headers
259
+ headers.each do |key, value|
260
+ request.add_field(key, value)
261
+ end
262
+
263
+ # Setup PATCH/POST/PUT
264
+ if [:patch, :post, :put].include?(verb)
265
+ if data.respond_to?(:read)
266
+ request.content_length = data.size
267
+ request.body_stream = data
268
+ elsif data.is_a?(Hash)
269
+ request.form_data = data
270
+ else
271
+ request.body = data
272
+ end
273
+ end
274
+
275
+ begin
276
+ # Create a connection using the block form, which will ensure the socket
277
+ # is properly closed in the event of an error.
278
+ response = pool.request(uri, request)
279
+
280
+ case response
281
+ when Net::HTTPRedirection
282
+ # On a redirect of a GET or HEAD request, the URL already contains
283
+ # the data as query string parameters.
284
+ if [:head, :get].include?(verb)
285
+ data = {}
286
+ end
287
+ request(verb, response[LOCATION_HEADER], data, headers)
288
+ when Net::HTTPSuccess
289
+ success(response)
290
+ else
291
+ error(response)
292
+ end
293
+ rescue *RESCUED_EXCEPTIONS => e
294
+ raise HTTPConnectionError.new(address, e)
295
+ end
296
+ end
297
+
298
+ # Construct a URL from the given verb and path. If the request is a GET or
299
+ # DELETE request, the params are assumed to be query params are are
300
+ # converted as such using {Client#to_query_string}.
301
+ #
302
+ # If the path is relative, it is merged with the {Defaults.address}
303
+ # attribute. If the path is absolute, it is converted to a URI object and
304
+ # returned.
305
+ #
306
+ # @param [Symbol] verb
307
+ # the lowercase HTTP verb (e.g. :+get+)
308
+ # @param [String] path
309
+ # the absolute or relative HTTP path (url) to get
310
+ # @param [Hash] params
311
+ # the list of params to build the URI with (for GET and DELETE requests)
312
+ #
313
+ # @return [URI]
314
+ def build_uri(verb, path, params = {})
315
+ # Add any query string parameters
316
+ if [:delete, :get].include?(verb)
317
+ path = [path, to_query_string(params)].compact.join("?")
318
+ end
319
+
320
+ # Parse the URI
321
+ uri = URI.parse(path)
322
+
323
+ # Don't merge absolute URLs
324
+ uri = URI.parse(File.join(address, path)) unless uri.absolute?
325
+
326
+ # Return the URI object
327
+ uri
328
+ end
329
+
330
+ # Helper method to get the corresponding {Net::HTTP} class from the given
331
+ # HTTP verb.
332
+ #
333
+ # @param [#to_s] verb
334
+ # the HTTP verb to create a class from
335
+ #
336
+ # @return [Class]
337
+ def class_for_request(verb)
338
+ Net::HTTP.const_get(verb.to_s.capitalize)
339
+ end
340
+
341
+ # Convert the given hash to a list of query string parameters. Each key and
342
+ # value in the hash is URI-escaped for safety.
343
+ #
344
+ # @param [Hash] hash
345
+ # the hash to create the query string from
346
+ #
347
+ # @return [String, nil]
348
+ # the query string as a string, or +nil+ if there are no params
349
+ def to_query_string(hash)
350
+ hash.map do |key, value|
351
+ "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
352
+ end.join('&')[/.+/]
353
+ end
354
+
355
+ # Parse the response object and manipulate the result based on the given
356
+ # +Content-Type+ header. For now, this method only parses JSON, but it
357
+ # could be expanded in the future to accept other content types.
358
+ #
359
+ # @param [HTTP::Message] response
360
+ # the response object from the request
361
+ #
362
+ # @return [String, Hash]
363
+ # the parsed response, as an object
364
+ def success(response)
365
+ if response.body && (response.content_type || '').include?("json")
366
+ JSON.parse(response.body, JSON_PARSE_OPTIONS)
367
+ else
368
+ response.body
369
+ end
370
+ end
371
+
372
+ # Raise a response error, extracting as much information from the server's
373
+ # response as possible.
374
+ #
375
+ # @raise [HTTPError]
376
+ #
377
+ # @param [HTTP::Message] response
378
+ # the response object from the request
379
+ def error(response)
380
+ if response.body && response.body.match("missing client token")
381
+ raise MissingTokenError
382
+ end
383
+
384
+ # Use the correct exception class
385
+ case response
386
+ when Net::HTTPClientError
387
+ klass = HTTPClientError
388
+ when Net::HTTPServerError
389
+ klass = HTTPServerError
390
+ else
391
+ klass = HTTPError
392
+ end
393
+
394
+ if (response.content_type || '').include?("json")
395
+ # Attempt to parse the error as JSON
396
+ begin
397
+ json = JSON.parse(response.body, JSON_PARSE_OPTIONS)
398
+
399
+ if json[:errors]
400
+ raise klass.new(address, response, json[:errors])
401
+ end
402
+ rescue JSON::ParserError; end
403
+ end
404
+
405
+ raise klass.new(address, response, [response.body])
406
+ end
407
+
408
+ # Execute the given block with retries and exponential backoff.
409
+ #
410
+ # @param [Array<Exception>] rescued
411
+ # the list of exceptions to rescue
412
+ def with_retries(*rescued, &block)
413
+ options = rescued.last.is_a?(Hash) ? rescued.pop : {}
414
+ exception = nil
415
+ retries = 0
416
+
417
+ rescued = Defaults::RETRIED_EXCEPTIONS if rescued.empty?
418
+
419
+ max_attempts = options[:attempts] || Defaults::RETRY_ATTEMPTS
420
+ backoff_base = options[:base] || Defaults::RETRY_BASE
421
+ backoff_max = options[:max_wait] || Defaults::RETRY_MAX_WAIT
422
+
423
+ begin
424
+ return yield retries, exception
425
+ rescue *rescued => e
426
+ exception = e
427
+
428
+ retries += 1
429
+ raise if retries > max_attempts
430
+
431
+ # Calculate the exponential backoff combined with an element of
432
+ # randomness.
433
+ backoff = [backoff_base * (2 ** (retries - 1)), backoff_max].min
434
+ backoff = backoff * (0.5 * (1 + Kernel.rand))
435
+
436
+ # Ensure we are sleeping at least the minimum interval.
437
+ backoff = [backoff_base, backoff].max
438
+
439
+ # Exponential backoff.
440
+ Kernel.sleep(backoff)
441
+
442
+ # Now retry
443
+ retry
444
+ end
445
+ end
446
+ end
447
+ end