Skip to content

Commit a2b80fb

Browse files
authored
Dual-path retry: exponential backoff + rate-limit handling (#280)
Unified HTTP response handling and retry behavior Brings analytics-ruby in line with the TAPI HTTP agreements and with analytics-java 3.5.5. Upgrade notes This release sends X-Retry-Count on retries, which earlier versions did not. If traffic to Segment passes through a proxy, gateway or WAF that allowlists request headers, add it before upgrading or retried uploads will be rejected. Authorization is unchanged: this client has always sent the write key as HTTP Basic credentials. Default backoff pacing has changed: base 500ms (was 100ms), ceiling 60s (was 10s), multiplier 2 (was 1.5). Set min_timeout_ms, max_timeout_ms and multiplier on a BackoffPolicy to keep the old schedule. Retry behavior - Retryable statuses: 408, 410, 429, 460 and 5xx except 501, 505 and 511. 529 arrives through the generic 5xx rule. - Retry-After is honored on every retryable status rather than 429 alone, capped at 300s (rate_limit_retry_after_cap). - Two budgets, both defaulting to 12h. A response carrying Retry-After takes a rate-limit path bounded by elapsed time (max_rate_limit_duration) that spends no retry count; everything else takes counted exponential backoff (retries 10, max_total_backoff_duration). Both live in a new RetryBudget class, which reports a delay or nil once spent; Transport still performs the sleep, keeping the seam specs stub. - retries: N grants exactly N. The budget was decremented before the exhaustion check, so N performed N-1 and 1 performed none. - Backoff is clamped to max_timeout_ms first and jittered downward afterward, so the ceiling stays hard and clients that back off together do not retry in lockstep. - Network errors (ECONNRESET, DNS failures, read timeouts) go through the counted backoff budget rather than dropping the batch on first occurrence. - Only 2xx counts as a successful upload. Worker checks Response#success? instead of status == 200, so a 201 or 204 is no longer reported as an error. A 3xx is a non-retryable failure that logs the status and the configured host: Net::HTTP never follows redirects, so a raw 3xx means nothing was uploaded. TAPI does not emit 3xx; this matters because host is customer-configurable. Correctness - Duration budgets use Process::CLOCK_MONOTONIC, so a clock adjustment cannot expire or extend them. HTTP-date parsing and sentAt stay on wall clock. - Retry waits are sliced and check Thread.current[:should_exit] between slices, so shutdown is noticed within a second rather than being held for up to the 300s Retry-After cap. at_exit sets the flag only; Thread#wakeup raises ThreadError on a thread that has just finished, and an unrescued raise there forces the process to exit 1. - backoff_policy.reset! is guarded by respond_to?, since the older documented contract was next_interval alone. A policy without it now warns at construction: one instance serves every batch, so without reset! attempt counts accumulate and retries get slower the longer a process runs. Notes - RetryBudget takes an options hash rather than keyword arguments; the gemspec declares required_ruby_version >= 2.0, where required keyword arguments are a syntax error. - Metrics/ClassLength for transport.rb is raised to 167 in .rubocop_todo.yml. Regenerating that file wholesale rewrites ~100 unrelated lines, since it was last generated by rubocop 1.44 in 2023. 212 examples, rubocop clean on lib/ and spec/, and all 61 e2e tests pass.
1 parent 170a8fc commit a2b80fb

17 files changed

Lines changed: 715 additions & 99 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22
Gemfile.lock
33
.ruby-version
44
coverage/
5+
.bundle/
6+
vendor/
7+
*-plan.md

.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: 167
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).

History.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
Unreleased
2+
==========
3+
4+
### Upgrade note: new request header and proxy allowlists
5+
6+
This release sends an `X-Retry-Count` request header on retries. If your
7+
traffic to Segment goes through a proxy, gateway or WAF that allowlists
8+
request headers, add it before upgrading or retried uploads will be
9+
rejected. The `Authorization` header is unchanged: this client has always
10+
sent the write key as HTTP Basic credentials.
11+
12+
* Send `X-Retry-Count` on retries, so the server can distinguish a retry from a first attempt. Omitted on the first attempt.
13+
* Unified retry handling: 429, 408, 410, 460 and 5xx (except 501, 505 and 511) are retried. `Retry-After` is honoured on all of them, not just 429, which brings 529 in through the generic 5xx rule.
14+
* `Retry-After` accepts numeric seconds and the RFC 7231 HTTP-date formats, capped at 300s (`rate_limit_retry_after_cap`).
15+
* Rate-limited retries are bounded by elapsed time rather than counted against the retry limit, so a long `Retry-After` no longer exhausts the budget.
16+
* New options `max_total_backoff_duration` and `max_rate_limit_duration` (default 12 hours each) bound the two waits.
17+
* Only 2xx responses count as a successful upload. A 3xx is now reported as a failed upload rather than silently treated as delivered. It is not retried: a redirect `Net::HTTP` already declined to follow will not succeed on a retry. The Segment endpoint does not redirect, so this only affects custom `host` values.
18+
* Network errors are retried on the same backoff schedule as failed responses instead of dropping the batch.
19+
* Backoff waits no longer block shutdown for the full delay.
20+
* Retry timing uses a monotonic clock, so a system clock change cannot stretch or collapse a backoff.
21+
* Backoff intervals are now jittered at the ceiling as well, so clients that back off together do not retry in lockstep.
22+
* **Default backoff pacing changed**: the base wait is 500ms (was 100ms), the ceiling is 60s (was 10s), and the multiplier is 2 (was 1.5). This aligns ruby with the other Segment SDKs, but it does mean a retry schedule that was previously 100ms, 150ms, 225ms… now starts at 500ms and climbs faster. Set `min_timeout_ms`, `max_timeout_ms` and `multiplier` on a `BackoffPolicy` to keep the old pacing.
23+
* A `backoff_policy` supplied by the caller that does not implement `reset!` now logs a warning. One policy instance serves every batch, so without `reset!` its attempt count accumulates and retries get slower the longer the process runs.
24+
* Fix `retries` granting one fewer attempt than configured. A configured 10 performed 9, and `retries: 1` performed none at all.
25+
126
2.5.0 / 2024-07-17
227
==================
328

