ecoportal-api 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ module Ecoportal
2
+ module API
3
+ module Common
4
+ class Client
5
+ module WithRetry
6
+ DELAY_REQUEST_RETRY = 5
7
+ RETRY_ATTEMPTS = 5
8
+ HANDLED_CONNECTION_ERRORS = [
9
+ HTTP::ConnectionError,
10
+ IOError
11
+ ].freeze
12
+
13
+ include Ecoportal::API::Common::Client::ElasticApmIntegration
14
+
15
+ private
16
+
17
+ # Helper to ensure unexpected server errors do not bring
18
+ # client scripts immediately down
19
+ # @note it manages limited range of errors, the rest
20
+ # are not handled.
21
+ def with_retry(
22
+ attempts = retry_attemps,
23
+ delay = delay_request_retry,
24
+ error_safe: true,
25
+ &block
26
+ )
27
+ response = nil
28
+
29
+ attempts.times do |i|
30
+ remaining = attempts - i - 1
31
+
32
+ response = with_connection_error_handling(
33
+ remaining,
34
+ error_safe: error_safe,
35
+ callback: block
36
+ ) do
37
+ block.call
38
+ end
39
+
40
+ return response unless some_unexpected_error?(response)
41
+
42
+ # handle server errors (5xx) & server bugs (i.e. empty body)
43
+ msg = "re-attempting (remaining: "
44
+ msg << "#{remaining} attempts out of #{attempts})"
45
+ log(:debug) { msg }
46
+
47
+ log_unexpected_server_error(response)
48
+
49
+ msg = "Got server error (#{response.status}): #{response.body}\n"
50
+ msg << "Going to retry (##{i} of #{attempts})"
51
+ log(:debug) { msg }
52
+
53
+ sleep(delay) if i < attempts
54
+ end
55
+
56
+ response
57
+ end
58
+
59
+ def with_connection_error_handling(remaining, callback:, error_safe: true)
60
+ yield
61
+ rescue *handled_connection_errors => err
62
+ raise unless error_safe && remaining.positive?
63
+
64
+ msg = "Got #{err.class}: #{err.message}"
65
+ log(:debug) { msg }
66
+
67
+ with_retry(remaining, error_safe: error_safe, &callback)
68
+ end
69
+
70
+ # Add here other connection errors
71
+ def handled_connection_errors
72
+ self.class::HANDLED_CONNECTION_ERRORS
73
+ end
74
+
75
+ def retry_attemps
76
+ self.class::RETRY_ATTEMPTS
77
+ end
78
+
79
+ def delay_request_retry
80
+ self.class::DELAY_REQUEST_RETRY
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,4 +1,12 @@
1
1
  require 'http'
2
+
3
+ require 'ecoportal/api/common/client/error'
4
+ require 'ecoportal/api/common/client/elastic_apm_integration'
5
+ require 'ecoportal/api/common/client/rate_throttling'
6
+ require 'ecoportal/api/common/client/throughput'
7
+ require 'ecoportal/api/common/client/time_out'
8
+ require 'ecoportal/api/common/client/with_retry'
9
+
2
10
  module Ecoportal
3
11
  module API
4
12
  module Common
@@ -16,9 +24,10 @@ module Ecoportal
16
24
  # @attr_reader logger [Logger] the logger.
17
25
  # @attr_reader host [String] the remote target server.
18
26
  class Client
19
- include Common::ElasticApmIntegration
20
- DELAY_REQUEST_RETRY = 5
21
- RETRY_ATTEMPTS = 5
27
+ include WithRetry
28
+ include RateThrottling
29
+
30
+ DEFAULT_HOST = 'live.ecoportal.com'.freeze
22
31
 
23
32
  attr_accessor :logger
24
33
  attr_reader :host
@@ -28,40 +37,32 @@ module Ecoportal
28
37
  # @param version [String] it is part of the base url and will determine the api version we query against.
29
38
  # @param host [String] api server domain.
30
39
  # @param logger [Logger] an object with `Logger` interface to generate logs.
