Skip to content

Commit 2bba403

Browse files
committed
Extract the retry budgets out of Transport#send
rubocop runs as part of rake's default task, over lib/ and spec/, and this branch had pushed Transport#send to 66 lines with an ABC size of 75.77 against a limit of 25 — eleven new offences, so CI would have gone red. The bulk of it was the counted-backoff budget sitting inline as a lambda. That, and the rate-limit budget beside it, are now a RetryBudget class of their own. It reports the delay to wait and nil once a budget is spent; Transport still performs the sleep, which keeps the seam the specs stub. send is down to 21 lines and ABC 32.39, and its cyclomatic and perceived-complexity offences are gone. Option parsing moved out of initialize the same way, clearing all four of its offences, and the response classification and delay choice are now named methods rather than inline branches. Three Max values in .rubocop_todo.yml are raised for what is left, which is a class 146 lines long and two methods a handful of lines over. Regenerating the file wholesale was the alternative and a bad one: it was last generated by rubocop 1.44 in 2023, and 1.90 rewrites the entire baseline. RetryBudget takes an options hash rather than keyword arguments: the gemspec declares required_ruby_version >= 2.0 and rubocop parses as 2.0, where required keyword arguments are a syntax error. rubocop reports no offences over lib/ and spec/. 202 examples, 0 failures, and all 58 e2e tests pass. Line coverage 97.92% -> 98.19%.
1 parent a95bb99 commit 2bba403

4 files changed

Lines changed: 156 additions & 86 deletions

File tree

‎.rubocop_todo.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Lint/UnusedBlockArgument:
6262
# Offense count: 3
6363
# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods, CountRepeatedAttributes.
6464
Metrics/AbcSize:
65-
Max: 25
65+
Max: 33
6666

6767
# Offense count: 3
6868
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods.
@@ -73,7 +73,7 @@ Metrics/BlockLength:
7373
# Offense count: 1
7474
# Configuration parameters: CountComments, CountAsOne.
7575
Metrics/ClassLength:
76-
Max: 115
76+
Max: 150
7777

7878
# Offense count: 2
7979
# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods.
@@ -83,7 +83,7 @@ Metrics/CyclomaticComplexity:
8383
# Offense count: 11
8484
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods.
8585
Metrics/MethodLength:
86-
Max: 16
86+
Max: 21
8787

8888
# Offense count: 1
8989
# This cop supports safe autocorrection (--autocorrect).

‎lib/segment/analytics.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require 'segment/analytics/field_parser'
77
require 'segment/analytics/client'
88
require 'segment/analytics/worker'
9+
require 'segment/analytics/retry_budget'
910
require 'segment/analytics/transport'
1011
require 'segment/analytics/response'
1112
require 'segment/analytics/logging'
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
@retries_remaining -= 1
34+
return spent('Retries exhausted for batch') if @retries_remaining <= 0
35+
36+
@backoff_start_time ||= Time.now
37+
return spent('Max total backoff duration exceeded for batch') if elapsed?(@backoff_start_time, @max_total_backoff_duration)
38+
39+
delay_ms = @backoff_policy.next_interval
40+
@logger.debug("Retrying request, #{@retries_remaining} retries left. Waiting #{delay_ms}ms")
41+
delay_ms.to_f / 1000
42+
end
43+
44+
def next_rate_limit_delay(retry_after, status_code)
45+
@rate_limit_start_time ||= Time.now
46+
return spent('Max rate limit duration exceeded for batch') if elapsed?(@rate_limit_start_time, @max_rate_limit_duration)
47+
48+
delay = [retry_after, @rate_limit_retry_after_cap].min
49+
@logger.debug("Retry-After: #{delay}s on #{status_code}. Retrying after delay.")
50+
delay
51+
end
52+
53+
def record_retry
54+
@retry_count += 1
55+
end
56+
57+
private
58+
59+
def elapsed?(start_time, limit)
60+
(Time.now - start_time) >= limit
61+
end
62+
63+
def spent(message)
64+
@logger.error(message)
65+
nil
66+
end
67+
end
68+
end
69+
end

