Skip to content

Commit 6a10b7c

Browse files
MichaelGHSegclaude
andcommitted
Address PR review feedback
- Extract duplicate exponential backoff calculation into helper function - Add upper bound (max_total_attempts) to prevent infinite retry loops with Retry-After - Improves code maintainability and prevents edge case of continuous Retry-After responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e1ac214 commit 6a10b7c

File tree

2 files changed

+41
-22
lines changed

2 files changed

+41
-22
lines changed

segment/analytics/consumer.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,22 @@ def should_use_retry_after(status):
140140
"""Check if status code should respect Retry-After header"""
141141
return status in (408, 429, 503)
142142

143+
def calculate_backoff_delay(attempt):
144+
"""
145+
Calculate exponential backoff delay with jitter.
146+
First retry is immediate, then 0.5s, 1s, 2s, 4s, etc.
147+
"""
148+
if attempt == 1:
149+
return 0 # First retry is immediate
150+
base_delay = 0.5 * (2 ** (attempt - 2))
151+
jitter = random.uniform(0, 0.1 * base_delay)
152+
return min(base_delay + jitter, 60) # Cap at 60 seconds
153+
143154
total_attempts = 0
144155
backoff_attempts = 0
145156
max_backoff_attempts = self.retries + 1
157+
# Prevent infinite retry loops even with Retry-After
158+
max_total_attempts = max_backoff_attempts * 10
146159

147160
while True:
148161
try:
@@ -168,6 +181,13 @@ def should_use_retry_after(status):
168181
except APIError as e:
169182
total_attempts += 1
170183

184+
# Prevent infinite retry loops
185+
if total_attempts >= max_total_attempts:
186+
self.log.error(
187+
f"Maximum total attempts ({max_total_attempts}) reached after {total_attempts} attempts. Final error: {e}"
188+
)
189+
raise
190+
171191
# Check if we should use Retry-After header
172192
if should_use_retry_after(e.status) and e.response:
173193
retry_after = parse_retry_after(e.response)
@@ -194,9 +214,7 @@ def should_use_retry_after(status):
194214
raise
195215

196216
# Calculate exponential backoff delay with jitter
197-
base_delay = 0.5 * (2 ** (backoff_attempts - 1))
198-
jitter = random.uniform(0, 0.1 * base_delay)
199-
delay = min(base_delay + jitter, 60) # Cap at 60 seconds
217+
delay = calculate_backoff_delay(backoff_attempts)
200218

201219
self.log.debug(
202220
f"Retry attempt {backoff_attempts}/{self.retries} (total attempts: {total_attempts}) "
@@ -209,16 +227,21 @@ def should_use_retry_after(status):
209227
total_attempts += 1
210228
backoff_attempts += 1
211229

230+
# Prevent infinite retry loops
231+
if total_attempts >= max_total_attempts:
232+
self.log.error(
233+
f"Maximum total attempts ({max_total_attempts}) reached after {total_attempts} attempts. Final error: {e}"
234+
)
235+
raise
236+
212237
if backoff_attempts >= max_backoff_attempts:
213238
self.log.error(
214239
f"All {self.retries} retries exhausted after {total_attempts} total attempts. Final error: {e}"
215240
)
216241
raise
217242

218243
# Calculate exponential backoff delay with jitter
219-
base_delay = 0.5 * (2 ** (backoff_attempts - 1))
220-
jitter = random.uniform(0, 0.1 * base_delay)
221-
delay = min(base_delay + jitter, 60) # Cap at 60 seconds
244+
delay = calculate_backoff_delay(backoff_attempts)
222245

223246
self.log.debug(
224247
f"Network error retry {backoff_attempts}/{self.retries} (total attempts: {total_attempts}) "

segment/analytics/test/test_consumer.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -469,13 +469,12 @@ def mock_sleep(duration):
469469
self.assertEqual(len(sleep_durations), 3)
470470

471471
# Delays should be increasing (exponential)
472-
# First: ~0.5s, Second: ~1s, Third: ~2s (with jitter)
473-
self.assertGreater(sleep_durations[0], 0.4)
474-
self.assertLess(sleep_durations[0], 1.0)
475-
self.assertGreater(sleep_durations[1], 0.9)
476-
self.assertLess(sleep_durations[1], 2.0)
477-
self.assertGreater(sleep_durations[2], 1.8)
478-
self.assertLess(sleep_durations[2], 4.0)
472+
# First: 0s (immediate), Second: ~0.5s, Third: ~1s (with jitter)
473+
self.assertEqual(sleep_durations[0], 0) # First retry is immediate
474+
self.assertGreater(sleep_durations[1], 0.4)
475+
self.assertLess(sleep_durations[1], 0.6)
476+
self.assertGreater(sleep_durations[2], 0.9)
477+
self.assertLess(sleep_durations[2], 1.2)
479478

480479
def test_fatal_error_not_retried(self):
481480
"""Test that FatalError is not retried"""
@@ -572,11 +571,10 @@ def mock_sleep(duration):
572571
self.assertEqual(call_count, 2)
573572
self.assertEqual(retry_counts, [0, 1])
574573

575-
# Should use backoff delay (around 0.5s with jitter)
574+
# First retry should be immediate (0s delay)
576575
self.assertIsNotNone(sleep_duration)
577576
if sleep_duration is not None:
578-
self.assertGreater(sleep_duration, 0.4)
579-
self.assertLess(sleep_duration, 1.0)
577+
self.assertEqual(sleep_duration, 0)
580578

581579
def test_408_without_retry_after_uses_backoff(self):
582580
"""T10: 408 without Retry-After header uses backoff retry"""
@@ -613,11 +611,10 @@ def mock_sleep(duration):
613611
self.assertEqual(call_count, 2)
614612
self.assertEqual(retry_counts, [0, 1])
615613

616-
# Should use backoff delay
614+
# First retry should be immediate (0s delay)
617615
self.assertIsNotNone(sleep_duration)
618616
if sleep_duration is not None:
619-
self.assertGreater(sleep_duration, 0.4)
620-
self.assertLess(sleep_duration, 1.0)
617+
self.assertEqual(sleep_duration, 0)
621618

622619
def test_network_error_retried_with_backoff(self):
623620
"""T15: Network/IO error is retried with backoff"""
@@ -651,11 +648,10 @@ def mock_sleep(duration):
651648
self.assertEqual(call_count, 2)
652649
self.assertEqual(retry_counts, [0, 1])
653650

654-
# Should use backoff delay
651+
# First retry should be immediate (0s delay)
655652
self.assertIsNotNone(sleep_duration)
656653
if sleep_duration is not None:
657-
self.assertGreater(sleep_duration, 0.4)
658-
self.assertLess(sleep_duration, 1.0)
654+
self.assertEqual(sleep_duration, 0)
659655

660656
def test_511_is_retryable(self):
661657
"""T05: 511 status code is retryable (part of 5xx family, not in non-retryable list)"""

0 commit comments

Comments
 (0)