Skip to content

Commit ad198ef

Browse files
committed
Address all issues from deep code review
- Remove unused backoff dependency from setup.py and requirements.txt - Reject None for max_total_backoff_duration/max_rate_limit_duration in client.py - Fix off-by-one: use >= for max_total_backoff_duration check - Fix Retry-After: 0 tight loop: fall through to counted backoff instead of pipeline-blocking - Log and call on_error when queue is full during 429 re-queue - Document flush() blocking behavior in docstring - Add comment explaining 410 retryable parity with Node SDK - Extract duplicate backoff logic into apply_backoff() helper - Warn on unrecognized Retry-After format (HTTP-date) instead of silently ignoring - Add comments for task_done() invariant and FatalError origin - Add 8 new tests covering all previously missing coverage gaps
1 parent 8c5680a commit ad198ef

7 files changed

Lines changed: 346 additions & 101 deletions

File tree

requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
backoff==2.2.1
21
cryptography==44.0.0
32
flake8==7.1.1
43
mock==2.0.0

segment/analytics/client.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ def __init__(self,
7272
max_rate_limit_duration=DefaultConfig.max_rate_limit_duration,):
7373
require('write_key', write_key, str)
7474

75-
if max_total_backoff_duration is not None and max_total_backoff_duration < 0:
76-
raise ValueError('max_total_backoff_duration must be non-negative')
77-
if max_rate_limit_duration is not None and max_rate_limit_duration < 0:
78-
raise ValueError('max_rate_limit_duration must be non-negative')
75+
if max_total_backoff_duration is None or max_total_backoff_duration < 0:
76+
raise ValueError('max_total_backoff_duration must be a non-negative number')
77+
if max_rate_limit_duration is None or max_rate_limit_duration < 0:
78+
raise ValueError('max_rate_limit_duration must be a non-negative number')
7979

8080
self.queue = queue.Queue(max_queue_size)
8181
self.write_key = write_key
@@ -331,7 +331,12 @@ def _enqueue(self, msg):
331331
return False, msg
332332

333333
def flush(self):
334-
"""Forces a flush from the internal queue to the server"""
334+
"""Forces a flush from the internal queue to the server.
335+
336+
Warning: if the consumer is currently rate-limited, this call will
337+
block until the rate limit clears or max_rate_limit_duration elapses
338+
(up to 12 hours by default).
339+
"""
335340
queue = self.queue
336341
size = queue.qsize()
337342
queue.join()

segment/analytics/consumer.py

Lines changed: 51 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,16 @@ def upload(self):
136136
if e.status == 429 and self.rate_limited_until is not None:
137137
# 429: rate-limit state already set by request(). Re-queue batch.
138138
self.log.debug('429 received. Re-queuing batch and halting upload iteration.')
139+
dropped = []
139140
for item in batch:
140141
try:
141142
self.queue.put(item, block=False)
142143
except Exception:
143-
pass # Queue full, item lost
144+
dropped.append(item)
145+
if dropped:
146+
self.log.error('Queue full during 429 re-queue. Dropping %d item(s).', len(dropped))
147+
if self.on_error:
148+
self.on_error(Exception('Queue full, items dropped during 429 re-queue'), dropped)
144149
success = False
145150
else:
146151
self.log.error('error uploading: %s', e)
@@ -153,7 +158,9 @@ def upload(self):
153158
if self.on_error:
154159
self.on_error(e, batch)
155160
finally:
156-
# mark items as acknowledged from queue
161+
# Each item in batch was obtained via queue.get() and must have
162+
# exactly one matching task_done() call — including re-queued items,
163+
# which will produce a new task_done() obligation on their next get().
157164
for _ in batch:
158165
self.queue.task_done()
159166
return success
@@ -196,14 +203,13 @@ def request(self, batch):
196203
"""Attempt to upload the batch and retry before raising an error"""
197204

198205
def is_retryable_status(status):
199-
"""
200-
Determine if a status code is retryable.
201-
Retryable 4xx: 408, 410, 429, 460
202-
Non-retryable 4xx: 400, 401, 403, 404, 413, 422, and all other 4xx
203-
Retryable 5xx: All except 501, 505
204-
- 511 is only retryable when OauthManager is configured
205-
Non-retryable 5xx: 501, 505
206-
"""
206+
# Retryable 4xx: 408, 429, 460
207+
# 410 Gone: permanently removed, but included for parity with the
208+
# Node.js SDK. Retrying is harmless since the server will keep
209+
# returning 410, and the retry budget caps total attempts.
210+
# Non-retryable 4xx: 400, 401, 403, 404, 413, 422, and all other 4xx
211+
# Retryable 5xx: all except 501, 505
212+
# 511: only retryable when OauthManager is configured
207213
if 400 <= status < 500:
208214
return status in (408, 410, 429, 460)
209215
elif 500 <= status < 600:
@@ -215,15 +221,36 @@ def is_retryable_status(status):
215221
return False
216222