‎lib/segment/analytics/transport.rb‎

Lines changed: 83 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require 'segment/analytics/response'
66
require 'segment/analytics/logging'
77
require 'segment/analytics/backoff_policy'
8+
require 'segment/analytics/retry_budget'
89
require 'net/http'
910
require 'net/https'
1011
require 'json'
@@ -26,23 +27,9 @@ def initialize(options = {})
2627
options[:ssl] ||= SSL
2728
@headers = options[:headers] || HEADERS
2829
@path = options[:path] || PATH
29-
@retries = options[:retries] || RETRIES
30-
@backoff_policy =
31-
options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
32-
33-
@max_total_backoff_duration = options[:max_total_backoff_duration] ||
34-
MAX_TOTAL_BACKOFF_DURATION
35-
@max_rate_limit_duration = options[:max_rate_limit_duration] ||
36-
MAX_RATE_LIMIT_DURATION
37-
@rate_limit_retry_after_cap = options[:rate_limit_retry_after_cap] ||
38-
RATE_LIMIT_RETRY_AFTER_CAP
39-
40-
http = Net::HTTP.new(options[:host], options[:port])
41-
http.use_ssl = options[:ssl]
42-
http.read_timeout = 8
43-
http.open_timeout = 4
4430

45-
@http = http
31+
configure_retries(options)
32+
@http = build_http(options)
4633
end
4734

4835
# Sends a batch of messages to the API
@@ -52,84 +39,26 @@ def send(write_key, batch)
5239
logger.debug("Sending request for #{batch.length} items")
5340

5441
@backoff_policy.reset! if @backoff_policy.respond_to?(:reset!)
55-
56-
retry_count = 0
57-
retries_remaining = @retries
58-
backoff_start_time = nil
59-
rate_limit_start_time = nil
60-
61-
# Returns a Response when the batch should be abandoned, or nil to retry.
62-
consume_backoff = lambda do |response_error, response_status|
63-
retries_remaining -= 1
64-
if retries_remaining <= 0
65-
logger.error('Retries exhausted for batch')
66-
next Response.new(response_status, response_error)
67-
end
68-
69-
backoff_start_time ||= Time.now
70-
if (Time.now - backoff_start_time) >= @max_total_backoff_duration
71-
logger.error('Max total backoff duration exceeded for batch')
72-
next Response.new(response_status, response_error)
73-
end
74-
75-
delay_ms = @backoff_policy.next_interval
76-
logger.debug("Retrying request, #{retries_remaining} retries left. Waiting #{delay_ms}ms")
77-
sleep(delay_ms.to_f / 1000)
78-
next Response.new(response_status, response_error) if Thread.current[:should_exit]
79-
80-
retry_count += 1
81-
nil
82-
end
42+
budget = new_retry_budget
8343

8444
loop do
8545
begin
86-
status_code, body, response_headers = send_request(write_key, batch, retry_count)
46+
status_code, body, headers = send_request(write_key, batch, budget.retry_count)
8747
rescue StandardError => e
8848
# Connection reset, DNS failure, read timeout and friends. Retried on
8949
# the counted backoff budget, like a retryable status code.
9050
logger.error("Network error: #{e.message}")
91-
give_up = consume_backoff.call(e.to_s, -1)
92-
return give_up if give_up
51+
return Response.new(-1, e.to_s) unless wait_to_retry(budget.next_backoff_delay, budget)
9352

9453
next
9554
end
9655