e2e-cli/e2e-config.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
22
"sdk": "ruby",
3-
"test_suites": "basic",
3+
"test_suites": "basic,retry",
44
"auto_settings": false,
55
"patch": null,
6-
"env": {}
6+
"env": {
7+
"AUTH_HEADER": "true"
8+
}
79
}

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'

lib/segment/analytics/backoff_policy.rb

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,17 @@ def initialize(opts = {})
2626
# @return [Numeric] the next backoff interval, in milliseconds.
2727
def next_interval
2828
interval = @min_timeout_ms * (@multiplier**@attempts)
29-
interval = add_jitter(interval, @randomization_factor)
30-
3129
@attempts += 1
3230

33-
[interval, @max_timeout_ms].min
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)
3436
end
3537

36-
private
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
4840
end
4941
end
5042
end

lib/segment/analytics/client.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ def initialize(opts = {})
3232

3333
check_write_key!
3434

35-
at_exit { @worker_thread && @worker_thread[:should_exit] = true }
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 }
3640
end
3741

3842
# Synchronously waits until the worker has flushed the queue.

lib/segment/analytics/defaults.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ module Request
1212
'Content-Type' => 'application/json',
1313
'User-Agent' => "analytics-ruby/#{Analytics::VERSION}" }
1414
RETRIES = 10
15+
MAX_TOTAL_BACKOFF_DURATION = 43_200 # 12 hours in seconds
16+
MAX_RATE_LIMIT_DURATION = 43_200 # 12 hours in seconds
17+
RATE_LIMIT_RETRY_AFTER_CAP = 300 # seconds
1518
end
1619

1720
module Queue
@@ -28,9 +31,9 @@ module MessageBatch
2831
end
2932

3033
module BackoffPolicy
31-
MIN_TIMEOUT_MS = 100
32-
MAX_TIMEOUT_MS = 10000
33-
MULTIPLIER = 1.5
34+
MIN_TIMEOUT_MS = 500
35+
MAX_TIMEOUT_MS = 60_000
36+
MULTIPLIER = 2
3437
RANDOMIZATION_FACTOR = 0.5
3538
end
3639
end

lib/segment/analytics/response.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ def initialize(status = 200, error = nil)
1212
@status = status
1313
@error = error
1414
end
15+
16+
def success?
17+
status >= 200 && status < 300
18+
end
1519
end
1620
end
1721
end
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
@backoff_start_time ||= monotonic_now
41+
return spent('Max total backoff duration exceeded for batch') if elapsed?(@backoff_start_time, @max_total_backoff_duration)
42+
43+
delay_ms = @backoff_policy.next_interval
44+
@logger.debug("Retrying request, #{@retries_remaining} retries left. Waiting #{delay_ms}ms")
45+
delay_ms.to_f / 1000
46+
end
47+
48+
def next_rate_limit_delay(retry_after, status_code)
49+
@rate_limit_start_time ||= monotonic_now
50+
return spent('Max rate limit duration exceeded for batch') if elapsed?(@rate_limit_start_time, @max_rate_limit_duration)
51+
52+
delay = [retry_after, @rate_limit_retry_after_cap].min
53+
@logger.debug("Retry-After: #{delay}s on #{status_code}. Retrying after delay.")
54+
delay
55+
end
56+
57+
def record_retry
58+
@retry_count += 1
59+
end
60+
61+
private
62+
63+
def elapsed?(start_time, limit)
64+
(monotonic_now - start_time) >= limit
65+
end
66+
67+
# Wall-clock time can jump; these budgets must not expire or stretch with it.
68+
def monotonic_now
69+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
70+
end
71+
72+
def spent(message)
73+
@logger.error(message)
74+
nil
75+
end
76+
end
77+
end
78+
end

0 commit comments

Comments
 (0)