31
- # @param response_logging [Boolean] whether or not batch responses should be logged
40
+ # @param deep_logging [Boolean] whether or not batch responses should be logged
32
41
  # @return [Client] an object that holds the configuration of the api connection.
33
- def initialize(api_key:, version: "v1", host: "live.ecoportal.com", logger: nil, response_logging: false)
34
- @version = version
35
- @api_key = api_key
36
- @logger = logger
37
- @host = host
38
- @response_logging_enabled = response_logging
42
+ def initialize(api_key:, version: "v1", host: DEFAULT_HOST, logger: nil, deep_logging: false)
43
+ @version = version
44
+ @api_key = api_key
45
+ @logger = logger
46
+ @host = host
47
+ @deep_logging = deep_logging
39
48
 
40
49
  if host.match(/^localhost|^127\.0\.0\.1/)
41
50
  @base_uri = "http://#{host}/api/"
42
51
  else
43
52
  @base_uri = "https://#{host}/api/"
44
53
  end
45
- log(:info) { "#{version} client initialized pointing at #{host}" }
54
+
55
+ if deep_logging?
56
+ log(:debug) {
57
+ "#{version} client initialized pointing at #{host}"
58
+ }
59
+ end
46
60
 
47
61
  return unless @api_key.nil? || @api_key.match(/\A\W*\z/)
48
62
 
49
63
  log(:error) { "Api-key missing!" }
50
64
  end
51
65
 
52
- # Logger interface.
53
- # @example:
54
- # log(:info) {"General information on what's going on"}
55
- # log(:warn) {"This is a warning that something is likely to have gone amiss"}
56
- # log(:error) {"Something went wrong"}
57
- # log(:fatal) {"An unrecoverable error has happend"}
58
- # @param level [Symbol] the level that the message should be logged.
59
- # @yield [] generates the message.
60
- # @yieldreturn [String] the generated message.
61
- def log(level, &block)
62
- logger&.send(level, &block)
63
- end
64
-
65
66
  # Sends an http `GET` request against the api version using `path` to complete the base url,
66
67
  # and adding the key_value pairs of `params` in the http _header_.
67
68
  # @param path [String] the tail that completes the url of the request.
@@ -152,7 +153,7 @@ module Ecoportal
152
153
  # @param path [String] the tail that completes the url of the request.
153
154
  # @return [String] the final url.
154
155
  def url_for(path)
155
- @base_uri+@version+path
156
+ "#{@base_uri}#{@version}#{path}"
156
157
  end
157
158
 
158
159
  private
@@ -161,71 +162,45 @@ module Ecoportal
161
162
  raise "Expected block" unless block_given?
162
163
 
163
164
  start_time = Time.now.to_f
164
- log(:info) { "#{method} #{url_for(path)}" }
165
- log(:debug) { "Data: #{JSON.pretty_generate(data)}" }
166
165
 
167
- with_retry(&block).tap do |result|
166
+ if deep_logging?
167
+ log(:debug) { "#{method} #{url_for(path)}" }
168
+ log(:debug) { "Data: #{JSON.pretty_generate(data)}" }
169
+ end
170
+
171
+ with_retry do
172
+ rate_throttling(&block)
173
+ end.tap do |result|
174
+ next unless deep_logging?
175
+
168
176
  end_time = Time.now.to_f
