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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cc0547ca88715ad6b0f2d3bc7ed4661bab0f9259117ce28aa5d43aab0761d02
4
- data.tar.gz: 1e109b427a0e71bd91edf1d4e9ce6e7cf9ac2df998662c97746df30b8e07998b
3
+ metadata.gz: 28e9d05f838bf52d038a6d9533a8bcbb98f7b66b5ffa6fa7e99a4c30b2d0e486
4
+ data.tar.gz: a6586e72297c5e109ed4f2e8a04beb32e1e435ea3eb0332803b4ba9fd91e8fb3
5
5
  SHA512:
6
- metadata.gz: 44eba3743e30beff3c04e71b1191900b8477d81af1371f1a5003cb6847384e384d535e51390e4f8db39e8aa81a0cbf7fc200a87ac18036c45e56530b72b09342
7
- data.tar.gz: 1749045310b0748e8935027cd0d1b9d69e7a84c4534b27017bd1581848d579c6359e59dedfe19e3cbe451038e2a4c70fb82150a01ca4c9260eadde64bfac5a92
6
+ metadata.gz: b8ecb9337ed3b2f11913ae5823bdb3b4771d1bcb364a50afc68bba38518dff39fd7986de641a4030030cfeeb0019a5960d9ac2440a10ee2bb49ed15aad77f0a8
7
+ data.tar.gz: 1f3ca204cb6c8b4a6cab2396285bf50644cb955f1120c15253f1d81ac1663bf2874f2b3706f5dedcfbdc34bf7c67d5a0520f1e62c0e8891259b337d92394cbc3
data/CHANGELOG.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [0.10.3] - 2024-09-xx
5
+ ## [0.10.5] - 2024-10-xx
6
6
 
7
7
  ### Added
8
8
 
@@ -10,6 +10,29 @@ All notable changes to this project will be documented in this file.
10
10
 
11
11
  ### Fixed
12
12
 
13
+ ## [0.10.4] - 2024-10-15
14
+
15
+ ### Added
16
+
17
+ - `Ecoportal::API::Common::Client::RateThrottling`
18
+ - See: <https://github.com/zombocom/rate_throttle_client?tab=readme-ov-file#ratethrottleclient>
19
+ - Add adapter to the RateLimit, may it be present.
20
+ - **Default:** `:proportional_decrease`
21
+
22
+ ### Changed
23
+
24
+ - `Ecoportal::API::Common::Client::TimeOut`
25
+ - Const `MIN_SIZE` changed from `10` to `5` to speed up small requests.
26
+ - Full revamp of `Job` endpoint
27
+
28
+ ## [0.10.3] - 2024-10-01
29
+
30
+ ### Changed
31
+
32
+ - refactored Client
33
+ - initialized with `deep_logging` named argumnet
34
+ - previously `response_logging`
35
+
13
36
  ## [0.10.2] - 2024-09-27
14
37
 
15
38
  ### Added
@@ -13,7 +13,7 @@ Gem::Specification.new do |spec|
13
13
  'oscar@ecoportal.co.nz'
14
14
  ]
15
15
 