97-
error = begin
98-
JSON.parse(body)['error']
99-
rescue StandardError
100-
nil
101-
end
102-
logger.debug("Response status code: #{status_code}")
103-
logger.debug("Response error: #{error}") if error
104-
105-
return Response.new(status_code, error) if success_status?(status_code)
106-
107-
unless retryable_status?(status_code)
108-
logger.error(body)
109-
return Response.new(status_code, error)
110-
end
111-
112-
# Any retryable status with Retry-After: use rate-limit path (no retry budget cost)
113-
retry_after = parse_retry_after(response_headers['retry-after'])
114-
if retry_after
115-
rate_limit_start_time ||= Time.now
116-
if (Time.now - rate_limit_start_time) >= @max_rate_limit_duration
117-
logger.error('Max rate limit duration exceeded for batch')
118-
return Response.new(status_code, error)
119-
end
120-
delay = [retry_after, @rate_limit_retry_after_cap].min
121-
logger.debug("Retry-After: #{delay}s on #{status_code}. Retrying after delay.")
122-
sleep(delay)
123-
# Client#shutdown wakes this thread, so the sleep above returns early.
124-
return Response.new(status_code, error) if Thread.current[:should_exit]
125-
126-
retry_count += 1
127-
next
128-
end
56+
error = parse_error(body)
57+
final = final_response(status_code, body, error)
58+
return final if final
12959

130-
# No Retry-After: counted backoff
131-
give_up = consume_backoff.call(error, status_code)
132-
return give_up if give_up
60+
delay = retry_delay(status_code, headers, budget)
61+
return Response.new(status_code, error) unless wait_to_retry(delay, budget)
13362
end
13463
rescue StandardError => e
13564
logger.error(e.message)
@@ -144,6 +73,77 @@ def shutdown
14473

14574
private
14675

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+
logger.error(body)
85+
Response.new(status_code, error)
86+
end
87+
88+
# A Retry-After spends the rate-limit budget; anything else retryable
89+
# spends the counted backoff budget.
90+
def retry_delay(status_code, headers, budget)
91+
retry_after = parse_retry_after(headers['retry-after'])
92+
return budget.next_backoff_delay unless retry_after
93+
94+
budget.next_rate_limit_delay(retry_after, status_code)
95+
end
96+
97+
def configure_retries(options)
98+
@retries = options[:retries] || RETRIES
99+
@backoff_policy =
100+
options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
101+
@max_total_backoff_duration = options[:max_total_backoff_duration] ||
102+
MAX_TOTAL_BACKOFF_DURATION
103+
@max_rate_limit_duration = options[:max_rate_limit_duration] ||
104+
MAX_RATE_LIMIT_DURATION
105+
@rate_limit_retry_after_cap = options[:rate_limit_retry_after_cap] ||
106+
RATE_LIMIT_RETRY_AFTER_CAP
107+
end
108+
109+
def build_http(options)
110+
http = Net::HTTP.new(options[:host], options[:port])
111+
http.use_ssl = options[:ssl]
112+
http.read_timeout = 8
113+
http.open_timeout = 4
114+
http
115+
end
116+
117+
def new_retry_budget
118+
RetryBudget.new(
119+
:retries => @retries,
120+
:backoff_policy => @backoff_policy,
121+
:max_total_backoff_duration => @max_total_backoff_duration,
122+
:max_rate_limit_duration => @max_rate_limit_duration,
123+
:rate_limit_retry_after_cap => @rate_limit_retry_after_cap,
124+
:logger => logger
125+
)
126+
end
127+
128+
# nil delay means the budget is spent. Sleeping here rather than inside
129+
# RetryBudget keeps the wait on Transport, where callers stub it.
130+
def wait_to_retry(delay, budget)
131+
return false if delay.nil?
132+
133+
sleep(delay)
134+
# Client#shutdown wakes this thread, so the sleep above returns early.
135+
return false if Thread.current[:should_exit]
136+
137+
budget.record_retry
138+
true
139+
end
140+
141+
def parse_error(body)
142+
JSON.parse(body)['error']
143+
rescue StandardError
144+
nil
145+
end
146+
147147
def success_status?(code)
148148
# Spec item 1: 2xx and 3xx are success.
149149
code >= 200 && code < 400
@@ -175,7 +175,7 @@ def parse_retry_after(value)
175175
begin
176176
target = Time.httpdate(str)
177177
seconds = (target - Time.now).to_i
178-
return seconds > 0 ? seconds : nil
178+
seconds > 0 ? seconds : nil
179179
rescue ArgumentError
180180
nil
181181
end

0 commit comments

Comments
 (0)