Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
Gemfile.lock
.ruby-version
coverage/
.bundle/
vendor/
*-plan.md
6 changes: 3 additions & 3 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Lint/UnusedBlockArgument:
# Offense count: 3
# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods, CountRepeatedAttributes.
Metrics/AbcSize:
Max: 25
Max: 33

# Offense count: 3
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods.
Expand All @@ -73,7 +73,7 @@ Metrics/BlockLength:
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ClassLength:
Max: 115
Max: 150

# Offense count: 2
# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods.
Expand All @@ -83,7 +83,7 @@ Metrics/CyclomaticComplexity:
# Offense count: 11
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods.
Metrics/MethodLength:
Max: 16
Max: 21

# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
Expand Down
6 changes: 4 additions & 2 deletions e2e-cli/e2e-config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"sdk": "ruby",
"test_suites": "basic",
"test_suites": "basic,retry",
"auto_settings": false,
"patch": null,
"env": {}
"env": {
"AUTH_HEADER": "true"
}
}
1 change: 1 addition & 0 deletions lib/segment/analytics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require 'segment/analytics/field_parser'
require 'segment/analytics/client'
require 'segment/analytics/worker'
require 'segment/analytics/retry_budget'
require 'segment/analytics/transport'
require 'segment/analytics/response'
require 'segment/analytics/logging'
Expand Down
4 changes: 4 additions & 0 deletions lib/segment/analytics/backoff_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def next_interval
[interval, @max_timeout_ms].min
end

def reset!
@attempts = 0
end

private

def add_jitter(base, randomization_factor)
Expand Down
9 changes: 8 additions & 1 deletion lib/segment/analytics/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ def initialize(opts = {})

check_write_key!

at_exit { @worker_thread && @worker_thread[:should_exit] = true }
at_exit do
if @worker_thread
@worker_thread[:should_exit] = true
# Break any Retry-After or backoff sleep so shutdown is not held for
# up to rate_limit_retry_after_cap seconds.
@worker_thread.wakeup if @worker_thread.alive?
end
end
end

# Synchronously waits until the worker has flushed the queue.
Expand Down
9 changes: 6 additions & 3 deletions lib/segment/analytics/defaults.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ module Request
'Content-Type' => 'application/json',
'User-Agent' => "analytics-ruby/#{Analytics::VERSION}" }
RETRIES = 10
MAX_TOTAL_BACKOFF_DURATION = 43_200 # 12 hours in seconds
MAX_RATE_LIMIT_DURATION = 43_200 # 12 hours in seconds
RATE_LIMIT_RETRY_AFTER_CAP = 300 # seconds
end

module Queue
Expand All @@ -28,9 +31,9 @@ module MessageBatch
end

module BackoffPolicy
MIN_TIMEOUT_MS = 100
MAX_TIMEOUT_MS = 10000
MULTIPLIER = 1.5
MIN_TIMEOUT_MS = 500
MAX_TIMEOUT_MS = 60_000
MULTIPLIER = 2
RANDOMIZATION_FACTOR = 0.5
end
end
Expand Down
5 changes: 5 additions & 0 deletions lib/segment/analytics/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ def initialize(status = 200, error = nil)
@status = status
@error = error
end

def success?
# Spec item 1: 2xx and 3xx are success.
status >= 200 && status < 400
end
end
end
end
69 changes: 69 additions & 0 deletions lib/segment/analytics/retry_budget.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module Segment
class Analytics
# Tracks the two independent budgets one send may spend.
#
# A retryable status carrying Retry-After spends the rate-limit budget, which
# is bounded by wall clock only. Anything else retryable spends the counted
# backoff budget, bounded by both a retry count and wall clock. Keeping them
# separate is what stops a rate-limited server from exhausting the retries
# available to genuine failures.
#
# The caller performs the wait, so both methods return the delay in seconds,
# or nil when the budget is spent and the batch should be abandoned.
class RetryBudget
attr_reader :retry_count

# Keyword arguments would be cleaner but need Ruby 2.1; the gemspec still
# declares >= 2.0, which is also what rubocop is configured to parse.
def initialize(options = {})
@retries_remaining = options[:retries]
@backoff_policy = options[:backoff_policy]
@max_total_backoff_duration = options[:max_total_backoff_duration]
@max_rate_limit_duration = options[:max_rate_limit_duration]
@rate_limit_retry_after_cap = options[:rate_limit_retry_after_cap]
@logger = options[:logger]
@retry_count = 0
@backoff_start_time = nil
@rate_limit_start_time = nil
end

def next_backoff_delay
@retries_remaining -= 1
return spent('Retries exhausted for batch') if @retries_remaining <= 0

@backoff_start_time ||= Time.now
return spent('Max total backoff duration exceeded for batch') if elapsed?(@backoff_start_time, @max_total_backoff_duration)

delay_ms = @backoff_policy.next_interval
@logger.debug("Retrying request, #{@retries_remaining} retries left. Waiting #{delay_ms}ms")
delay_ms.to_f / 1000
end

def next_rate_limit_delay(retry_after, status_code)
@rate_limit_start_time ||= Time.now
return spent('Max rate limit duration exceeded for batch') if elapsed?(@rate_limit_start_time, @max_rate_limit_duration)

delay = [retry_after, @rate_limit_retry_after_cap].min
@logger.debug("Retry-After: #{delay}s on #{status_code}. Retrying after delay.")
delay
end

def record_retry
@retry_count += 1
end

private

def elapsed?(start_time, limit)
(Time.now - start_time) >= limit
end

def spent(message)
@logger.error(message)
nil
end
end
end
end
Loading