vault-kv 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
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