Skip to content

Commit 7ae1ac9

Browse files
committed
Grant the configured number of retries, and drop the at_exit wakeup
Two fixes. RetryBudget decremented @retries_remaining before testing it, so one retry was spent on the exhaustion check itself: a configured N performed N-1, and retries: 1 and retries: 0 both performed none. go, python and java all grant N. The existing spec asserted `.exactly(retries - 1).times`, so it codified the bug and would have passed either way; it now asserts N, and the new retry_budget_spec covers the 1-and-0 cases that were previously indistinguishable. Both fail without this change. The at_exit block called Thread#wakeup on the worker. That raises ThreadError if the thread finished between the alive? check and the call, and an unrescued raise inside at_exit forces the process to exit 1 — a spurious failure for any script or CI step using this client. Reproduced under the pinned ruby 3.2. Rescuing the error would have left the other half broken: wakeup only cuts short a sleep already in progress, so one arriving while the worker is mid-request is lost and the next sleep runs in full, which is the hang the wakeup was added to prevent. The retry wait is now sliced and checks should_exit between slices, so shutdown is noticed within a second without needing wakeup at all. Same shape as python's interruptible wait. Specs stub the new interruptible_sleep seam instead of sleep, and must return truthy — a nil return reads as "shutting down" and stops the retry. Metrics/ClassLength for transport.rb goes 155 -> 161. Regenerating .rubocop_todo.yml as the repo's CLAUDE.md suggests rewrote ~100 unrelated lines (rubocop 1.90 against a file generated by 1.44, plus e2e-cli files that were not previously inspected), so this is the minimal edit instead. 210 examples pass, rubocop is clean on lib and spec, and the 61-test e2e suite passes.
1 parent 7cd5b02 commit 7ae1ac9

7 files changed

Lines changed: 133 additions & 34 deletions

File tree

‎.rubocop_todo.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Metrics/BlockLength:
7373
# Offense count: 1
7474
# Configuration parameters: CountComments, CountAsOne.
7575
Metrics/ClassLength:
76-
Max: 155
76+
Max: 161
7777

7878
# Offense count: 2
7979
# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods.

‎History.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ sent the write key as HTTP Basic credentials.
1919
* Backoff waits no longer block shutdown for the full delay.
2020
* Retry timing uses a monotonic clock, so a system clock change cannot stretch or collapse a backoff.
2121
* Backoff intervals are now jittered at the ceiling as well, so clients that back off together do not retry in lockstep.
22+
* Fix `retries` granting one fewer attempt than configured. A configured 10 performed 9, and `retries: 1` performed none at all.
2223

2324
2.5.0 / 2024-07-17
2425
==================

‎lib/segment/analytics/client.rb‎

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

3333
check_write_key!
3434

35-
at_exit do
36-
if @worker_thread
37-
@worker_thread[:should_exit] = true
38-
# Break any Retry-After or backoff sleep so shutdown is not held for
39-
# up to rate_limit_retry_after_cap seconds.
40-
@worker_thread.wakeup if @worker_thread.alive?
41-
end
42-
end
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 }
4340
end
4441

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

‎lib/segment/analytics/retry_budget.rb‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@ def initialize(options = {})
3030
end
3131

3232
def next_backoff_delay
33-
@retries_remaining -= 1
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.
3436
return spent('Retries exhausted for batch') if @retries_remaining <= 0
3537

38+
@retries_remaining -= 1
39+
3640
@backoff_start_time ||= monotonic_now
3741
return spent('Max total backoff duration exceeded for batch') if elapsed?(@backoff_start_time, @max_total_backoff_duration)
3842

‎lib/segment/analytics/transport.rb‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ def build_http(options)
120120
http
121121
end
122122