169
- log(result.success?? :info : :warn) {
177
+
178
+ log(:debug) {
170
179
  "Took %.2fs, Status #{result.status}" % (end_time - start_time) # rubocop:disable Style/FormatString
171
180
  }
172
181
 
173
- next unless @response_logging_enabled
174
-
175
- log(result.success?? :debug : :warn) {
182
+ log(:debug) {
176
183
  "Response: #{JSON.pretty_generate(result.body)}"
177
184
  }
178
185
  end
179
186
  end
180
187
 
181
- # Helper to ensure unexpected server errors do not bring client scripts immediately down
182
- def with_retry(attempts = RETRY_ATTEMPTS, delay = DELAY_REQUEST_RETRY, error_safe: true, &block)
183
- response = nil
184
- attempts.times do |i|
185
- remaining = attempts - i - 1
186
-
187
- begin
188
- response = block.call
189
- rescue HTTP::ConnectionError => e
190
- raise unless error_safe && remaining.positive?
191
- log(:error) { "Got connection error: #{e.message}" }
192
- response = with_retry(remaining, error_safe: error_safe, &block)
193
- rescue IOError => e
194
- raise unless error_safe && remaining.positive?
195
- log(:error) { "Got IO error: #{e.message}" }
196
- response = with_retry(remaining, error_safe: error_safe, &block)
197
- end
198
-
199
- return response unless some_unexpected_error?(response)
200
-
201
- puts "re-attempting (remaining: #{remaining} attempts out of #{attempts})"
202
-
203
- log_unexpected_server_error(response)
204
-
205
- msg = "Got server error (#{response.status}): #{response.body}\n"
206
- msg += "Going to retry (#{i} out of #{attempts})"
207
- log(:error) { msg }
208
-
209
- sleep(delay) if i < attempts
210
- end
211
- response
212
- end
213
-
214
- # Sometimes response body is wrong but status code
215
- # doesn't reflect. Let it retry
216
- def some_unexpected_error?(response)
217
- unexpected_server_error?(response.status) ||
218
- unexpected_body?(response)
188
+ def deep_logging?
189
+ @deep_logging
219
190
  end
220
191
 
221
- def unexpected_body?(response)
222
- response.body.nil?.tap do |wrong|
223
- next unless wrong
224
-
225
- msg = "Received non json body in response "
226
- msg << "(#{response.src_body.class}):\n #{response.src_body}"
227
- puts
228
- end
192
+ # Logger interface.
193
+ # @example:
194
+ # log(:info) {"General information on what's going on"}
195
+ # log(:warn) {"This is a warning that something is likely to have gone amiss"}
196
+ # log(:error) {"Something went wrong"}
197
+ # log(:fatal) {"An unrecoverable error has happend"}
198
+ # @param level [Symbol] the level that the message should be logged.
199
+ # @yield [] generates the message.
200
+ # @yieldreturn [String] the generated message.
201
+ def log(level, &block)
202
+ puts "(#{level}) #{yield}"
203
+ logger&.send(level, &block)
229
204
  end
230
205
  end
231
206
  end
@@ -10,8 +10,6 @@ require 'ecoportal/api/common/hash_diff'
10
10
  require 'ecoportal/api/common/base_model'
11
11
  require 'ecoportal/api/common/doc_helpers'
12
12
  require 'ecoportal/api/common/logging'
13
- require 'ecoportal/api/common/elastic_apm_integration'
14
- require 'ecoportal/api/common/time_out'
15
13
  require 'ecoportal/api/common/client'
16
14
  require 'ecoportal/api/common/response'
17
15
  require 'ecoportal/api/common/wrapped_response'
@@ -0,0 +1,115 @@
1
+ module Ecoportal
2
+ module API
3
+ class V1
4
+ class Job
5
+ class Awaiter
6
+ include Common::Client::TimeOut
7
+
8
+ DELAY_STATUS_CHECK = 4
9
+ MIN_STATUS_CHECK = 2
10
+
11
+ TIMEOUT_APPROACH = :conservative # adaptative timeout
12
+ TIMEOUT_FALLBACK = :min
13
+
14
+ attr_reader :job, :job_id, :total
15
+ attr_accessor :timeout_approach
16
+
17
+ def initialize(job, job_id:, total:)
18
+ @job = job
19
+ @job_id = job_id
20
+ @total = total
21
+ @checked = false
22
+ self.timeout_approach = self.class::TIMEOUT_APPROACH
23
+ end
24
+
25
+ # Allows to preserve the learned throughput
26
+ def new(**kargs)
27
+ self.class.new(job, **kargs).tap do |out|
28
+ out.throughput = throughput
29
+ end
30
+ end
31
+
32
+ def await_completion! # rubocop:disable Metrics/AbcSize
33
+ max_timeout = timeout_for(total, approach: timeout_approach)
34
+
35
+ # timeout library is evil. So we make poor-man timeout.
36
+ # https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
37
+ before = Time.now
38
+ delay_status_check = nil
39
+
40
+ loop do
41
+ status = job.status(job_id)
42
+ waited = Time.now - before
43
+
44
+ adapted = waited
45
+ adapted = waited - (delay_status_check / 2) if delay_status_check
46
+ ratio = throughput!(adapted, count: status.progress)
47
+
48
+ break status if status.complete?(total)
49
+
50
+ pending = status.pending(total)
51
+ left = max_timeout - waited
52
+
53
+ timeout!(status, timeout: max_timeout) unless left.positive?
54
+
55
+ delay_status_check = status_check_in(pending, timeout_in: left)
56
+
57
+ msg = " ... Awaiting #{delay_status_check} s. -- "
58
+ msg << " TimeOut: #{left.round(2)} s. "
59
+ msg << "(job '#{job_id}') "
60
+ msg << "Done: #{status.progress} (est. #{ratio} rec/s) "
61
+ msg << " \r"
62
+
63
+ print msg
64
+ $stdout.flush
65
+
66
+ sleep(delay_status_check)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def timeout!(status, timeout:)
73
+ self.timeout_approach = self.class::TIMEOUT_FALLBACK
74
+
75
+ msg = "Job '#{job_id}' not complete (size: #{total}).\n"
76
+ msg << " Timed out after #{timeout} seconds.\n"
77
+ msg << " Current status: #{status}"
78
+
79
+ raise API::Errors::TimeOut, msg
80
+ end
81
+
82
+ def checked?
83
+ @checked
84
+ end
85
+
86
+ def status_check_in(pending, timeout_in:)
87
+ unless checked?
88
+ @checked = true
89
+ return min_delay_status_check
90
+ end
91
+
92
+ return default_delay_status_check if around_min_throughput?
93
+
94
+ eta = eta_for(pending, approach: :optimistic)
95
+ check_in_max = [eta, timeout_in].min * 0.90
96
+
97
+ check_in_best = check_in_max / 2.0
98
+ default_5 = default_delay_status_check * 5
99
+
100
+ top_check = [default_5, check_in_best].min.ceil
101
+ [top_check, min_delay_status_check].max
102
+ end
103
+
104
+ def default_delay_status_check
105
+ self.class::DELAY_STATUS_CHECK
106
+ end
107
+
108
+ def min_delay_status_check
109
+ self.class::MIN_STATUS_CHECK
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,42 @@
1
+ module Ecoportal
2
+ module API
3
+ class V1
4
+ class Job
5
+ class Status
6
+ attr_reader :id, :progress
7
+
8
+ def initialize(id, complete, errored, progress)
9
+ @id = id
10
+ @complete = complete
11
+ @errored = errored
12
+ @progress = progress
13
+ end
14
+
15
+ def complete?(total = nil)
16
+ return @complete if total.nil?
17
+
18
+ progress >= total
19
+ end
20
+
21
+ def pending(total)
22
+ return 1 unless total
23
+ return 0 if total <= progress
24
+
25
+ total - progress
26
+ end
27
+
28
+ def errored?
29
+ @errored
30
+ end
31
+
32
+ def to_s
33
+ msg = complete? ? "Completed" : "In progress"
34
+ msg = "Errored" if errored?
35
+ msg << " with #{progress} done."
36
+ msg
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,131 @@
1
+ require 'ecoportal/api/v1/job/status'
2
+ require 'ecoportal/api/v1/job/awaiter'
3
+
4
+ module Ecoportal
5
+ module API
6
+ class V1
7
+ class Job
8
+ attr_reader :client, :person_class
9
+
10
+ # @param client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
11
+ # @return [People] an instance object ready to make people api requests.
12
+ def initialize(client, person_class:)
13
+ @client = client
14
+ @person_class = person_class
15
+ @created = false
16
+ end
17
+
18
+ # Allows to preserve the learned througoutput
19
+ def new
20
+ self.class.new(client, person_class: person_class).tap do |out|
21
+ out.awaiter = @awaiter
22
+ end
23
+ end
24
+
25
+ def created?
26
+ @created
27
+ end
28
+
29
+ # @return [Ecoportal::API::Common::Response] the results of the batch job
30
+ def batch(recover: false)
31
+ raise_if_already_launched! unless recover
32
+
33
+ @operation ||= Common::BatchOperation.new(
34
+ "/people",
35
+ person_class,
36
+ logger: client.logger
37
+ )
38
+
39
+ yield operation unless recover
40
+
41
+ total = operation.count
42
+ job_id = create(operation, recover: recover)
43
+ stat = awaiter(
44
+ job_id: job_id,
45
+ total: total
46
+ ).await_completion!
47
+
48
+ job_result(job_id, operation) if stat&.complete?(total)
49
+ end
50
+
51
+ def status(job_id)
52
+ response = client.get("/people/job/#{CGI.escape(job_id)}/status")
53
+ body = response && body_data(response.body)
54
+
55
+ msg = "Status error (#{response.status}) - "
56
+ msg << "Errors: #{body}"
57
+ raise msg unless response.success?
58
+
59
+ Status.new(*body.values_at(*%w[id complete errored progress]))
60
+ end
61
+
62
+ protected
63
+
64
+ attr_writer :awaiter
65
+
66
+ def awaiter(job_id:, total:, preserve: true)
67
+ return @awaiter = Awaiter.new(self, job_id: job_id, total: total) unless preserve && @awaiter
68
+
69
+ @awaiter = @awaiter.new(job_id: job_id, total: total)
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :operation
75
+
76
+ def operations
77
+ @operations ||= {}
78
+ end
79
+
80
+ def job_operation(job_id)
81
+ operations[job_id]
82
+ end
83
+
84
+ def raise_if_already_launched!
85
+ return unless created?
86
+
87
+ msg = "Missusage: job was already created."
88
+ msg << " Can't call batch more than once"
89
+ raise msg
90
+ end
91
+
92
+ # @return [Ecoportal::API::Common::Response] the results of the batch job
93
+ def job_result(job_id, operation)
94
+ client.get("/people/job/#{CGI.escape(job_id)}").tap do |response|
95
+ operation.process_response(response)
96
+ end
97
+ end
98
+
99
+ # @return [String] the `id` of the created batch job
100
+ def create(operation, recover: false)
101
+ raise_if_already_launched! unless recover
102
+
103
+ job_id = nil
104
+
105
+ client.post("/people/job", data: operation.as_json).tap do |response|
106
+ job_id = body_data(response.body)["id"] if response.success?
107
+
108
+ next if job_id
109
+
110
+ msg = "Could not create job - Error (#{response.status}): "
111
+ msg << body_data(response.body).to_s
112
+ raise msg
113
+ end
114
+
115
+ if job_id
116
+ @created = true
117
+ operations[job_id] = operation
118
+ end
119
+
120
+ job_id
121
+ end
122
+
123
+ # Hook for other api versions to obtain the raw data of a response
124
+ # @note this was introduced to allow `v2` to reuse this class
125
+ def body_data(body)
126
+ body
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -6,11 +6,8 @@ module Ecoportal
6
6
  class People
7
7
  extend Common::BaseClass
8
8
  include Common::DocHelpers
9
- include Common::TimeOut
10
9
  include Enumerable
11
10
 
12
- DELAY_STATUS_CHECK = 5
13
-
14
11
  class_resolver :person_class, "Ecoportal::API::V1::Person"
15
12
 
16
13
  attr_reader :client
@@ -143,7 +140,7 @@ module Ecoportal
143
140
  # @param job_mode [Boolean] whether or not it should use batch jobs
144
141
  # @return [Ecoportal::API::Common::Response] the results of the batch
145
142
  def batch(job_mode: true, &block)
146
- return job(&block) if job_mode
143
+ return job.batch(&block) if job_mode
147
144
 
148
145
  operation = Common::BatchOperation.new(
149
146
  "/people",
@@ -159,33 +156,11 @@ module Ecoportal
159
156
  end
160
157
  end
161
158
 
162
- # @return [Ecoportal::API::Common::Response] the results of the batch job
163
- def job
164
- operation = Common::BatchOperation.new(
165
- "/people",
166
- person_class,
167
- logger: client.logger
168
- )
169
-
170
- yield operation
171
-
172
- total = operation.count
173
- timeout = timeout_for(total)
174
-
175
- job_id = create_job(operation)
176
- status = wait_for_job_completion(job_id, timeout: timeout, total: total)
159
+ # @return [Ecoportal::API::V1::Job]
160
+ def job(preserve_stats: true)
161
+ return @last_job = Job.new(client, person_class: person_class) unless preserve_stats && @last_job
177
162
 
178
- # @todo
179
- # if total == status.progress
180
- if status&.complete?(total)
181
- job_result(job_id, operation)
182
- else
183
- msg = "Job '#{job_id}' not complete (size: #{total}).\n"
184
- msg << " Probably timeout after #{timeout} seconds.\n"
185
- msg << " Current status: #{status}"
186
-
187
- raise API::Errors::TimeOut, msg
188
- end
163
+ @last_job = @last_job.new
189
164
  end
190
165
 
191
166
  # Creates a new `Person` object.
@@ -196,59 +171,6 @@ module Ecoportal
196
171
 
197
172
  private
198
173
 
199
- def job_status(job_id)
200
- response = client.get("/people/job/#{CGI.escape(job_id)}/status")
201
- body = response && body_data(response.body)
202
-
203
- msg = "Status error (#{response.status}) - "
204
- msg << "Errors: #{body}"
205
- raise msg unless response.success?
206
-
207
- JobStatus.new(*body.values_at(*%w[id complete errored progress]))
208
- end
209
-
210
- # @return [Ecoportal::API::Common::Response] the results of the batch job
211
- def job_result(job_id, operation)
212
- client.get("/people/job/#{CGI.escape(job_id)}").tap do |response|
213
- operation.process_response(response)
214
- end
215
- end
216
-
217
- def wait_for_job_completion(job_id, timeout:, total:)
218
- # timeout library is evil. So we make poor-man timeout.
219
- # https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
220
- before = Time.now
221
-
222
- loop do
223
- status = job_status(job_id)
224
- break status if status.complete?(total)
225
-
226
- left = (before + timeout) - Time.now
227
- break status unless left.positive?
228
- # break status if Time.now >= before + timeout
229
-
230
- msg = " ... Await job "
231
- msg << "('#{job_id}'; done: #{status.progress}): "
232
- msg << "#{left.ceil} sec. \r"
233
-
234
- print msg
235
- $stdout.flush
236
-
237
- sleep(DELAY_STATUS_CHECK)
238
- status
239
- end
240
- end
241
-
242
- # @return [String] the `id` of the created batch job
243
- def create_job(operation)
244
- job_id = nil
245
- client.post("/people/job", data: operation.as_json).tap do |response|
246
- job_id = body_data(response.body)["id"] if response.success?
247
- raise "Could not create job - Error (#{response.status}): #{body_data(response.body)}" unless job_id
248
- end
249
- job_id
250
- end
251
-
252
174
  # Hook for other api versions to obtain the raw data of a response
253
175
  # @note this was introduced to allow `v2` to reuse this class
254
176
  def body_data(body)
@@ -262,3 +184,4 @@ end
262
184
  require 'ecoportal/api/v1/schema_field_value'
263
185
  require 'ecoportal/api/v1/person_details'
264
186
  require 'ecoportal/api/v1/person'
187
+ require 'ecoportal/api/v1/job'