16
- spec.summary = %q{A collection of helpers for interacting with the ecoPortal MS's various APIs}
16
+ spec.summary = "A collection of helpers for interacting with the ecoPortal MS's various APIs"
17
17
  spec.homepage = "https://www.ecoportal.com"
18
18
  spec.licenses = %w[MIT]
19
19
 
@@ -28,17 +28,18 @@ Gem::Specification.new do |spec|
28
28
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
29
29
  spec.require_paths = ['lib']
30
30
 
31
- spec.add_development_dependency 'pry' , '~> 0.14'
31
+ spec.add_development_dependency 'pry', '~> 0.14'
32
32
  spec.add_development_dependency 'rake', '>= 13.0.3', '< 14'
33
33
  spec.add_development_dependency 'redcarpet', '>= 3.6.0', '< 4'
34
34
  spec.add_development_dependency 'rspec', '>= 3.12.0', '< 4'
35
35
  spec.add_development_dependency 'rubocop', '~> 1'
36
36
  spec.add_development_dependency 'rubocop-rake', '~> 0'
37
- spec.add_development_dependency 'yard', '~> 0.9'
37
+ spec.add_development_dependency 'yard', '~> 0.9'
38
38
 
39
39
  spec.add_dependency 'dotenv', '~> 3'
40
40
  spec.add_dependency 'elastic-apm', '>= 4.7', "< 5"
41
41
  spec.add_dependency 'http', '~> 5.1', "< 6"
42
+ spec.add_dependency 'rate_throttle_client', '~> 0.1'
42
43
  end
43
44
 
44
45
  # rubocop:enable Gemspec/DevelopmentDependencies
@@ -4,11 +4,12 @@ module Ecoportal
4
4
  class BatchOperation
5
5
  include Common::DocHelpers
6
6
 
7
- def initialize(base_path, wrapper, logger: nil)
8
- @base_path = base_path
9
- @wrapper = wrapper
10
- @operations = []
11
- @logger = logger
7
+ def initialize(base_path, wrapper, logger: nil, deep_logging: false)
8
+ @base_path = base_path
9
+ @wrapper = wrapper
10
+ @operations = []
11
+ @logger = logger
12
+ @deep_logging = deep_logging
12
13
  end
13
14
 
14
15
  def count
@@ -25,11 +26,12 @@ module Ecoportal
25
26
 
26
27
  def process_response(response)
27
28
  unless response.success?
28
- log(:error) { "Total failure in batch operation." }
29
- raise "Total failure in batch operation"
29
+ msg = "Error: total failure in batch operation."
30
+ log(:debug) { msg }
31
+ raise msg
30
32
  end
31
33
 
32
- log(:info) { "Processing batch responses" }
34
+ log(:debug) { "Processing batch responses" } if deep_logging?
33
35
 
34
36
  body_data(response.body).each.with_index do |subresponse, idx|
35
37
  status = subresponse["status"]
@@ -110,14 +112,19 @@ module Ecoportal
110
112
  end
111
113
 
112
114
  def log_batch_response(operation, response)
113
- log(:info) { "BATCH #{operation[:method]} #{operation[:path]}" }
114
- log(:info) { "Status #{response.status}" }
115
+ return unless deep_logging?
115
116
 
116
- level = response.success?? :debug : :warn
117
- log(level) { "Response: #{JSON.pretty_generate(response.body)}" }
117
+ log(:debug) { "BATCH #{operation[:method]} #{operation[:path]}" }
118
+ log(:debug) { "Status #{response.status}" }
119
+ log(:debug) { "Response: #{JSON.pretty_generate(response.body)}" }
120
+ end
121
+
122
+ def deep_logging?
123
+ @deep_logging
118
124
  end
119
125
 
120
126
  def log(level, &block)
127
+ puts "(#{level}) #{yield}"
121
128
  @logger&.send(level, &block)
122
129
  end
123
130
  end
@@ -0,0 +1,111 @@
1
+ require 'elastic-apm'
2
+ module Ecoportal
3
+ module API
4
+ module Common
5
+ class Client
6
+ module ElasticApmIntegration
7
+ include Ecoportal::API::Common::Client::Error::Checks
8
+
9
+ APM_SERVICE_NAME = 'ecoportal-api-gem'.freeze
10
+
11
+ # Log only errors that are only server's responsibility
12
+ def log_unexpected_server_error(response)
13
+ msg = "Expecting Ecoportal::API::Common::Response. Given: #{response.class}"
14
+ raise msg unless response.is_a?(Common::Response)
15
+
16
+ return unless elastic_apm_service
17
+ return unless unexpected_server_error_code?(response.status)
18
+ return unless ElasticAPM.running?
19
+
20
+ ElasticAPM.report(
21
+ Ecoportal::API::Common::Client::Error::UnexpectedServerError.new(
22
+ response.body,
23
+ code: response.status
24
+ )
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ # finalizer to stop the agent
31
+ close_elastic_apm = proc do |_id|
32
+ next unless ElasticAPM.running?
33
+
34
+ puts "Stopping ElasticAPM service"
35
+ ElasticAPM.stop
36
+ rescue StandardError
37
+ # Silent
38
+ end
39
+
40
+ ObjectSpace.define_finalizer("ElasticAPM", close_elastic_apm)
41
+
42
+ def elastic_apm_service
43
+ return false if @disable_apm
44
+
45
+ ElasticAPM.start(**elastic_apm_options) unless ElasticAPM.running?
46
+ rescue StandardError => err
47
+ @disable_apm = true
48
+ puts "ElasticAPM services not available: #{err}"
49
+ end
50
+
51
+ def elastic_apm_options
52
+ {
53
+ service_name: APM_SERVICE_NAME,
54
+ server_url: elastic_apm_url,
55
+ secret_token: elastic_apm_key,
56
+ environment: environment,
57
+ # http_compression: false,
58
+ transaction_sample_rate: 0.1,
59
+ transaction_max_spans: 100,
60
+ span_frames_min_duration: "5ms"
61
+ }.tap do |options|
62
+ # next unless false
63
+
64
+ options.merge!({
65
+ log_level: Logger::DEBUG,
66
+ log_path: File.join(__dir__, "elastic_apm.log")
67
+ })
68
+ end
69
+ end
70
+
71
+ def elastic_apm_url
72
+ @elastic_apm_url ||= "https://".tap do |url|
73
+ url << elastic_apm_account_id.to_s
74
+ url << ".#{elastic_apm_base_url}"
75
+ url << ":#{elastic_apm_port}"
76
+ end
77
+ end
78
+
79
+ def elastic_apm_key
80
+ @elastic_apm_key ||= ENV['ELASTIC_APM_KEY']
81
+ end
82
+
83
+ def elastic_apm_account_id
84
+ @elastic_apm_account_id ||= ENV['ELASTIC_APM_ACCOUNT_ID']
85
+ end
86
+
87
+ def elastic_apm_base_url
88
+ @elastic_apm_base_url ||= "apm.#{elastic_apm_region}.aws.cloud.es.io"
89
+ end
90
+
91
+ def elastic_apm_region
92
+ @elastic_apm_region ||= ENV['ELASTIC_APM_REGION'] || "ap-southeast-2"
93
+ end
94
+
95
+ def elastic_apm_port
96
+ @elastic_apm_port ||= ENV['ELASTIC_APM_PORT'] || "443"
97
+ end
98
+
99
+ def environment
100
+ @environment ||= "unknown".tap do |value|
101
+ next unless instance_variable_defined?(:@host)
102
+ next unless (env = @host.gsub(".ecoportal.com", ''))
103
+
104
+ value.clear << env
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,39 @@
1
+ module Ecoportal
2
+ module API
3
+ module Common
4
+ class Client
5
+ module Error
6
+ module Checks
7
+ private
8
+
9
+ def unexpected_server_error_code?(code)
10
+ return true unless code
11
+ return true if (code >= 500) && (code <= 599)
12
+
13
+ code <= 99
14
+ end
15
+
16
+ # Sometimes response body is wrong but status code
17
+ # doesn't reflect. Let it retry
18
+ def some_unexpected_error?(response)
19
+ return true if unexpected_server_error_code?(response.status)
20
+
21
+ unexpected_body?(response)
22
+ end
23
+
24
+ def unexpected_body?(response)
25
+ response.body.nil?.tap do |wrong|
26
+ next unless wrong
27
+
28
+ msg = "Received non json body in response "
29
+ msg << "(#{response.src_body.class}):\n "
30
+ msg << response.src_body
31
+ puts
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,17 @@
1
+ require 'ecoportal/api/common/client/error/checks'
2
+
3
+ module Ecoportal
4
+ module API
5
+ module Common
6
+ class Client
7
+ module Error
8
+ class UnexpectedServerError < StandardError
9
+ def initialize(msg, code:)
10
+ super("Code: #{code} -- Error: #{msg}")
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ require 'rate_throttle_client'
2
+
3
+ module Ecoportal
4
+ module API
5
+ module Common
6
+ class Client
7
+ module RateThrottling
8
+ private
9
+
10
+ def rate_throttling(strategy: nil, &block)
11
+ throttle(strategy).call(&block)
12
+ end
13
+
14
+ def throttle(strategy = nil)
15
+ @throttle ||= {}
16
+
17
+ return @throttle[strategy] if @throttle.key?(strategy)
18
+
19
+ @throttle[strategy] = throttle_class(strategy).new
20
+ end
21
+
22
+ def throttle_class(strategy = default_throttle_strategy)
23
+ case strategy
24
+ when :gradual_decrease
25
+ RateThrottleClient::ExponentialIncreaseGradualDecrease
26
+ when :proportional_decrease
27
+ RateThrottleClient::ExponentialIncreaseProportionalDecrease
28
+ when :remaining_decrease
29
+ # requires RateLimit-Remaining in the response
30
+ RateThrottleClient::ExponentialIncreaseProportionalRemainingDecrease
31
+ else # :backoff
32
+ RateThrottleClient::ExponentialBackoff
33
+ end
34
+ end
35
+
36
+ def default_throttle_strategy
37
+ :proportional_decrease
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,119 @@
1
+ module Ecoportal
2
+ module API
3
+ module Common
4
+ class Client
5
+ class Throughput
6
+ class Stats
7
+ DEFAULT_SD = 1.0
8
+ DUMMY_DIFF = 0.001
9
+ CONFIDENCE = {
10
+ _90: 1645,
11
+ _95: 1.96,
12
+ _98: 2.326,
13
+ _99: 2.576,
14
+ _995: 2.807
15
+ }.freeze
16
+ DEFAULT_CONFI = :_99
17
+
18
+ attr_reader :average, :standard_deviation, :count
19
+ attr_reader :default_margin_error
20
+
21
+ def initialize(average, margin: DEFAULT_CONFI)
22
+ @average = average.to_f
23
+ @standard_deviation = default_sd
24
+ @count = 1
25
+ @default_margin_error = margin || DEFAULT_CONFI
26
+ end
27
+
28
+ def empty?
29
+ count <= 1
30
+ end
31
+
32
+ def max(value = default_margin_error)
33
+ confidence_interval(value).max
34
+ end
35
+
36
+ def min(value = default_margin_error)
37
+ confidence_interval(value).min
38
+ end
39
+
40
+ def record!(value)
41
+ if count == 1
42
+ @average = value
43
+ else
44
+ @average = ((average * count) + value) / (count + 1)
45
+ end
46
+
47
+ @standard_deviation = new_sd(value)
48
+ @count += 1
49
+ average
50
+ end
51
+
52
+ alias_method :<<, :record!
53
+
54
+ # @return [Interval]
55
+ def confidence_interval(value = default_margin_error)
56
+ me = margin_error(value)
57
+ pair = [me * -1, me].map {|err| average + err}
58
+ (pair.first..pair.last)
59
+ end
60
+
61
+ protected
62
+
63
+ def margin_error(value = default_margin_error)
64
+ to_z(value) * (standard_deviation / Math.sqrt(count))
65
+ end
66
+
67
+ private
68
+
69
+ def new_sd(value)
70
+ pre_sum = (sd_per_item ** 2) * count
71
+ cur_sum = pre_sum + ((value - average) ** 2)
72
+ Math.sqrt(cur_sum / count)
73
+ end
74
+
75
+ # (diff^2) * N
76
+ # SD^2 = -------------
77
+ # N - 1
78
+ #
79
+ # (diff^2) * N = SD^2 * (N - 1)
80
+ #
81
+ # --------------
82
+ # diff = 2 / SD^2 * (N - 1)
83
+ # V -------------
84
+ # N
85
+ def sd_per_item
86
+ base = (sd_for_calcs ** 2) * (count - 1) / count.to_f
87
+ Math.sqrt(base)
88
+ end
89
+
90
+ def sd_for_calcs
91
+ return standard_deviation unless empty?
92
+
93
+ standard_deviation + dummy_diff
94
+ end
95
+
96
+ def dummy_diff
97
+ self.class::DUMMY_DIFF
98
+ end
99
+
100
+ def to_z(value)
101
+ value = default_margin_error unless confidences.key?(value)
102
+
103
+ confidences[value]
104
+ end
105
+
106
+ def confidences
107
+ self.class::CONFIDENCE
108
+ end
109
+
110
+ def default_sd
111
+ default = self.class::DEFAULT_SD
112
+ default * average / 10
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,93 @@
1
+ require 'ecoportal/api/common/client/throughput/stats'
2
+
3
+ module Ecoportal
4
+ module API
5
+ module Common
6
+ class Client
7
+ class Throughput
8
+ MIN_THROUGHPUT = 0.2 # records per second
9
+ MAX_THROUGHPUT = 12
10
+
11
+ attr_reader :default_approach
12
+
13
+ def initialize(approach = :conservative)
14
+ @default_approach = approach || :conservative
15
+ end
16
+
17
+ def eta_for(count, approach: default_approach, ratio: self.ratio(approach))
18
+ to_seconds(count, ratio: ratio)
19
+ end
20
+
21
+ def ratio(approach = default_approach)
22
+ case approach
23
+ when :min
24
+ min_throughput
25
+ when :last
26
+ @last_throughput || stats.average
27
+ when :average
28
+ stats.average
29
+ when :optimistic
30
+ [stats.max, max_throughput].min
31
+ else # :conservative
32
+ [stats.min, min_throughput].max
33
+ end.then do |relation|
34
+ next relation if relation < max_throughput
35
+
36
+ max_throughput
37
+ end
38
+ end
39
+
40
+ def record!(secs = nil, count: nil)
41
+ return ratio if secs.to_f.zero? || count.to_i.zero?
42
+
43
+ (count / secs.to_f).round(3).tap do |last|
44
+ stats << @last_throughput = last
45
+ end
46
+ end
47
+
48
+ # Keeps track of the current processing speed
49
+ # @note it needs to be called in specific spots
50
+ def push(value)
51
+ stats.record!(value)
52
+ end
53
+ alias_method :<<, :push
54
+
55
+ def around_min_throughput?(margin)
56
+ rate = ratio
57
+ return true if rate == min_throughput
58
+
59
+ right_under = min_throughput * (1 - margin)
60
+ return false if rate < right_under
61
+
62
+ right_above = min_throughput * (1 + margin)
63
+ return false if rate > right_above
64
+
65
+ true
66
+ end
67
+
68
+ protected
69
+
70
+ def stats
71
+ @stats ||= Stats.new(min_throughput)
72
+ end
73
+
74
+ def to_seconds(count, ratio: min_throughput)
75
+ return 1 unless count
76
+
77
+ (count.ceil / ratio.to_f).ceil
78
+ end
79
+
80
+ private
81
+
82
+ def min_throughput
83
+ self.class::MIN_THROUGHPUT
84
+ end
85
+
86
+ def max_throughput
87
+ self.class::MAX_THROUGHPUT
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,70 @@
1
+ module Ecoportal
2
+ module API
3
+ module Common
4
+ class Client
5
+ module TimeOut
6
+ TIMEOUT_MARGIN = 0.15 # adaptative timeout with margin
7
+ MIN_SIZE = 5 # requests
8
+
9
+ protected
10
+
11
+ attr_writer :throughput
12
+
13
+ # Keeps track of the current processing speed
14
+ # @note it needs to be called in specific spots
15
+ def throughput!(secs = nil, count: nil)
16
+ throughput.record!(secs, count: count)
17
+ end
18
+
19
+ def throughput
20
+ @throughput ||= Throughput.new
21
+ end
22
+
23
+ def to_count(value)
24
+ value ||= 0
25
+ return min_size if value.positive? && value < min_size
26
+ return value if value.positive?
27
+
28
+ 1
29
+ end
30
+
31
+ private
32
+
33
+ def eta_for(count, approach: :conservative)
34
+ kargs = {}.tap do |params|
35
+ next params.merge!({ratio: rectified_throughput}) if approach == :conservative
36
+
37
+ params.merge!({approach: approach})
38
+ end
39
+
40
+ throughput.eta_for(to_count(count), **kargs)
41
+ end
42
+
43
+ def timeout_for(count, waited: 0, max_wait: nil, approach: :conservative)
44
+ (waited + eta_for(count, approach: approach)).then do |time|
45
+ next time unless max_wait
46
+
47
+ [max_wait, time].min
48
+ end
49
+ end
50
+
51
+ def rectified_throughput
52
+ throughput.ratio * (1 - timeout_margin)
53
+ end
54
+
55
+ def around_min_throughput?
56
+ throughput.around_min_throughput?(timeout_margin)
57
+ end
58
+
59
+ def timeout_margin
60
+ self.class::TIMEOUT_MARGIN
61
+ end
62
+
63
+ def min_size
64
+ self.class::MIN_SIZE
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end