55require 'segment/analytics/response'
66require 'segment/analytics/logging'
77require 'segment/analytics/backoff_policy'
8+ require 'segment/analytics/retry_budget'
89require 'net/http'
910require 'net/https'
1011require '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