123+
# How long a sliced retry wait sleeps before re-checking for shutdown.
124+
SHUTDOWN_CHECK_INTERVAL = 1
125+
123126
def new_retry_budget
124127
RetryBudget.new(
125128
:retries => @retries,
@@ -135,15 +138,31 @@ def new_retry_budget
135138
# RetryBudget keeps the wait on Transport, where callers stub it.
136139
def wait_to_retry(delay, budget)
137140
return false if delay.nil?
138-
139-
sleep(delay)
140-
# Client#shutdown wakes this thread, so the sleep above returns early.
141-
return false if Thread.current[:should_exit]
141+
return false unless interruptible_sleep(delay)
142142

143143
budget.record_retry
144144
true
145145
end
146146

147+
# Sleeps in slices so shutdown is noticed within SHUTDOWN_CHECK_INTERVAL
148+
# rather than after the whole delay, which can be rate_limit_retry_after_cap
149+
# seconds. Returns false if shutdown was requested.
150+
#
151+
# Thread#wakeup is deliberately not used for this: it only cuts short a sleep
152+
# already in progress, so a wakeup arriving while the worker is mid-request is
153+
# lost and the next sleep still runs in full.
154+
def interruptible_sleep(seconds)
155+
remaining = seconds
156+
while remaining > 0
157+
return false if Thread.current[:should_exit]
158+
159+
slice = [remaining, SHUTDOWN_CHECK_INTERVAL].min
160+
sleep(slice)
161+
remaining -= slice
162+
end
163+
!Thread.current[:should_exit]
164+
end
165+
147166
def parse_error(body)
148167
JSON.parse(body)['error']
149168
rescue StandardError
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
module Segment
6+
class Analytics
7+
describe RetryBudget do
8+
let(:logger) { Logger.new(File::NULL) }
9+
10+
def budget(retries, intervals = nil)
11+
described_class.new(
12+
:retries => retries,
13+
:backoff_policy => FakeBackoffPolicy.new(intervals || Array.new(retries, 1000)),
14+
:max_total_backoff_duration => 43_200,
15+
:max_rate_limit_duration => 43_200,
16+
:rate_limit_retry_after_cap => 300,
17+
:logger => logger
18+
)
19+
end
20+
21+
describe '#next_backoff_delay' do
22+
it 'grants exactly as many retries as configured' do
23+
# The count used to be decremented before the exhaustion check, so a
24+
# configured N yielded N-1. go, python and java all grant N.
25+
subject = budget(3)
26+
27+
expect(subject.next_backoff_delay).to eq(1.0)
28+
expect(subject.next_backoff_delay).to eq(1.0)
29+
expect(subject.next_backoff_delay).to eq(1.0)
30+
expect(subject.next_backoff_delay).to be_nil
31+
end
32+
33+
it 'grants one retry for retries: 1' do
34+
subject = budget(1)
35+
36+
expect(subject.next_backoff_delay).to eq(1.0)
37+
expect(subject.next_backoff_delay).to be_nil
38+
end
39+
40+
it 'grants no retries for retries: 0' do
41+
# retries: 0 and retries: 1 were previously indistinguishable.
42+
subject = budget(0, [1000])
43+
44+
expect(subject.next_backoff_delay).to be_nil
45+
end
46+
end
47+
end
48+
end
49+
end

‎spec/segment/analytics/transport_spec.rb‎

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -163,18 +163,18 @@ class Analytics
163163
let(:status_code) { status_code }
164164
let(:body) { body }
165165
let(:retries) { 4 }
166-
let(:backoff_policy) { FakeBackoffPolicy.new([1000, 1000, 1000]) }
166+
let(:backoff_policy) { FakeBackoffPolicy.new([1000, 1000, 1000, 1000]) }
167167
subject {
168168
described_class.new(retries: retries,
169169
backoff_policy: backoff_policy)
170170
}
171171

172172
it 'retries the request' do
173173
expect(subject)
174-
.to receive(:sleep)
175-
.exactly(retries - 1).times
174+
.to receive(:interruptible_sleep)
175+
.exactly(retries).times
176176
.with(1)
177-
.and_return(nil)
177+
.and_return(true)
178178
subject.send(write_key, batch)
179179
end
180180
end
@@ -188,7 +188,7 @@ class Analytics
188188

189189
it 'does not retry the request' do
190190
expect(subject)
191-
.to receive(:sleep)
191+
.to receive(:interruptible_sleep)
192192
.never
193193
subject.send(write_key, batch)
194194
end
@@ -208,7 +208,7 @@ class Analytics
208208
context '3xx is not retried and is not success' do
209209
let(:status_code) { 301 }
210210
it 'returns the status without retrying, and does not report success' do
211-
expect(subject).not_to receive(:sleep)
211+
expect(subject).not_to receive(:interruptible_sleep)
212212
response = subject.send(write_key, batch)
213213
expect(response.status).to eq(301)
214214
expect(response.success?).to be false
@@ -261,18 +261,18 @@ class Analytics
261261
end
262262

263263
it 'sleeps for the Retry-After duration' do
264-
expect(subject).to receive(:sleep).with(2).once
264+
expect(subject).to receive(:interruptible_sleep).with(2).once.and_return(true)
265265
subject.send(write_key, batch)
266266
end
267267

268268
it 'caps Retry-After at RATE_LIMIT_RETRY_AFTER_CAP' do
269269
allow(response).to receive(:to_hash) { { 'retry-after' => ['9999'] } }
270-
expect(subject).to receive(:sleep).with(described_class::RATE_LIMIT_RETRY_AFTER_CAP).once
270+
expect(subject).to receive(:interruptible_sleep).with(described_class::RATE_LIMIT_RETRY_AFTER_CAP).once.and_return(true)
271271
subject.send(write_key, batch)
272272
end
273273

274274
it 'returns success after retry' do
275-
allow(subject).to receive(:sleep)
275+
allow(subject).to receive(:interruptible_sleep).and_return(true)
276276
expect(subject.send(write_key, batch).success?).to be true
277277
end
278278
end
@@ -291,12 +291,12 @@ class Analytics
291291
end
292292

293293
it 'sleeps for the Retry-After duration' do
294-
expect(subject).to receive(:sleep).with(2).once
294+
expect(subject).to receive(:interruptible_sleep).with(2).once.and_return(true)
295295
subject.send(write_key, batch)
296296
end
297297

298298
it 'does not decrement retries_remaining (uses rate-limit path)' do
299-
allow(subject).to receive(:sleep)
299+
allow(subject).to receive(:interruptible_sleep).and_return(true)
300300
# With retries: 1, a 503+Retry-After should NOT exhaust retries because
301301
# it uses the rate-limit path (no retry budget cost)
302302
transport = described_class.new(retries: 1, backoff_policy: FakeBackoffPolicy.new([1000]))
@@ -306,7 +306,7 @@ class Analytics
306306
allow(success_response).to receive(:body) { '{}' }
307307
allow(success_response).to receive(:to_hash) { {} }
308308
allow(http).to receive(:request).and_return(response, success_response)
309-
allow(transport).to receive(:sleep)
309+
allow(transport).to receive(:interruptible_sleep).and_return(true)
310310
result = transport.send(write_key, batch)
311311
expect(result.status).to eq(200)
312312
end
@@ -326,12 +326,12 @@ class Analytics
326326
end
327327

328328
it 'sleeps for the Retry-After duration' do
329-
expect(subject).to receive(:sleep).with(1).once
329+
expect(subject).to receive(:interruptible_sleep).with(1).once.and_return(true)
330330
subject.send(write_key, batch)
331331
end
332332

333333
it 'returns success after retry' do
334-
allow(subject).to receive(:sleep)
334+
allow(subject).to receive(:interruptible_sleep).and_return(true)
335335
expect(subject.send(write_key, batch).success?).to be true
336336
end
337337

@@ -344,19 +344,19 @@ class Analytics
344344
allow(success_response).to receive(:body) { '{}' }
345345
allow(success_response).to receive(:to_hash) { {} }
346346
allow(http).to receive(:request).and_return(response, success_response)
347-
allow(transport).to receive(:sleep)
347+
allow(transport).to receive(:interruptible_sleep).and_return(true)
348348
result = transport.send(write_key, batch)
349349
expect(result.status).to eq(200)
350350
end
351351
end
352352

353353
context 'X-Retry-Count header' do
354354
let(:status_code) { 500 }
355-
let(:backoff_policy) { FakeBackoffPolicy.new([1, 1]) }
355+
let(:backoff_policy) { FakeBackoffPolicy.new([1, 1, 1]) }
356356
subject { described_class.new(retries: 3, backoff_policy: backoff_policy) }
357357

358358
it 'does not send X-Retry-Count on first attempt' do
359-
allow(subject).to receive(:sleep)
359+
allow(subject).to receive(:interruptible_sleep).and_return(true)
360360
first_request = nil
361361
http = subject.instance_variable_get(:@http)
362362
allow(http).to receive(:request) do |req, _|
@@ -368,7 +368,7 @@ class Analytics
368368
end
369369

370370
it 'sends X-Retry-Count incrementing on retries' do
371-
allow(subject).to receive(:sleep)
371+
allow(subject).to receive(:interruptible_sleep).and_return(true)
372372
requests = []
373373
http = subject.instance_variable_get(:@http)
374374
allow(http).to receive(:request) do |req, _|
@@ -378,6 +378,7 @@ class Analytics
378378
subject.send(write_key, batch)
379379
expect(requests[1]['X-Retry-Count']).to eq('1')
380380
expect(requests[2]['X-Retry-Count']).to eq('2')
381+
expect(requests[3]['X-Retry-Count']).to eq('3')
381382
end
382383
end
383384

@@ -395,7 +396,7 @@ class Analytics
395396

396397
success_response
397398
end
398-
allow(subject).to receive(:sleep)
399+
allow(subject).to receive(:interruptible_sleep).and_return(true)
399400

400401
response = subject.send(write_key, batch)
401402

@@ -406,7 +407,7 @@ class Analytics
406407
it 'gives up once the retry budget is spent' do
407408
http = subject.instance_variable_get(:@http)
408409
allow(http).to receive(:request).and_raise(Errno::ECONNRESET, 'reset')
409-
allow(subject).to receive(:sleep)
410+
allow(subject).to receive(:interruptible_sleep).and_return(true)
410411

411412
response = subject.send(write_key, batch)
412413

@@ -480,6 +481,34 @@ class Analytics
480481
end
481482
end
482483
end
484+
485+
describe '#interruptible_sleep' do
486+
subject { described_class.new }
487+
488+
it 'abandons the wait when shutdown is requested instead of sleeping it out' do
489+
# The wait used to be a single sleep broken by Thread#wakeup, which only
490+
# interrupts a sleep already in progress and raises ThreadError if the
491+
# thread has finished. Slicing removes the need for it.
492+
elapsed = nil
493+
494+
worker = Thread.new do
495+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
496+
result = subject.__send__(:interruptible_sleep, 30)
497+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
498+
result
499+
end
500+
501+
sleep 0.1
502+
worker[:should_exit] = true
503+
504+
expect(worker.value).to be false
505+
expect(elapsed).to be < 5
506+
end
507+
508+
it 'reports completion when the delay elapses' do
509+
expect(Thread.new { subject.__send__(:interruptible_sleep, 0) }.value).to be true
510+
end
511+
end
483512
end
484513
end
485514
end

0 commit comments

Comments
 (0)