#!/usr/bin/env python3 """Original finite virtual-clock retry model. No network, sleep, SDK or load test.""" import json, platform def run(outcomes, *, deadline=2500, reserve=200, per_try=800, minimum=400, max_attempts=3, waits=(100,300), retry_after=0, safe=True, budget=10, cancel_at=None): now=0; trace=[]; attempts=0 def done(reason): return dict(reason=reason, attempts=attempts, elapsed_ms=now, retry_tokens_left=budget, trace=trace) while attempts < max_attempts: if cancel_at is not None and cancel_at <= now: return done('cancelled') available=deadline-reserve-now if available < minimum: return done('deadline') allowed=min(per_try, available) attempts+=1 duration,outcome=outcomes[min(attempts-1,len(outcomes)-1)] elapsed=min(duration,allowed) if cancel_at is not None and now+elapsed >= cancel_at: trace.append(dict(kind='attempt',start=now,end=cancel_at,status='cancelled')) now=cancel_at;return done('cancelled') end=now+elapsed if duration>allowed: outcome='timeout' trace.append(dict(kind='attempt',start=now,end=end,status=outcome,limit_ms=allowed));now=end if outcome=='ok': return done('success') if outcome=='permanent': return done('permanent') if not safe: return done('reconcile') if attempts==max_attempts: return done('attempt-limit') # retry_after is an already parsed nonnegative relative server floor. wait=retry_after+waits[min(attempts-1,len(waits)-1)] if now+wait+minimum > deadline-reserve: return done('deadline') if budget<=0: return done('retry-budget') end=now+wait if cancel_at is not None and end>=cancel_at: trace.append(dict(kind='wait',start=now,end=cancel_at,status='cancelled')) now=cancel_at;return done('cancelled') trace.append(dict(kind='wait',start=now,end=end));now=end # A real concurrent implementation must atomically acquire at send time. budget-=1 return done('attempt-limit') def main(): cases={ 'deadline_stops_third':run([(800,'timeout')]), 'shorten_last_attempt':run([(800,'timeout')],minimum=100), 'transient_then_success':run([(800,'timeout'),(250,'ok')]), 'unsafe_timeout':run([(800,'timeout')],safe=False), 'permanent':run([(50,'permanent')]), 'retry_after_exceeds_deadline':run([(800,'transient')],retry_after=2000), 'no_retry_tokens':run([(800,'timeout')],budget=0), 'cancel_during_wait':run([(800,'timeout')],cancel_at=850), 'cancel_before_send':run([(800,'timeout')],cancel_at=0), 'attempt_cap':run([(10,'transient')],deadline=10000), 'retry_after_floor':run([(100,'transient'),(100,'ok')],retry_after=500), } expected={ 'deadline_stops_third':('deadline',2,1700), 'shorten_last_attempt':('attempt-limit',3,2300), 'transient_then_success':('success',2,1150), 'unsafe_timeout':('reconcile',1,800), 'permanent':('permanent',1,50), 'retry_after_exceeds_deadline':('deadline',1,800), 'no_retry_tokens':('retry-budget',1,800), 'cancel_during_wait':('cancelled',1,850), 'cancel_before_send':('cancelled',0,0), 'attempt_cap':('attempt-limit',3,430), 'retry_after_floor':('success',2,800), } for key,result in cases.items(): assert (result['reason'],result['attempts'],result['elapsed_ms'])==expected[key],key assert all(x['end']<=2300 for x in result['trace']),key assert cases['shorten_last_attempt']['trace'][-1]['limit_ms']==300 assert cases['cancel_during_wait']['retry_tokens_left']==10 assert cases['retry_after_floor']['trace'][1]==dict(kind='wait',start=100,end=700) calculations=dict(naive_ms=3*800+100+300+200, nested_attempts=3**3, single_owner_attempts=3, unbudgeted_attempt_ceiling=1000*3, shared_retry_budget_ceiling=1000+100, illustrative_jitter_ms=[20,70,120,170,220,270], jitter_100ms_bins=[2,2,2], fixed_delay_ms=[300]*6) assert calculations['naive_ms']==3000 # Exercise the recursive invocation tree, not merely the exponent formula. def tree(depth): return 1 if depth==0 else sum(tree(depth-1) for _ in range(3)) assert tree(3)==calculations['nested_attempts'] assert [sum(lo<=x