latitude-api 0.2.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +283 -0
  5. data/Rakefile +13 -0
  6. data/lib/latitude/api/api_object.rb +105 -0
  7. data/lib/latitude/api/api_resource.rb +260 -0
  8. data/lib/latitude/api/attributes.rb +64 -0
  9. data/lib/latitude/api/client.rb +80 -0
  10. data/lib/latitude/api/configuration.rb +88 -0
  11. data/lib/latitude/api/errors.rb +134 -0
  12. data/lib/latitude/api/json_api.rb +40 -0
  13. data/lib/latitude/api/list_object.rb +129 -0
  14. data/lib/latitude/api/objects.rb +216 -0
  15. data/lib/latitude/api/operations/action.rb +41 -0
  16. data/lib/latitude/api/operations/create.rb +22 -0
  17. data/lib/latitude/api/operations/delete.rb +22 -0
  18. data/lib/latitude/api/operations/list.rb +54 -0
  19. data/lib/latitude/api/operations/nested.rb +53 -0
  20. data/lib/latitude/api/operations/retrieve.rb +22 -0
  21. data/lib/latitude/api/operations/update.rb +22 -0
  22. data/lib/latitude/api/request_executor.rb +217 -0
  23. data/lib/latitude/api/request_options.rb +86 -0
  24. data/lib/latitude/api/request_params.rb +70 -0
  25. data/lib/latitude/api/resources/api_key.rb +61 -0
  26. data/lib/latitude/api/resources/billing_usage.rb +51 -0
  27. data/lib/latitude/api/resources/elastic_ip.rb +96 -0
  28. data/lib/latitude/api/resources/event.rb +55 -0
  29. data/lib/latitude/api/resources/firewall.rb +129 -0
  30. data/lib/latitude/api/resources/ip.rb +110 -0
  31. data/lib/latitude/api/resources/kubernetes_cluster.rb +184 -0
  32. data/lib/latitude/api/resources/plan.rb +658 -0
  33. data/lib/latitude/api/resources/project.rb +179 -0
  34. data/lib/latitude/api/resources/region.rb +41 -0
  35. data/lib/latitude/api/resources/role.rb +20 -0
  36. data/lib/latitude/api/resources/server.rb +251 -0
  37. data/lib/latitude/api/resources/ssh_key.rb +44 -0
  38. data/lib/latitude/api/resources/storage_filesystem.rb +36 -0
  39. data/lib/latitude/api/resources/storage_object.rb +85 -0
  40. data/lib/latitude/api/resources/storage_volume.rb +64 -0
  41. data/lib/latitude/api/resources/tag.rb +34 -0
  42. data/lib/latitude/api/resources/team.rb +169 -0
  43. data/lib/latitude/api/resources/traffic.rb +215 -0
  44. data/lib/latitude/api/resources/user_data.rb +38 -0
  45. data/lib/latitude/api/resources/user_profile.rb +51 -0
  46. data/lib/latitude/api/resources/user_team.rb +90 -0
  47. data/lib/latitude/api/resources/virtual_machine.rb +146 -0
  48. data/lib/latitude/api/resources/virtual_network.rb +162 -0
  49. data/lib/latitude/api/resources/vpn_session.rb +80 -0
  50. data/lib/latitude/api/singleton_api_resource.rb +22 -0
  51. data/lib/latitude/api/util.rb +64 -0
  52. data/lib/latitude/api/version.rb +7 -0
  53. data/lib/latitude/api.rb +147 -0
  54. data/lib/latitude/metadata/errors.rb +18 -0
  55. data/lib/latitude/metadata/session.rb +109 -0
  56. data/lib/latitude/metadata.rb +43 -0
  57. data/lib/latitude.rb +4 -0
  58. metadata +132 -0
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ # Shared, read-only value objects that appear as nested attributes across
6
+ # many resources (project/team/currency/limits/stats/billing/tag/user/role).
7
+ module Objects
8
+ # @!attribute [r] id
9
+ # @return [String]
10
+ # @!attribute [r] code
11
+ # @return [String]
12
+ # @!attribute [r] name
13
+ # @return [String]
14
+ # @!attribute [r] currency_id
15
+ # @return [String]
16
+ class Currency < APIObject
17
+ attribute :id, :string, read_only: true
18
+ attribute :code, :string, read_only: true
19
+ attribute :name, :string, read_only: true
20
+ attribute :currency_id, :string, read_only: true
21
+ end
22
+
23
+ # @!attribute [r] bare_metal
24
+ # @return [Integer]
25
+ # @!attribute [r] bare_metal_gpu
26
+ # @return [Integer]
27
+ # @!attribute [r] virtual_machine
28
+ # @return [Integer]
29
+ # @!attribute [r] virtual_machine_gpu
30
+ # @return [Integer]
31
+ # @!attribute [r] elastic_ip
32
+ # @return [Integer]
33
+ # @!attribute [r] virtual_network
34
+ # @return [Integer]
35
+ # @!attribute [r] database
36
+ # @return [String]
37
+ # @!attribute [r] filesystem
38
+ # @return [String]
39
+ # @!attribute [r] block_storage
40
+ # @return [String]
41
+ class Limits < APIObject
42
+ attribute :bare_metal, :integer, read_only: true
43
+ attribute :bare_metal_gpu, :integer, read_only: true
44
+ attribute :virtual_machine, :integer, read_only: true
45
+ attribute :virtual_machine_gpu, :integer, read_only: true
46
+ attribute :elastic_ip, :integer, read_only: true
47
+ attribute :virtual_network, :integer, read_only: true
48
+ attribute :database, :string, read_only: true
49
+ attribute :filesystem, :string, read_only: true
50
+ attribute :block_storage, :string, read_only: true
51
+ end
52
+
53
+ # @!attribute [r] databases
54
+ # @return [Integer]
55
+ # @!attribute [r] ip_addresses
56
+ # @return [Integer]
57
+ # @!attribute [r] prefixes
58
+ # @return [Integer]
59
+ # @!attribute [r] servers
60
+ # @return [Integer]
61
+ # @!attribute [r] storages
62
+ # @return [Integer]
63
+ # @!attribute [r] virtual_machines
64
+ # @return [Integer]
65
+ # @!attribute [r] vlans
66
+ # @return [Integer]
67
+ class Stats < APIObject
68
+ attribute :databases, :integer, read_only: true
69
+ attribute :ip_addresses, :integer, read_only: true
70
+ attribute :prefixes, :integer, read_only: true
71
+ attribute :servers, :integer, read_only: true
72
+ attribute :storages, :integer, read_only: true
73
+ attribute :virtual_machines, :integer, read_only: true
74
+ attribute :vlans, :integer, read_only: true
75
+ end
76
+
77
+ # @!attribute [r] id
78
+ # @return [String]
79
+ # @!attribute [r] customer_billing_id
80
+ # @return [String]
81
+ # @!attribute [r] subscription_id
82
+ # @return [String]
83
+ # @!attribute [r] type
84
+ # @return [String]
85
+ # @!attribute [r] method
86
+ # @return [String]
87
+ class Billing < APIObject
88
+ attribute :id, :string, read_only: true
89
+ attribute :customer_billing_id, :string, read_only: true
90
+ attribute :subscription_id, :string, read_only: true
91
+ attribute :type, :string, read_only: true
92
+ attribute :method, :string, read_only: true
93
+ end
94
+
95
+ # @!attribute [r] id
96
+ # @return [String]
97
+ # @!attribute [r] name
98
+ # @return [String]
99
+ # @!attribute [r] created_at
100
+ # @return [Time]
101
+ # @!attribute [r] updated_at
102
+ # @return [Time]
103
+ class Role < APIObject
104
+ attribute :id, :string, read_only: true
105
+ attribute :name, :string, read_only: true
106
+ attribute :created_at, :time, read_only: true
107
+ attribute :updated_at, :time, read_only: true
108
+ end
109
+
110
+ # @!attribute [r] id
111
+ # @return [String]
112
+ # @!attribute [r] name
113
+ # @return [String]
114
+ # @!attribute [r] description
115
+ # @return [String]
116
+ # @!attribute [r] color
117
+ # @return [String]
118
+ class Tag < APIObject
119
+ attribute :id, :string, read_only: true
120
+ attribute :name, :string, read_only: true
121
+ attribute :description, :string, read_only: true
122
+ attribute :color, :string, read_only: true
123
+ end
124
+
125
+ # @!attribute [r] id
126
+ # @return [String]
127
+ # @!attribute [r] name
128
+ # @return [String]
129
+ # @!attribute [r] slug
130
+ # @return [String]
131
+ # @!attribute [r] description
132
+ # @return [String]
133
+ # @!attribute [r] address
134
+ # @return [String]
135
+ # @!attribute [r] currency
136
+ # @return [Latitude::API::Objects::Currency]
137
+ # @!attribute [r] status
138
+ # @return [String]
139
+ # @!attribute [r] feature_flags
140
+ # @return [Array]
141
+ # @!attribute [r] limits
142
+ # @return [Latitude::API::Objects::Limits]
143
+ class Team < APIObject
144
+ attribute :id, :string, read_only: true
145
+ attribute :name, :string, read_only: true
146
+ attribute :slug, :string, read_only: true
147
+ attribute :description, :string, read_only: true
148
+ attribute :address, :string, read_only: true
149
+ attribute :currency, Objects::Currency, read_only: true
150
+ attribute :status, :string, read_only: true
151
+ attribute :feature_flags, :array, read_only: true
152
+ attribute :limits, Objects::Limits, read_only: true
153
+ end
154
+
155
+ # @!attribute [r] id
156
+ # @return [String]
157
+ # @!attribute [r] slug
158
+ # @return [String]
159
+ # @!attribute [r] name
160
+ # @return [String]
161
+ # @!attribute [r] description
162
+ # @return [String]
163
+ # @!attribute [r] provisioning_type
164
+ # @return [String]
165
+ # @!attribute [r] billing_type
166
+ # @return [String]
167
+ # @!attribute [r] billing_method
168
+ # @return [String]
169
+ # @!attribute [r] bandwidth_alert
170
+ # @return [Boolean]
171
+ # @!attribute [r] environment
172
+ # @return [String]
173
+ # @!attribute [r] billing
174
+ # @return [Latitude::API::Objects::Billing]
175
+ # @!attribute [r] stats
176
+ # @return [Latitude::API::Objects::Stats]
177
+ class Project < APIObject
178
+ attribute :id, :string, read_only: true
179
+ attribute :slug, :string, read_only: true
180
+ attribute :name, :string, read_only: true
181
+ attribute :description, :string, read_only: true
182
+ attribute :provisioning_type, :string, read_only: true
183
+ attribute :billing_type, :string, read_only: true
184
+ attribute :billing_method, :string, read_only: true
185
+ attribute :bandwidth_alert, :boolean, read_only: true
186
+ attribute :environment, :string, read_only: true
187
+ attribute :billing, Objects::Billing, read_only: true
188
+ attribute :stats, Objects::Stats, read_only: true
189
+ end
190
+
191
+ # @!attribute [r] id
192
+ # @return [String]
193
+ # @!attribute [r] email
194
+ # @return [String]
195
+ # @!attribute [r] first_name
196
+ # @return [String]
197
+ # @!attribute [r] last_name
198
+ # @return [String]
199
+ # @!attribute [r] created_at
200
+ # @return [Time]
201
+ # @!attribute [r] updated_at
202
+ # @return [Time]
203
+ # @!attribute [r] role
204
+ # @return [Latitude::API::Objects::Role]
205
+ class User < APIObject
206
+ attribute :id, :string, read_only: true
207
+ attribute :email, :string, read_only: true
208
+ attribute :first_name, :string, read_only: true
209
+ attribute :last_name, :string, read_only: true
210
+ attribute :created_at, :time, read_only: true
211
+ attribute :updated_at, :time, read_only: true
212
+ attribute :role, Objects::Role, read_only: true
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module Operations
6
+ module Action
7
+ def action(name, path:, type: nil, method: :post, attributes: {}, accepts: nil, returns: :raw)
8
+ action_method = method.to_sym
9
+ action_path = path
10
+ action_type = type
11
+ action_attrs = attributes
12
+ accepts_keys = accepts ? Array(accepts).map(&:to_sym) : nil
13
+ returns_mode = returns
14
+
15
+ define_singleton_method(name) do |id, params = {}, opts = {}|
16
+ params = params.dup
17
+ path_params = extract_path_params(params, path_param_keys)
18
+ if accepts_keys
19
+ unknown = params.keys.map(&:to_sym) - accepts_keys
20
+ raise ArgumentError, "unknown action params: #{unknown.join(', ')}" unless unknown.empty?
21
+ end
22
+ body_attrs = action_attrs.merge(params)
23
+ body = if action_type
24
+ JSONAPI.encode(type: action_type, attributes: body_attrs)
25
+ elsif !body_attrs.empty?
26
+ JSON.generate(body_attrs)
27
+ end
28
+ url = "#{instance_url(id, path_params: path_params)}/#{action_path}"
29
+ parsed = execute_request(method: action_method, path: url, body: body, opts: opts)
30
+ returns_mode == :instance ? construct_from(parsed, opts: opts, path_params: path_params) : parsed
31
+ end
32
+
33
+ define_method(name) do |params = {}, opts = {}|
34
+ merged = @path_params.merge(params || {})
35
+ self.class.public_send(name, id, merged, opts)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module Operations
6
+ module Create
7
+ def create(params = {}, opts = {})
8
+ params = params.dup
9
+ path_params = extract_path_params(params, path_param_keys)
10
+ body = JSONAPI.encode(type: resource_type, attributes: params)
11
+ parsed = execute_request(
12
+ method: :post,
13
+ path: class_url(path_params: path_params),
14
+ body: body,
15
+ opts: opts,
16
+ )
17
+ construct_from(parsed, opts: opts, path_params: path_params)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module Operations
6
+ module Delete
7
+ def delete(id, params = {}, opts = {})
8
+ params = params.dup
9
+ path_params = extract_path_params(params, path_param_keys)
10
+ query = RequestParams.encode(params)
11
+ execute_request(
12
+ method: :delete,
13
+ path: instance_url(id, path_params: path_params),
14
+ query: query,
15
+ opts: opts,
16
+ )
17
+ true
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module Operations
6
+ module List
7
+ def list(params = {}, opts = {})
8
+ params = deep_dup_hash(params)
9
+ path_params = params.delete(:_path_params) || extract_path_params(params, path_param_keys)
10
+ query = build_query(params)
11
+ parsed = execute_request(
12
+ method: :get,
13
+ path: class_url(path_params: path_params),
14
+ query: query,
15
+ opts: opts,
16
+ )
17
+ ListObject.new(
18
+ resource_class: self,
19
+ parsed: parsed,
20
+ request_params: params,
21
+ request_opts: opts,
22
+ path_params: path_params,
23
+ )
24
+ end
25
+
26
+ def all(params = {}, opts = {})
27
+ list(params, opts).auto_paging_each.to_a
28
+ end
29
+
30
+ private
31
+
32
+ def build_query(params)
33
+ params = params.dup
34
+ sort = params.delete(:sort) || params.delete("sort")
35
+ encoded = RequestParams.encode(params)
36
+ encoded_sort = RequestParams.encode_sort(sort)
37
+ [encoded, encoded_sort && !encoded_sort.empty? ? "sort=#{URI.encode_www_form_component(encoded_sort)}" : nil]
38
+ .reject { |s| s.nil? || s.empty? }
39
+ .join("&")
40
+ end
41
+
42
+ def deep_dup_hash(hash)
43
+ hash.each_with_object({}) do |(k, v), out|
44
+ out[k] = case v
45
+ when Hash then deep_dup_hash(v)
46
+ when Array then v.dup
47
+ else v
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module Operations
6
+ module Nested
7
+ def nested_resource(name, class_name:)
8
+ parent_key = path_param_keys_for_parent
9
+ define_singleton_method(name) do |id|
10
+ klass = Object.const_get(class_name)
11
+ NestedAccessor.new(klass: klass, path_params: { parent_key => id })
12
+ end
13
+
14
+ define_method(name) do
15
+ klass = Object.const_get(class_name)
16
+ NestedAccessor.new(klass: klass, path_params: { parent_key => @id })
17
+ end
18
+ end
19
+
20
+ def path_param_keys_for_parent
21
+ "#{resource_type.sub(/s\z/, '')}_id".to_sym
22
+ end
23
+ end
24
+
25
+ class NestedAccessor
26
+ def initialize(klass:, path_params:)
27
+ @klass = klass
28
+ @path_params = path_params
29
+ end
30
+
31
+ def list(params = {}, opts = {})
32
+ @klass.list(params.merge(@path_params), opts, path_params: @path_params)
33
+ end
34
+
35
+ def retrieve(id, params = {}, opts = {})
36
+ @klass.retrieve(id, params.merge(@path_params), opts)
37
+ end
38
+
39
+ def create(params = {}, opts = {})
40
+ @klass.create(params.merge(@path_params), opts)
41
+ end
42
+
43
+ def update(id, params = {}, opts = {})
44
+ @klass.update(id, params.merge(@path_params), opts)
45
+ end
46
+
47
+ def delete(id, params = {}, opts = {})
48
+ @klass.delete(id, params.merge(@path_params), opts)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module Operations
6
+ module Retrieve
7
+ def retrieve(id, params = {}, opts = {})
8
+ params = params.dup
9
+ path_params = extract_path_params(params, path_param_keys)
10
+ query = RequestParams.encode(params)
11
+ parsed = execute_request(
12
+ method: :get,
13
+ path: instance_url(id, path_params: path_params),
14
+ query: query,
15
+ opts: opts,
16
+ )
17
+ construct_from(parsed, opts: opts, path_params: path_params)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module Operations
6
+ module Update
7
+ def update(id, params = {}, opts = {})
8
+ params = params.dup
9
+ path_params = extract_path_params(params, path_param_keys)
10
+ body = JSONAPI.encode(type: resource_type, id: id, attributes: params)
11
+ parsed = execute_request(
12
+ method: :patch,
13
+ path: instance_url(id, path_params: path_params),
14
+ body: body,
15
+ opts: opts,
16
+ )
17
+ construct_from(parsed, opts: opts, path_params: path_params)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+
5
+ module Latitude
6
+ module API
7
+ class RequestExecutor
8
+ RETRIABLE_STATUSES = [429, 500, 502, 503, 504].freeze
9
+ RETRIABLE_EXCEPTIONS = [
10
+ EOFError,
11
+ Errno::ECONNREFUSED,
12
+ Errno::ECONNRESET,
13
+ Errno::EPIPE,
14
+ Net::OpenTimeout,
15
+ Net::ReadTimeout,
16
+ OpenSSL::SSL::SSLError,
17
+ SocketError,
18
+ ].freeze
19
+
20
+ def initialize(config)
21
+ @config = config
22
+ end
23
+
24
+ def execute(method:, path:, query: nil, body: nil, headers: {}, idempotency_key: nil, extra_headers: {})
25
+ @config.validate_api_key!
26
+
27
+ url = build_url(path, query)
28
+ request_headers = build_headers(method, headers, idempotency_key, extra_headers)
29
+ options = build_httparty_options(request_headers, body)
30
+
31
+ attempts = 0
32
+ max_retries = @config.max_network_retries
33
+
34
+ loop do
35
+ attempts += 1
36
+ started = Time.now
37
+ response = nil
38
+ begin
39
+ response = HTTParty.send(method.to_sym, url.to_s, options)
40
+ rescue *RETRIABLE_EXCEPTIONS => e
41
+ log_exception(e, method, path, attempts)
42
+ raise ConnectionError.new("Network error talking to #{url}: #{e.message}", cause: e) if attempts > max_retries
43
+
44
+ sleep(backoff_delay(attempts, nil))
45
+ next
46
+ end
47
+
48
+ duration = ((Time.now - started) * 1000).round
49
+ log_response(method, path, response, duration)
50
+
51
+ if retriable_status?(response.code) && attempts <= max_retries
52
+ sleep(backoff_delay(attempts, response))
53
+ next
54
+ end
55
+
56
+ return handle_response(response, method: method, path: path)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def build_url(path, query)
63
+ base = URI.parse(@config.api_base)
64
+ resolved = path.start_with?("http://", "https://") ? URI.parse(path) : URI.join("#{base}/", path.sub(%r{^/}, ""))
65
+ if query && !query.empty?
66
+ resolved.query = [resolved.query, query].compact.reject(&:empty?).join("&")
67
+ end
68
+ resolved
69
+ end
70
+
71
+ def build_headers(method, headers, idempotency_key, extra_headers)
72
+ h = {}
73
+ h["Authorization"] = "Bearer #{@config.api_key}"
74
+ h["Accept"] = JSONAPI::MEDIA_TYPE
75
+ h["Content-Type"] = "application/json" if %i[post put patch].include?(method.to_sym)
76
+ h["User-Agent"] = user_agent
77
+ h["API-Version"] = @config.api_version if @config.api_version
78
+ h["Idempotency-Key"] = idempotency_key if idempotency_key
79
+
80
+ (headers || {}).each { |k, v| h[k.to_s] = v.to_s }
81
+ (extra_headers || {}).each { |k, v| h[k.to_s] = v.to_s }
82
+ h
83
+ end
84
+
85
+ def build_httparty_options(headers, body)
86
+ options = {
87
+ headers: headers,
88
+ open_timeout: @config.open_timeout,
89
+ read_timeout: @config.read_timeout,
90
+ follow_redirects: false,
91
+ format: :plain,
92
+ }
93
+ options[:write_timeout] = @config.write_timeout if @config.write_timeout
94
+ options[:verify] = @config.verify_ssl_certs
95
+ options[:ca_path] = @config.ca_bundle_path if @config.ca_bundle_path
96
+ options[:body] = body if body
97
+
98
+ if @config.proxy
99
+ proxy_uri = URI.parse(@config.proxy)
100
+ options[:http_proxyaddr] = proxy_uri.host
101
+ options[:http_proxyport] = proxy_uri.port
102
+ options[:http_proxyuser] = proxy_uri.user if proxy_uri.user
103
+ options[:http_proxypass] = proxy_uri.password if proxy_uri.password
104
+ end
105
+ options
106
+ end
107
+
108
+ def handle_response(response, method:, path:)
109
+ status = response.code
110
+ body = response.body
111
+ headers = normalize_headers(response.headers)
112
+ request_id = headers["x-request-id"] || headers["request-id"]
113
+
114
+ if status.between?(200, 299)
115
+ return nil if body.nil? || body.empty? || status == 204
116
+
117
+ return JSONAPI.parse(body)
118
+ end
119
+
120
+ parsed = begin
121
+ JSONAPI.parse(body)
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ klass = Latitude::API.error_class_for_status(status)
126
+ raise klass.new(
127
+ nil,
128
+ http_status: status,
129
+ http_headers: headers,
130
+ http_body: body,
131
+ json_body: parsed,
132
+ request_id: request_id,
133
+ )
134
+ end
135
+
136
+ def normalize_headers(headers)
137
+ return {} if headers.nil?
138
+
139
+ result = {}
140
+ headers.each { |k, v| result[k.to_s.downcase] = v.is_a?(Array) ? v.first : v }
141
+ result
142
+ end
143
+
144
+ def retriable_status?(status)
145
+ RETRIABLE_STATUSES.include?(status.to_i)
146
+ end
147
+
148
+ def backoff_delay(attempt, response)
149
+ if response && response.code.to_i == 429
150
+ retry_after = extract_retry_after(response)
151
+ return retry_after.to_f if retry_after
152
+ end
153
+ base = @config.initial_network_retry_delay
154
+ max = @config.max_network_retry_delay
155
+ delay = base * (2**(attempt - 1))
156
+ jitter = delay * (rand * 0.5)
157
+ [max, delay + jitter].min
158
+ end
159
+
160
+ def extract_retry_after(response)
161
+ headers = normalize_headers(response.headers)
162
+ header = headers["retry-after"]
163
+ return Integer(header) if header && header.to_s.match?(/\A\d+\z/)
164
+
165
+ body = response.body
166
+ return nil if body.nil? || body.empty?
167
+
168
+ parsed = begin
169
+ JSONAPI.parse(body)
170
+ rescue StandardError
171
+ nil
172
+ end
173
+ errors = parsed.is_a?(Hash) ? Array(parsed["errors"]) : []
174
+ errors.each do |e|
175
+ meta = e["meta"] || {}
176
+ return Integer(meta["retry_after"]) if meta["retry_after"]
177
+ end
178
+ nil
179
+ end
180
+
181
+ def user_agent
182
+ parts = ["Latitude/v1 RubyBindings/#{VERSION}"]
183
+ parts << "(#{RUBY_ENGINE} #{RUBY_VERSION}; #{RUBY_PLATFORM})"
184
+ if (info = @config.app_info)
185
+ app = +"#{info[:name]}"
186
+ app << "/#{info[:version]}" if info[:version]
187
+ app << " (#{info[:url]})" if info[:url]
188
+ parts << app
189
+ end
190
+ parts.join(" ")
191
+ end
192
+
193
+ def log_response(method, path, response, duration_ms)
194
+ logger = @config.logger
195
+ return unless logger
196
+
197
+ logger.info("latitude-api #{method.to_s.upcase} #{path} status=#{response.code} dur=#{duration_ms}ms")
198
+ return unless logger.debug?
199
+
200
+ parsed = begin
201
+ JSON.parse(response.body.to_s)
202
+ rescue StandardError
203
+ response.body
204
+ end
205
+ redacted = Util.redact(parsed, @config.sensitive_fields)
206
+ logger.debug("latitude-api response: #{redacted.inspect[0..4096]}")
207
+ end
208
+
209
+ def log_exception(exception, method, path, attempt)
210
+ logger = @config.logger
211
+ return unless logger
212
+
213
+ logger.warn("latitude-api #{method.to_s.upcase} #{path} attempt=#{attempt} error=#{exception.class}: #{exception.message}")
214
+ end
215
+ end
216
+ end
217
+ end