@@ -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 )
0 commit comments