analytics-ruby 2.5.0 → 2.6.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.
- checksums.yaml +4 -4
- data/lib/segment/analytics/backoff_policy.rb +7 -15
- data/lib/segment/analytics/client.rb +5 -1
- data/lib/segment/analytics/defaults.rb +17 -3
- data/lib/segment/analytics/response.rb +4 -0
- data/lib/segment/analytics/retry_budget.rb +103 -0
- data/lib/segment/analytics/transport.rb +176 -60
- data/lib/segment/analytics/version.rb +1 -1
- data/lib/segment/analytics/worker.rb +1 -1
- data/lib/segment/analytics.rb +1 -0
- metadata +9 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93b810e0ef6d4ef896010dfc80c9997308f0761ac2f79345d96fa434f7dd66a3
|
|
4
|
+
data.tar.gz: e69ffd005078968e7137d220d8f9e51249a5452ae5311bc37757b21ea9cf82c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4619b6a5a332fd6da925f508c8e8ca58534236d597994ab0945840712d3761729a4c9dcf0a9aba2191e8f84f5191b853db9f1f854f047b00c46927ca98257c36
|
|
7
|
+
data.tar.gz: 9a6a6ca7a0c8c386dc9646c8521fb8000b183bd56d14d8da0a11e58117bd6beabd59048e8495f96b7acaab4624ad481c173dc3b781ca6dbbdcb24bd05d725a00
|
|
@@ -26,25 +26,17 @@ module Segment
|
|
|
26
26
|
# @return [Numeric] the next backoff interval, in milliseconds.
|
|
27
27
|
def next_interval
|
|
28
28
|
interval = @min_timeout_ms * (@multiplier**@attempts)
|
|
29
|
-
interval = add_jitter(interval, @randomization_factor)
|
|
30
|
-
|
|
31
29
|
@attempts += 1
|
|
32
30
|
|
|
33
|
-
|
|
31
|
+
# Clamp first, then jitter. Jittering before the clamp meant every attempt
|
|
32
|
+
# at the ceiling returned exactly max_timeout_ms, so a fleet that backed off
|
|
33
|
+
# together stayed in lockstep. Jitter only subtracts, so the ceiling holds.
|
|
34
|
+
capped = [interval, @max_timeout_ms].min
|
|
35
|
+
capped - (rand * capped * @randomization_factor)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def add_jitter(base, randomization_factor)
|
|
39
|
-
random_number = rand
|
|
40
|
-
max_deviation = base * randomization_factor
|
|
41
|
-
deviation = random_number * max_deviation
|
|
42
|
-
|
|
43
|
-
if random_number < 0.5
|
|
44
|
-
base - deviation
|
|
45
|
-
else
|
|
46
|
-
base + deviation
|
|
47
|
-
end
|
|
38
|
+
def reset!
|
|
39
|
+
@attempts = 0
|
|
48
40
|
end
|
|
49
41
|
end
|
|
50
42
|
end
|
|
@@ -32,7 +32,11 @@ module Segment
|
|
|
32
32
|
|
|
33
33
|
check_write_key!
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# The worker checks this between sleep slices, so a Retry-After or backoff
|
|
36
|
+
# wait is abandoned within a second rather than holding shutdown for up to
|
|
37
|
+
# rate_limit_retry_after_cap seconds. Assigning to a dead thread is safe;
|
|
38
|
+
# Thread#wakeup is not, and raising here would force a non-zero exit status.
|
|
39
|
+
at_exit { @worker_thread[:should_exit] = true if @worker_thread }
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# Synchronously waits until the worker has flushed the queue.
|
|
@@ -12,6 +12,20 @@ module Segment
|
|
|
12
12
|
'Content-Type' => 'application/json',
|
|
13
13
|
'User-Agent' => "analytics-ruby/#{Analytics::VERSION}" }
|
|
14
14
|
RETRIES = 10
|
|
15
|
+
MAX_TOTAL_BACKOFF_DURATION = 43_200 # 12 hours in seconds
|
|
16
|
+
|
|
17
|
+
# Rate-limited attempts are deliberately uncounted, so this duration is the
|
|
18
|
+
# only thing bounding them. It is deliberately several times
|
|
19
|
+
# RATE_LIMIT_RETRY_AFTER_CAP: when the two are equal a single maximal
|
|
20
|
+
# Retry-After consumes the whole budget, leaving one attempt and no retry,
|
|
21
|
+
# and the cap can never be the smaller of the two so it never binds at all.
|
|
22
|
+
MAX_RATE_LIMIT_DURATION = 1800 # seconds
|
|
23
|
+
|
|
24
|
+
# A guard against an absurd header, not a second budget. Waiting less than
|
|
25
|
+
# the server asked for does not make the next attempt more likely to
|
|
26
|
+
# succeed, it just sends more requests at something already rate-limiting
|
|
27
|
+
# us; how long we keep trying is MAX_RATE_LIMIT_DURATION's job.
|
|
28
|
+
RATE_LIMIT_RETRY_AFTER_CAP = 300 # seconds
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
module Queue
|
|
@@ -28,9 +42,9 @@ module Segment
|
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
module BackoffPolicy
|
|
31
|
-
MIN_TIMEOUT_MS =
|
|
32
|
-
MAX_TIMEOUT_MS =
|
|
33
|
-
MULTIPLIER =
|
|
45
|
+
MIN_TIMEOUT_MS = 500
|
|
46
|
+
MAX_TIMEOUT_MS = 60_000
|
|
47
|
+
MULTIPLIER = 2
|
|
34
48
|
RANDOMIZATION_FACTOR = 0.5
|
|
35
49
|
end
|
|
36
50
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Segment
|
|
4
|
+
class Analytics
|
|
5
|
+
# Tracks the two independent budgets one send may spend.
|
|
6
|
+
#
|
|
7
|
+
# A retryable status carrying Retry-After spends the rate-limit budget, which
|
|
8
|
+
# is bounded by wall clock only. Anything else retryable spends the counted
|
|
9
|
+
# backoff budget, bounded by both a retry count and wall clock. Keeping them
|
|
10
|
+
# separate is what stops a rate-limited server from exhausting the retries
|
|
11
|
+
# available to genuine failures.
|
|
12
|
+
#
|
|
13
|
+
# The caller performs the wait, so both methods return the delay in seconds,
|
|
14
|
+
# or nil when the budget is spent and the batch should be abandoned.
|
|
15
|
+
class RetryBudget
|
|
16
|
+
attr_reader :retry_count
|
|
17
|
+
|
|
18
|
+
# Keyword arguments would be cleaner but need Ruby 2.1; the gemspec still
|
|
19
|
+
# declares >= 2.0, which is also what rubocop is configured to parse.
|
|
20
|
+
def initialize(options = {})
|
|
21
|
+
@retries_remaining = options[:retries]
|
|
22
|
+
@backoff_policy = options[:backoff_policy]
|
|
23
|
+
@max_total_backoff_duration = options[:max_total_backoff_duration]
|
|
24
|
+
@max_rate_limit_duration = options[:max_rate_limit_duration]
|
|
25
|
+
@rate_limit_retry_after_cap = options[:rate_limit_retry_after_cap]
|
|
26
|
+
@logger = options[:logger]
|
|
27
|
+
@retry_count = 0
|
|
28
|
+
@backoff_start_time = nil
|
|
29
|
+
@rate_limit_start_time = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def next_backoff_delay
|
|
33
|
+
# Checked before the decrement: decrementing first spent one retry on the
|
|
34
|
+
# exhaustion test itself, so a configured N only ever performed N-1, and
|
|
35
|
+
# retries: 1 and retries: 0 were indistinguishable.
|
|
36
|
+
return spent('Retries exhausted for batch') if @retries_remaining <= 0
|
|
37
|
+
|
|
38
|
+
@retries_remaining -= 1
|
|
39
|
+
|
|
40
|
+
# One reading for the budget test and the fit check below, as on the
|
|
41
|
+
# rate-limit path.
|
|
42
|
+
now = monotonic_now
|
|
43
|
+
@backoff_start_time ||= now
|
|
44
|
+
|
|
45
|
+
remaining = @max_total_backoff_duration - (now - @backoff_start_time)
|
|
46
|
+
return spent('Max total backoff duration exceeded for batch') if remaining <= 0
|
|
47
|
+
|
|
48
|
+
# A backoff longer than what is left of the budget is a wait whose attempt
|
|
49
|
+
# can never run, so there is nothing to schedule.
|
|
50
|
+
delay = @backoff_policy.next_interval.to_f / 1000
|
|
51
|
+
return spent('Backoff interval does not fit the remaining budget') if delay > remaining
|
|
52
|
+
|
|
53
|
+
@logger.debug("Retrying request, #{@retries_remaining} retries left. Waiting #{delay}s")
|
|
54
|
+
delay
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def next_rate_limit_delay(retry_after, status_code)
|
|
58
|
+
# One reading serves the episode start, the budget test and the delay. A
|
|
59
|
+
# second reading lets the budget expire between them, which yields a negative
|
|
60
|
+
# remaining and a negative delay — and Kernel#sleep raises ArgumentError on
|
|
61
|
+
# one rather than returning immediately. It also leaves remaining a hair under
|
|
62
|
+
# the budget on an episode's first response, which is enough to lose an exact
|
|
63
|
+
# comparison against the cap.
|
|
64
|
+
now = monotonic_now
|
|
65
|
+
@rate_limit_start_time ||= now
|
|
66
|
+
|
|
67
|
+
remaining = @max_rate_limit_duration - (now - @rate_limit_start_time)
|
|
68
|
+
return spent('Max rate limit duration exceeded for batch') if remaining <= 0
|
|
69
|
+
|
|
70
|
+
# Capped, then required to fit. Shortening the wait to fit would send the
|
|
71
|
+
# next request inside the window the server asked us to wait out — one it
|
|
72
|
+
# has already said it will not serve — and the budget is spent by then, so
|
|
73
|
+
# it would be the last attempt either way. Giving up loses the same batch
|
|
74
|
+
# and sends one request fewer at something already rate-limiting us.
|
|
75
|
+
delay = [retry_after, @rate_limit_retry_after_cap].min
|
|
76
|
+
return spent('Retry-After does not fit the remaining rate limit budget') if delay > remaining
|
|
77
|
+
|
|
78
|
+
@logger.debug("Retry-After: #{delay}s on #{status_code}. Retrying after delay.")
|
|
79
|
+
delay
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_retry
|
|
83
|
+
@retry_count += 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def elapsed?(start_time, limit)
|
|
89
|
+
(monotonic_now - start_time) >= limit
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Wall-clock time can jump; these budgets must not expire or stretch with it.
|
|
93
|
+
def monotonic_now
|
|
94
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def spent(message)
|
|
98
|
+
@logger.error(message)
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -5,9 +5,11 @@ require 'segment/analytics/utils'
|
|
|
5
5
|
require 'segment/analytics/response'
|
|
6
6
|
require 'segment/analytics/logging'
|
|
7
7
|
require 'segment/analytics/backoff_policy'
|
|
8
|
+
require 'segment/analytics/retry_budget'
|
|
8
9
|
require 'net/http'
|
|
9
10
|
require 'net/https'
|
|
10
11
|
require 'json'
|
|
12
|
+
require 'time'
|
|
11
13
|
|
|
12
14
|
module Segment
|
|
13
15
|
class Analytics
|
|
@@ -16,22 +18,18 @@ module Segment
|
|
|
16
18
|
include Segment::Analytics::Utils
|
|
17
19
|
include Segment::Analytics::Logging
|
|
18
20
|
|
|
21
|
+
RETRYABLE_4XX = [408, 410, 429, 460].freeze
|
|
22
|
+
NON_RETRYABLE_5XX = [501, 505, 511].freeze
|
|
23
|
+
|
|
19
24
|
def initialize(options = {})
|
|
20
25
|
options[:host] ||= HOST
|
|
21
26
|
options[:port] ||= PORT
|
|
22
|
-
options[:ssl]
|
|
27
|
+
options[:ssl] ||= SSL
|
|
23
28
|
@headers = options[:headers] || HEADERS
|
|
24
|
-
@path
|
|
25
|
-
@retries = options[:retries] || RETRIES
|
|
26
|
-
@backoff_policy =
|
|
27
|
-
options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
|
|
29
|
+
@path = options[:path] || PATH
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
http
|
|
31
|
-
http.read_timeout = 8
|
|
32
|
-
http.open_timeout = 4
|
|
33
|
-
|
|
34
|
-
@http = http
|
|
31
|
+
configure_retries(options)
|
|
32
|
+
@http = build_http(options)
|
|
35
33
|
end
|
|
36
34
|
|
|
37
35
|
# Sends a batch of messages to the API
|
|
@@ -40,23 +38,32 @@ module Segment
|
|
|
40
38
|
def send(write_key, batch)
|
|
41
39
|
logger.debug("Sending request for #{batch.length} items")
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
error = JSON.parse(body)['error']
|
|
46
|
-
should_retry = should_retry_request?(status_code, body)
|
|
47
|
-
logger.debug("Response status code: #{status_code}")
|
|
48
|
-
logger.debug("Response error: #{error}") if error
|
|
41
|
+
@backoff_policy.reset! if @backoff_policy.respond_to?(:reset!)
|
|
42
|
+
budget = new_retry_budget
|
|
49
43
|
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
loop do
|
|
45
|
+
begin
|
|
46
|
+
status_code, body, headers = send_request(write_key, batch, budget.retry_count)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
# Connection reset, DNS failure, read timeout and friends. Retried on
|
|
49
|
+
# the counted backoff budget, like a retryable status code.
|
|
50
|
+
logger.error("Network error: #{e.message}")
|
|
51
|
+
return Response.new(-1, e.to_s) unless wait_to_retry(budget.next_backoff_delay, budget)
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
error = parse_error(body)
|
|
57
|
+
final = final_response(status_code, body, error)
|
|
58
|
+
return final if final
|
|
59
|
+
|
|
60
|
+
delay = retry_delay(status_code, headers, budget)
|
|
61
|
+
return Response.new(status_code, error) unless wait_to_retry(delay, budget)
|
|
59
62
|
end
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
logger.error(e.message)
|
|
65
|
+
e.backtrace.each { |line| logger.error(line) }
|
|
66
|
+
Response.new(-1, e.to_s)
|
|
60
67
|
end
|
|
61
68
|
|
|
62
69
|
# Closes a persistent connection if it exists
|
|
@@ -66,65 +73,174 @@ module Segment
|
|
|
66
73
|
|
|
67
74
|
private
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
# A Response once the batch is settled, or nil while it is still retryable.
|
|
77
|
+
def final_response(status_code, body, error)
|
|
78
|
+
logger.debug("Response status code: #{status_code}")
|
|
79
|
+
logger.debug("Response error: #{error}") if error
|
|
80
|
+
|
|
81
|
+
return Response.new(status_code, error) if success_status?(status_code)
|
|
82
|
+
return nil if retryable_status?(status_code)
|
|
83
|
+
|
|
84
|
+
if status_code >= 300 && status_code < 400
|
|
85
|
+
# Logging the body here would be useless: a redirect has none.
|
|
86
|
+
logger.error("Unexpected redirect (#{status_code}); batch not uploaded. " \
|
|
87
|
+
'Check whether the configured host points at a proxy or redirector.')
|
|
77
88
|
else
|
|
78
|
-
|
|
89
|
+
logger.error(body)
|
|
79
90
|
end
|
|
91
|
+
Response.new(status_code, error)
|
|
80
92
|
end
|
|
81
93
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
# A Retry-After spends the rate-limit budget; anything else retryable
|
|
95
|
+
# spends the counted backoff budget.
|
|
96
|
+
def retry_delay(status_code, headers, budget)
|
|
97
|
+
retry_after = parse_retry_after(headers['retry-after'])
|
|
98
|
+
return budget.next_backoff_delay unless retry_after
|
|
99
|
+
|
|
100
|
+
budget.next_rate_limit_delay(retry_after, status_code)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def configure_retries(options)
|
|
104
|
+
@retries = options[:retries] || RETRIES
|
|
105
|
+
@backoff_policy =
|
|
106
|
+
options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
|
|
107
|
+
|
|
108
|
+
# One policy instance serves every batch, so it has to be reset between
|
|
109
|
+
# them or attempt counts accumulate and each batch starts where the last
|
|
110
|
+
# one left off. reset! is not part of the older documented contract, which
|
|
111
|
+
# was next_interval alone, so a policy predating it still works — but it
|
|
112
|
+
# keeps that accumulation, and silently. Say so rather than letting an
|
|
113
|
+
# integrator find it as "retries get slower the longer we run".
|
|
114
|
+
unless @backoff_policy.respond_to?(:reset!)
|
|
115
|
+
logger.warn(
|
|
116
|
+
'backoff_policy does not implement reset!; attempt counts will ' \
|
|
117
|
+
'accumulate across batches. Add a reset! method that clears them.'
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
@max_total_backoff_duration = options[:max_total_backoff_duration] ||
|
|
121
|
+
MAX_TOTAL_BACKOFF_DURATION
|
|
122
|
+
@max_rate_limit_duration = options[:max_rate_limit_duration] ||
|
|
123
|
+
MAX_RATE_LIMIT_DURATION
|
|
124
|
+
@rate_limit_retry_after_cap = options[:rate_limit_retry_after_cap] ||
|
|
125
|
+
RATE_LIMIT_RETRY_AFTER_CAP
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_http(options)
|
|
129
|
+
http = Net::HTTP.new(options[:host], options[:port])
|
|
130
|
+
http.use_ssl = options[:ssl]
|
|
131
|
+
http.read_timeout = 8
|
|
132
|
+
http.open_timeout = 4
|
|
133
|
+
http
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# How long a sliced retry wait sleeps before re-checking for shutdown.
|
|
137
|
+
SHUTDOWN_CHECK_INTERVAL = 1
|
|
138
|
+
|
|
139
|
+
def new_retry_budget
|
|
140
|
+
RetryBudget.new(
|
|
141
|
+
:retries => @retries,
|
|
142
|
+
:backoff_policy => @backoff_policy,
|
|
143
|
+
:max_total_backoff_duration => @max_total_backoff_duration,
|
|
144
|
+
:max_rate_limit_duration => @max_rate_limit_duration,
|
|
145
|
+
:rate_limit_retry_after_cap => @rate_limit_retry_after_cap,
|
|
146
|
+
:logger => logger
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# nil delay means the budget is spent. Sleeping here rather than inside
|
|
151
|
+
# RetryBudget keeps the wait on Transport, where callers stub it.
|
|
152
|
+
def wait_to_retry(delay, budget)
|
|
153
|
+
return false if delay.nil?
|
|
154
|
+
return false unless interruptible_sleep(delay)
|
|
155
|
+
|
|
156
|
+
budget.record_retry
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Sleeps in slices so shutdown is noticed within SHUTDOWN_CHECK_INTERVAL
|
|
161
|
+
# rather than after the whole delay, which can be rate_limit_retry_after_cap
|
|
162
|
+
# seconds. Returns false if shutdown was requested.
|
|
87
163
|
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
164
|
+
# Thread#wakeup is deliberately not used for this: it only cuts short a sleep
|
|
165
|
+
# already in progress, so a wakeup arriving while the worker is mid-request is
|
|
166
|
+
# lost and the next sleep still runs in full.
|
|
167
|
+
def interruptible_sleep(seconds)
|
|
168
|
+
remaining = seconds
|
|
169
|
+
while remaining > 0
|
|
170
|
+
return false if Thread.current[:should_exit]
|
|
92
171
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
rescue StandardError => e
|
|
97
|
-
should_retry = true
|
|
98
|
-
caught_exception = e
|
|
172
|
+
slice = [remaining, SHUTDOWN_CHECK_INTERVAL].min
|
|
173
|
+
sleep(slice)
|
|
174
|
+
remaining -= slice
|
|
99
175
|
end
|
|
176
|
+
!Thread.current[:should_exit]
|
|
177
|
+
end
|
|
100
178
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
179
|
+
def parse_error(body)
|
|
180
|
+
JSON.parse(body)['error']
|
|
181
|
+
rescue StandardError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Only 2xx. Net::HTTP does not follow redirects, so a 3xx means nothing was
|
|
186
|
+
# uploaded; calling it success would drop the batch silently.
|
|
187
|
+
def success_status?(code)
|
|
188
|
+
code >= 200 && code < 300
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def retryable_status?(code)
|
|
192
|
+
if code >= 500 && code < 600
|
|
193
|
+
!NON_RETRYABLE_5XX.include?(code)
|
|
105
194
|
else
|
|
106
|
-
|
|
195
|
+
RETRYABLE_4XX.include?(code)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def parse_retry_after(value)
|
|
200
|
+
return nil if value.nil?
|
|
201
|
+
|
|
202
|
+
str = value.is_a?(Array) ? value.first : value
|
|
203
|
+
return nil if str.nil?
|
|
204
|
+
|
|
205
|
+
str = str.strip
|
|
206
|
+
|
|
207
|
+
# Try integer seconds
|
|
208
|
+
if str =~ /\A\d+\z/
|
|
209
|
+
seconds = str.to_i
|
|
210
|
+
return seconds > 0 ? seconds : nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Try HTTP-date (RFC 7231 S7.1.1.1)
|
|
214
|
+
begin
|
|
215
|
+
target = Time.httpdate(str)
|
|
216
|
+
seconds = (target - Time.now).to_i
|
|
217
|
+
seconds > 0 ? seconds : nil
|
|
218
|
+
rescue ArgumentError
|
|
219
|
+
nil
|
|
107
220
|
end
|
|
108
221
|
end
|
|
109
222
|
|
|
110
|
-
# Sends a request for the batch, returns [status_code, body]
|
|
111
|
-
def send_request(write_key, batch)
|
|
223
|
+
# Sends a request for the batch, returns [status_code, body, headers]
|
|
224
|
+
def send_request(write_key, batch, retry_count = 0)
|
|
112
225
|
payload = JSON.generate(
|
|
113
226
|
:sentAt => datetime_in_iso8601(Time.now),
|
|
114
227
|
:batch => batch
|
|
115
228
|
)
|
|
116
|
-
|
|
229
|
+
headers = @headers.dup
|
|
230
|
+
headers['X-Retry-Count'] = retry_count.to_s if retry_count > 0
|
|
231
|
+
|
|
232
|
+
request = Net::HTTP::Post.new(@path, headers)
|
|
117
233
|
request.basic_auth(write_key, nil)
|
|
118
234
|
|
|
119
235
|
if self.class.stub
|
|
120
236
|
logger.debug "stubbed request to #{@path}: " \
|
|
121
237
|
"write key = #{write_key}, batch = #{JSON.generate(batch)}"
|
|
122
238
|
|
|
123
|
-
[200, '{}']
|
|
239
|
+
[200, '{}', {}]
|
|
124
240
|
else
|
|
125
|
-
@http.start unless @http.started?
|
|
241
|
+
@http.start unless @http.started?
|
|
126
242
|
response = @http.request(request, payload)
|
|
127
|
-
[response.code.to_i, response.body]
|
|
243
|
+
[response.code.to_i, response.body, response.to_hash]
|
|
128
244
|
end
|
|
129
245
|
end
|
|
130
246
|
|
data/lib/segment/analytics.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'segment/analytics/utils'
|
|
|
6
6
|
require 'segment/analytics/field_parser'
|
|
7
7
|
require 'segment/analytics/client'
|
|
8
8
|
require 'segment/analytics/worker'
|
|
9
|
+
require 'segment/analytics/retry_budget'
|
|
9
10
|
require 'segment/analytics/transport'
|
|
10
11
|
require 'segment/analytics/response'
|
|
11
12
|
require 'segment/analytics/logging'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: analytics-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Segment.io
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: commander
|
|
@@ -86,14 +86,14 @@ dependencies:
|
|
|
86
86
|
requirements:
|
|
87
87
|
- - "~>"
|
|
88
88
|
- !ruby/object:Gem::Version
|
|
89
|
-
version: 3.6
|
|
89
|
+
version: '3.6'
|
|
90
90
|
type: :development
|
|
91
91
|
prerelease: false
|
|
92
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
93
93
|
requirements:
|
|
94
94
|
- - "~>"
|
|
95
95
|
- !ruby/object:Gem::Version
|
|
96
|
-
version: 3.6
|
|
96
|
+
version: '3.6'
|
|
97
97
|
- !ruby/object:Gem::Dependency
|
|
98
98
|
name: rubocop
|
|
99
99
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -126,6 +126,7 @@ files:
|
|
|
126
126
|
- lib/segment/analytics/logging.rb
|
|
127
127
|
- lib/segment/analytics/message_batch.rb
|
|
128
128
|
- lib/segment/analytics/response.rb
|
|
129
|
+
- lib/segment/analytics/retry_budget.rb
|
|
129
130
|
- lib/segment/analytics/test_queue.rb
|
|
130
131
|
- lib/segment/analytics/transport.rb
|
|
131
132
|
- lib/segment/analytics/utils.rb
|
|
@@ -135,7 +136,7 @@ homepage: https://github.com/segmentio/analytics-ruby
|
|
|
135
136
|
licenses:
|
|
136
137
|
- MIT
|
|
137
138
|
metadata: {}
|
|
138
|
-
post_install_message:
|
|
139
|
+
post_install_message:
|
|
139
140
|
rdoc_options: []
|
|
140
141
|
require_paths:
|
|
141
142
|
- lib
|
|
@@ -150,8 +151,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
150
151
|
- !ruby/object:Gem::Version
|
|
151
152
|
version: '0'
|
|
152
153
|
requirements: []
|
|
153
|
-
rubygems_version: 3.
|
|
154
|
-
signing_key:
|
|
154
|
+
rubygems_version: 3.5.22
|
|
155
|
+
signing_key:
|
|
155
156
|
specification_version: 4
|
|
156
157
|
summary: Segment.io analytics library
|
|
157
158
|
test_files: []
|