217223
def calculate_backoff_delay(attempt):
218-
"""
219-
Calculate exponential backoff delay with jitter.
220-
First retry is immediate, then 0.5s, 1s, 2s, 4s, etc.
221-
"""
224+
# First retry is immediate; thereafter 0.5s, 1s, 2s, 4s… capped at 60s
222225
if attempt == 1:
223-
return 0 # First retry is immediate
226+
return 0
224227
base_delay = 0.5 * (2 ** (attempt - 2))
225228
jitter = random.uniform(0, 0.1 * base_delay)
226-
return min(base_delay + jitter, 60) # Cap at 60 seconds
229+
return min(base_delay + jitter, 60)
230+
231+
def apply_backoff(e, label):
232+
"""Apply retry backoff logic. Returns delay if should retry, raises if exhausted."""
233+
nonlocal first_failure_time, backoff_attempts
234+
if first_failure_time is None:
235+
first_failure_time = time.time()
236+
if time.time() - first_failure_time >= self.max_total_backoff_duration:
237+
self.log.error(
238+
f"Max total backoff duration ({self.max_total_backoff_duration}s) exceeded "
239+
f"after {total_attempts} attempts. Final error: {e}"
240+
)
241+
raise e
242+
backoff_attempts += 1
243+
if backoff_attempts >= self.retries + 1:
244+
self.log.error(
245+
f"All {self.retries} retries exhausted after {total_attempts} total attempts. Final error: {e}"
246+
)
247+
raise e
248+
delay = calculate_backoff_delay(backoff_attempts)
249+
self.log.debug(
250+
f"{label} {backoff_attempts}/{self.retries} (total attempts: {total_attempts}) "
251+
f"after {delay:.2f}s: {e}"
252+
)
253+
return delay
227254

228255
total_attempts = 0
229256
backoff_attempts = 0
@@ -233,7 +260,6 @@ def calculate_backoff_delay(attempt):
233260
total_attempts += 1
234261

235262
try:
236-
# Make the request with current retry count
237263
response = post(
238264
self.write_key,
239265
self.host,
@@ -244,82 +270,33 @@ def calculate_backoff_delay(attempt):
244270
oauth_manager=self.oauth_manager,
245271
retry_count=total_attempts - 1
246272
)
247-
# Success
248273
return response
249274

250275
except FatalError as e:
251-
# Non-retryable error
276+
# Raised by oauth_manager when token refresh fails permanently;
277+
# not safe to retry.
252278
self.log.error(f"Fatal error after {total_attempts} attempts: {e}")
253279
raise
254280

255281
except APIError as e:
256-
# 429 with valid Retry-After: set rate-limit state and raise
257-
# to caller (pipeline blocking). Without Retry-After, fall
258-
# through to counted backoff like any other retryable error.
282+
# 429 with valid Retry-After > 0: block the pipeline and let
283+
# upload() re-queue the batch. Retry-After: 0 or missing falls
284+
# through to counted backoff to avoid a tight re-queue loop.
259285
if e.status == 429:
260286
retry_after = parse_retry_after(e.response) if e.response is not None else None
261-
if retry_after is not None:
287+
if retry_after is not None and retry_after > 0:
262288
self.set_rate_limit_state(e.response)
263289
raise
264290

265-
# Check if status is retryable
266291
if not is_retryable_status(e.status):
267292
self.log.error(
268293
f"Non-retryable error {e.status} after {total_attempts} attempts: {e}"
269294
)
270295
raise
271296

272-
# Transient error -- per-batch backoff
273-
if first_failure_time is None:
274-
first_failure_time = time.time()
275-
if time.time() - first_failure_time > self.max_total_backoff_duration:
276-
self.log.error(
277-
f"Max total backoff duration ({self.max_total_backoff_duration}s) exceeded "
278-
f"after {total_attempts} attempts. Final error: {e}"
279-
)
280-
raise
281-
282-
# Count this against backoff attempts
283-
backoff_attempts += 1
284-
if backoff_attempts >= self.retries + 1:
285-
self.log.error(
286-
f"All {self.retries} retries exhausted after {total_attempts} total attempts. Final error: {e}"
287-
)
288-
raise
289-
290-
# Calculate exponential backoff delay with jitter
291-
delay = calculate_backoff_delay(backoff_attempts)
292-
293-
self.log.debug(
294-
f"Retry attempt {backoff_attempts}/{self.retries} (total attempts: {total_attempts}) "
295-
f"after {delay:.2f}s for status {e.status}"
296-
)
297+
delay = apply_backoff(e, f"Retry attempt (status {e.status})")
297298
time.sleep(delay)
298299

299300
except Exception as e:
300-
# Network errors or other exceptions - retry with backoff
301-
if first_failure_time is None:
302-
first_failure_time = time.time()
303-
if time.time() - first_failure_time > self.max_total_backoff_duration:
304-
self.log.error(
305-
f"Max total backoff duration ({self.max_total_backoff_duration}s) exceeded "
306-
f"after {total_attempts} attempts. Final error: {e}"
307-
)
308-
raise
309-
310-
backoff_attempts += 1
311-
312-
if backoff_attempts >= self.retries + 1:
313-
self.log.error(
314-
f"All {self.retries} retries exhausted after {total_attempts} total attempts. Final error: {e}"
315-
)
316-
raise
317-
318-
# Calculate exponential backoff delay with jitter
319-
delay = calculate_backoff_delay(backoff_attempts)
320-
321-
self.log.debug(
322-
f"Network error retry {backoff_attempts}/{self.retries} (total attempts: {total_attempts}) "
323-
f"after {delay:.2f}s: {e}"
324-
)
301+
delay = apply_backoff(e, "Network error retry")
325302
time.sleep(delay)

segment/analytics/request.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ def parse_retry_after(response):
2828
return None
2929

3030
try:
31-
# Try parsing as integer (delay in seconds)
3231
delay = int(retry_after)
33-
# Ensure delay is non-negative before applying upper bound
3432
return min(max(delay, 0), MAX_RETRY_AFTER_SECONDS)
3533
except ValueError:
36-
# Could be HTTP-date format, but for simplicity we'll skip that
37-
# Most APIs use integer seconds
34+
# RFC 7231 allows HTTP-date format (e.g. "Wed, 21 Oct 2015 07:28:00 GMT")
35+
# but we don't parse it; fall back to counted backoff.
36+
log = logging.getLogger('segment')
37+
log.warning('Unrecognized Retry-After format %r; ignoring header.', retry_after)
3838
return None
3939

4040

0 commit comments

Comments
 (0)