[{"data":1,"prerenderedAt":952},["ShallowReactive",2],{"page-\u002Fadvanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002Ftesting-retry-and-backoff-logic-without-waiting\u002F":3},{"id":4,"title":5,"body":6,"description":915,"extension":916,"meta":917,"navigation":85,"path":948,"seo":949,"stem":950,"__hash__":951},"content\u002Fadvanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002Ftesting-retry-and-backoff-logic-without-waiting\u002Findex.md","Testing Retry and Backoff Logic Without Waiting",{"type":7,"value":8,"toc":904},"minimark",[9,13,16,21,47,51,193,316,445,449,460,470,474,522,526,529,535,538,615,619,622,691,698,788,792,817,820,824,837,843,860,864,893,900],[10,11,12],"p",{},"Retry logic is simple to write and easy to get subtly wrong: one attempt too many, a backoff that never caps, jitter that can go negative, a deadline that is checked before sleeping instead of after. Every one of those bugs is invisible in a test that just checks the final result, and a test that exercises real exponential backoff takes seconds or minutes per run. The result is that most suites test retries once, lightly, and then never touch them again.",[10,14,15],{},"The cost of leaving them untested is higher than it looks. Retry logic runs precisely when a dependency is struggling, so its bugs appear during incidents: an extra attempt multiplies load on a service already overloaded, a missing cap turns a brief outage into minutes of stalled requests, and synchronised retries without jitter hammer a recovering service at the same instant from every client. Injecting the sleep function changes that. The retry helper asks for a delay, the test records what was asked for and returns instantly, and the assertion can state the exact schedule the specification requires. A test that would have waited fifteen seconds runs in microseconds and checks more than it ever could with real time.",[17,18,20],"h2",{"id":19},"prerequisites","Prerequisites",[22,23,24,33,39],"ul",{},[25,26,27,28,32],"li",{},"Python 3.9+; ",[29,30,31],"code",{},"unittest.mock"," for scripting failures.",[25,34,35,38],{},[29,36,37],{},"pytest >= 8.0",".",[25,40,41,42,38],{},"The injected-clock pattern from ",[43,44,46],"a",{"href":45},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fdependency-injection-for-testability\u002Finjecting-a-clock-instead-of-patching-datetime\u002F","injecting a clock instead of patching datetime",[17,48,50],{"id":49},"solution","Solution",[52,53,58],"pre",{"className":54,"code":55,"language":56,"meta":57,"style":57},"language-python shiki shiki-themes github-light github-dark","import random\nimport time\nfrom typing import Callable, TypeVar\n\nT = TypeVar(\"T\")\n\n\ndef retry(fn: Callable[[], T], *, attempts: int = 4, base: float = 0.5,\n          cap: float = 4.0, jitter: float = 0.0,\n          sleep: Callable[[float], None] = time.sleep,\n          rng: random.Random = random.Random()) -> T:\n    for attempt in range(attempts):\n        try:\n            return fn()\n        except ConnectionError:\n            if attempt == attempts - 1:\n                raise                                   # out of attempts\n            delay = min(cap, base * 2 ** attempt)       # exponential, capped\n            if jitter:\n                delay += rng.uniform(-jitter, jitter) * delay\n            sleep(max(0.0, delay))\n    raise AssertionError(\"unreachable\")\n","python","",[29,59,60,68,74,80,87,93,98,103,109,115,121,127,133,139,145,151,157,163,169,175,181,187],{"__ignoreMap":57},[61,62,65],"span",{"class":63,"line":64},"line",1,[61,66,67],{},"import random\n",[61,69,71],{"class":63,"line":70},2,[61,72,73],{},"import time\n",[61,75,77],{"class":63,"line":76},3,[61,78,79],{},"from typing import Callable, TypeVar\n",[61,81,83],{"class":63,"line":82},4,[61,84,86],{"emptyLinePlaceholder":85},true,"\n",[61,88,90],{"class":63,"line":89},5,[61,91,92],{},"T = TypeVar(\"T\")\n",[61,94,96],{"class":63,"line":95},6,[61,97,86],{"emptyLinePlaceholder":85},[61,99,101],{"class":63,"line":100},7,[61,102,86],{"emptyLinePlaceholder":85},[61,104,106],{"class":63,"line":105},8,[61,107,108],{},"def retry(fn: Callable[[], T], *, attempts: int = 4, base: float = 0.5,\n",[61,110,112],{"class":63,"line":111},9,[61,113,114],{},"          cap: float = 4.0, jitter: float = 0.0,\n",[61,116,118],{"class":63,"line":117},10,[61,119,120],{},"          sleep: Callable[[float], None] = time.sleep,\n",[61,122,124],{"class":63,"line":123},11,[61,125,126],{},"          rng: random.Random = random.Random()) -> T:\n",[61,128,130],{"class":63,"line":129},12,[61,131,132],{},"    for attempt in range(attempts):\n",[61,134,136],{"class":63,"line":135},13,[61,137,138],{},"        try:\n",[61,140,142],{"class":63,"line":141},14,[61,143,144],{},"            return fn()\n",[61,146,148],{"class":63,"line":147},15,[61,149,150],{},"        except ConnectionError:\n",[61,152,154],{"class":63,"line":153},16,[61,155,156],{},"            if attempt == attempts - 1:\n",[61,158,160],{"class":63,"line":159},17,[61,161,162],{},"                raise                                   # out of attempts\n",[61,164,166],{"class":63,"line":165},18,[61,167,168],{},"            delay = min(cap, base * 2 ** attempt)       # exponential, capped\n",[61,170,172],{"class":63,"line":171},19,[61,173,174],{},"            if jitter:\n",[61,176,178],{"class":63,"line":177},20,[61,179,180],{},"                delay += rng.uniform(-jitter, jitter) * delay\n",[61,182,184],{"class":63,"line":183},21,[61,185,186],{},"            sleep(max(0.0, delay))\n",[61,188,190],{"class":63,"line":189},22,[61,191,192],{},"    raise AssertionError(\"unreachable\")\n",[52,194,196],{"className":54,"code":195,"language":56,"meta":57,"style":57},"from unittest.mock import Mock\n\nimport pytest\n\n\ndef test_backoff_schedule_is_exponential_and_capped():\n    slept: list[float] = []\n    fn = Mock(side_effect=[ConnectionError] * 4 + [\"ok\"])\n\n    result = retry(fn, attempts=5, base=0.5, cap=2.0, sleep=slept.append)\n\n    assert result == \"ok\"\n    assert fn.call_count == 5\n    assert slept == [0.5, 1.0, 2.0, 2.0]          # doubled, then held at the cap\n\n\ndef test_gives_up_after_the_last_attempt():\n    slept: list[float] = []\n    fn = Mock(side_effect=ConnectionError(\"down\"))\n\n    with pytest.raises(ConnectionError):\n        retry(fn, attempts=3, sleep=slept.append)\n\n    assert fn.call_count == 3                     # not a fourth\n    assert len(slept) == 2                        # no sleep after the final failure\n",[29,197,198,203,207,212,216,220,225,230,235,239,244,248,253,258,263,267,271,276,280,285,289,294,299,304,310],{"__ignoreMap":57},[61,199,200],{"class":63,"line":64},[61,201,202],{},"from unittest.mock import Mock\n",[61,204,205],{"class":63,"line":70},[61,206,86],{"emptyLinePlaceholder":85},[61,208,209],{"class":63,"line":76},[61,210,211],{},"import pytest\n",[61,213,214],{"class":63,"line":82},[61,215,86],{"emptyLinePlaceholder":85},[61,217,218],{"class":63,"line":89},[61,219,86],{"emptyLinePlaceholder":85},[61,221,222],{"class":63,"line":95},[61,223,224],{},"def test_backoff_schedule_is_exponential_and_capped():\n",[61,226,227],{"class":63,"line":100},[61,228,229],{},"    slept: list[float] = []\n",[61,231,232],{"class":63,"line":105},[61,233,234],{},"    fn = Mock(side_effect=[ConnectionError] * 4 + [\"ok\"])\n",[61,236,237],{"class":63,"line":111},[61,238,86],{"emptyLinePlaceholder":85},[61,240,241],{"class":63,"line":117},[61,242,243],{},"    result = retry(fn, attempts=5, base=0.5, cap=2.0, sleep=slept.append)\n",[61,245,246],{"class":63,"line":123},[61,247,86],{"emptyLinePlaceholder":85},[61,249,250],{"class":63,"line":129},[61,251,252],{},"    assert result == \"ok\"\n",[61,254,255],{"class":63,"line":135},[61,256,257],{},"    assert fn.call_count == 5\n",[61,259,260],{"class":63,"line":141},[61,261,262],{},"    assert slept == [0.5, 1.0, 2.0, 2.0]          # doubled, then held at the cap\n",[61,264,265],{"class":63,"line":147},[61,266,86],{"emptyLinePlaceholder":85},[61,268,269],{"class":63,"line":153},[61,270,86],{"emptyLinePlaceholder":85},[61,272,273],{"class":63,"line":159},[61,274,275],{},"def test_gives_up_after_the_last_attempt():\n",[61,277,278],{"class":63,"line":165},[61,279,229],{},[61,281,282],{"class":63,"line":171},[61,283,284],{},"    fn = Mock(side_effect=ConnectionError(\"down\"))\n",[61,286,287],{"class":63,"line":177},[61,288,86],{"emptyLinePlaceholder":85},[61,290,291],{"class":63,"line":183},[61,292,293],{},"    with pytest.raises(ConnectionError):\n",[61,295,296],{"class":63,"line":189},[61,297,298],{},"        retry(fn, attempts=3, sleep=slept.append)\n",[61,300,302],{"class":63,"line":301},23,[61,303,86],{"emptyLinePlaceholder":85},[61,305,307],{"class":63,"line":306},24,[61,308,309],{},"    assert fn.call_count == 3                     # not a fourth\n",[61,311,313],{"class":63,"line":312},25,[61,314,315],{},"    assert len(slept) == 2                        # no sleep after the final failure\n",[317,318,321,437],"figure",{"className":319},[320],"diagram",[322,323,330,331,330,335,330,339,330,347,330,357,330,363,330,369,330,375,330,379,330,383,330,388,330,391,330,397,330,402,330,405,330,408,330,412,330,415,330,423,330,428,330,433],"svg",{"viewBox":324,"role":325,"ariaLabelledBy":326,"xmlns":329},"0 0 820 262","img",[327,328],"bo-t","bo-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[332,333,334],"title",{"id":327},"A capped exponential backoff schedule",[336,337,338],"desc",{"id":328},"Bars for four delays between five attempts. The delays double from half a second to one second to two seconds, then the fourth is held at the two-second cap. There is no delay after the successful fifth attempt, and in the give-up case no delay after the final failure.",[340,341],"rect",{"x":342,"y":342,"width":343,"height":344,"rx":345,"fill":346},"0","820","262","14","#fffdf8",[348,349,356],"text",{"x":350,"y":351,"textAnchor":352,"fontSize":353,"fontWeight":354,"fill":355},"410","28","middle","16","700","#3d405b","base 0.5 s, doubling, capped at 2 s",[63,358],{"x1":359,"y1":360,"x2":361,"y2":360,"stroke":355,"strokeWidth":362},"80","210","760","1.4",[340,364],{"x":365,"y":366,"width":359,"height":367,"fill":368},"120","185","25","#81b29a",[348,370,374],{"x":371,"y":372,"textAnchor":352,"fontSize":373,"fill":355},"160","178","11","0.5 s",[348,376,378],{"x":371,"y":377,"textAnchor":352,"fontSize":373,"fill":355},"230","after #1",[340,380],{"x":381,"y":371,"width":359,"height":382,"fill":368},"260","50",[348,384,387],{"x":385,"y":386,"textAnchor":352,"fontSize":373,"fill":355},"300","153","1.0 s",[348,389,390],{"x":385,"y":377,"textAnchor":352,"fontSize":373,"fill":355},"after #2",[340,392],{"x":393,"y":394,"width":359,"height":395,"fill":396},"400","110","100","#f2cc8f",[348,398,401],{"x":399,"y":400,"textAnchor":352,"fontSize":373,"fill":355},"440","103","2.0 s",[348,403,404],{"x":399,"y":377,"textAnchor":352,"fontSize":373,"fill":355},"after #3",[340,406],{"x":407,"y":394,"width":359,"height":395,"fill":396},"540",[348,409,411],{"x":410,"y":400,"textAnchor":352,"fontSize":373,"fill":355},"580","2.0 s (cap)",[348,413,414],{"x":410,"y":377,"textAnchor":352,"fontSize":373,"fill":355},"after #4",[63,416],{"x1":395,"y1":394,"x2":417,"y2":394,"stroke":418,"strokeWidth":419,"strokeDashArray":420},"740","#e07a5f","1.6",[421,422],"6","4",[348,424,427],{"x":425,"y":395,"fontSize":373,"fontWeight":354,"fill":426},"690","#8f3d22","cap",[348,429,432],{"x":425,"y":430,"fontSize":373,"fill":431},"180","#2a5f49","#5 succeeds",[348,434,436],{"x":425,"y":435,"fontSize":373,"fill":431},"196","no sleep",[438,439,440,441,444],"figcaption",{},"The recorded list ",[29,442,443],{},"[0.5, 1.0, 2.0, 2.0]"," checks the doubling, the cap and the absence of a trailing sleep in one assertion — 5.5 seconds of real waiting, verified instantly.",[17,446,448],{"id":447},"why-this-works","Why this works",[10,450,451,452,455,456,459],{},"The retry helper's behaviour has two parts: how many times it calls the function, and how long it waits between calls. The ",[29,453,454],{},"side_effect"," script controls the first — the function fails exactly as many times as the test says — and the injected ",[29,457,458],{},"sleep"," observes the second, recording each requested delay without actually waiting. Together they reduce a time-dependent process to two lists the test can compare exactly.",[10,461,462,463,466,467,38],{},"The give-up test is the one teams most often skip, and it catches the most common bugs: an extra attempt after the limit, and a pointless sleep after the final failure that delays the error reaching the caller. Both show up immediately as a wrong ",[29,464,465],{},"call_count"," or an extra entry in ",[29,468,469],{},"slept",[17,471,473],{"id":472},"edge-cases-and-failure-modes","Edge cases and failure modes",[22,475,476,487,493,503,509],{},[25,477,478,482,483,486],{},[479,480,481],"strong",{},"Jitter going negative."," Symmetric jitter around a small base can produce a negative delay, which ",[29,484,485],{},"time.sleep"," rejects. Clamp to zero, and test it with a seeded generator that draws the extreme.",[25,488,489,492],{},[479,490,491],{},"No cap."," Exponential backoff without a cap reaches minutes after ten attempts. Assert the cap explicitly.",[25,494,495,498,499,502],{},[479,496,497],{},"Retrying the wrong exceptions."," Retrying a ",[29,500,501],{},"ValueError"," from bad input wastes attempts on something that will never succeed. Test that non-transient errors propagate immediately with no sleep.",[25,504,505,508],{},[479,506,507],{},"Deadlines checked at the wrong time."," A total deadline must account for the next sleep, or the helper sleeps past it. Inject a fake clock and assert the helper gives up before the deadline, not after.",[25,510,511,514,515,518,519,38],{},[479,512,513],{},"Async retries."," Inject ",[29,516,517],{},"asyncio.sleep"," as an async callable; the recorder becomes ",[29,520,521],{},"async def record(d): slept.append(d)",[17,523,525],{"id":524},"deadlines-as-well-as-attempt-limits","Deadlines as well as attempt limits",[10,527,528],{},"An attempt limit bounds how many times the code tries; it does not bound how long the caller waits. Four attempts with a two-second cap can take seven seconds, and a caller with a five-second budget of its own needs the retry helper to respect that. A total deadline — give up once the next sleep would cross it — is the fix, and it is exactly the kind of logic that is untestable with real time and trivial with a fake clock.",[10,530,531,532,534],{},"The fake clock's ",[29,533,458],{}," advances its own time, so the helper's view of \"now\" moves forward exactly as it would in production. The test sets a deadline, scripts permanent failure, and asserts two things: the helper raised before the clock passed the deadline, and it did not sleep across it. The second assertion is the subtle one. A helper that checks the deadline only at the top of each loop iteration will happily sleep past it and then notice, which means the caller waits longer than its budget allows — a bug that appears in production as cascading timeouts upstream.",[10,536,537],{},"Testing it this way also pins down the boundary precisely. With a deadline of five seconds and delays of 0.5, 1 and 2, the helper should make its fourth attempt at 3.5 seconds and then give up rather than sleeping two more; a fake clock makes that sequence observable and assertable to the millisecond. With real time, the same behaviour could only be checked approximately, and slowly, and would still vary with how busy the machine running the test happened to be.",[317,539,541,612],{"className":540},[320],[322,542,330,547,330,550,330,553,330,557,330,561,330,564,330,568,330,571,330,573,330,576,330,581,330,584,330,587,330,590,330,595,330,600,330,604,330,608],{"viewBox":543,"role":325,"ariaLabelledBy":544,"xmlns":329},"0 0 800 226",[545,546],"dl-t","dl-d",[332,548,549],{"id":545},"A retry deadline checked before sleeping",[336,551,552],{"id":546},"A timeline from zero to five seconds. Attempts occur at zero, half a second, one and a half, and three and a half seconds. The next delay of two seconds would end past the five-second deadline, so a correct helper gives up at three and a half seconds instead of sleeping. An incorrect helper sleeps to five and a half seconds and only then notices.",[340,554],{"x":342,"y":342,"width":555,"height":556,"rx":345,"fill":346},"800","226",[348,558,560],{"x":393,"y":351,"textAnchor":352,"fontSize":559,"fontWeight":354,"fill":355},"15.5","Check the deadline before the sleep, not after",[63,562],{"x1":563,"y1":365,"x2":361,"y2":365,"stroke":355,"strokeWidth":362},"60",[565,566],"circle",{"cx":359,"cy":365,"r":567,"fill":418},"7",[565,569],{"cx":570,"cy":365,"r":567,"fill":418},"140",[565,572],{"cx":381,"cy":365,"r":567,"fill":418},[565,574],{"cx":575,"cy":365,"r":567,"fill":418},"500",[348,577,580],{"x":359,"y":578,"textAnchor":352,"fontSize":579,"fill":355},"146","10.5","0 s",[348,582,583],{"x":570,"y":578,"textAnchor":352,"fontSize":579,"fill":355},"0.5",[348,585,586],{"x":381,"y":578,"textAnchor":352,"fontSize":579,"fill":355},"1.5",[348,588,589],{"x":575,"y":578,"textAnchor":352,"fontSize":579,"fill":355},"3.5",[63,591],{"x1":592,"y1":563,"x2":592,"y2":593,"stroke":396,"strokeWidth":594},"680","170","3",[348,596,599],{"x":592,"y":597,"textAnchor":352,"fontSize":373,"fontWeight":354,"fill":598},"52","#8a5a00","deadline 5 s",[348,601,603],{"x":575,"y":602,"textAnchor":352,"fontSize":373,"fill":431},"84","correct: give up here",[63,605],{"x1":575,"y1":395,"x2":417,"y2":395,"stroke":418,"strokeWidth":419,"strokeDashArray":606},[607,422],"5",[348,609,611],{"x":610,"y":435,"textAnchor":352,"fontSize":373,"fill":426},"620","incorrect: sleeps to 5.5 s, then notices",[438,613,614],{},"With a fake clock the difference is one assertion on the clock's final time; with real time it is an intermittent upstream timeout.",[17,616,618],{"id":617},"testing-jitter-reproducibly","Testing jitter reproducibly",[10,620,621],{},"Jitter exists to stop many clients retrying in lockstep after a shared outage, and it makes delays random by design. Testing it needs a seeded generator and assertions on bounds rather than values.",[52,623,625],{"className":54,"code":624,"language":56,"meta":57,"style":57},"import random\n\n\ndef test_jitter_stays_within_bounds_and_never_negative():\n    slept: list[float] = []\n    fn = Mock(side_effect=[ConnectionError] * 5 + [\"ok\"])\n\n    retry(fn, attempts=6, base=0.1, cap=1.0, jitter=0.5,\n          sleep=slept.append, rng=random.Random(20260918))\n\n    bases = [0.1, 0.2, 0.4, 0.8, 1.0]\n    for delay, base in zip(slept, bases):\n        assert 0.0 \u003C= delay \u003C= base * 1.5          # within ±50%, clamped at zero\n        assert delay >= base * 0.5 or delay == 0.0\n",[29,626,627,631,635,639,644,648,653,657,662,667,671,676,681,686],{"__ignoreMap":57},[61,628,629],{"class":63,"line":64},[61,630,67],{},[61,632,633],{"class":63,"line":70},[61,634,86],{"emptyLinePlaceholder":85},[61,636,637],{"class":63,"line":76},[61,638,86],{"emptyLinePlaceholder":85},[61,640,641],{"class":63,"line":82},[61,642,643],{},"def test_jitter_stays_within_bounds_and_never_negative():\n",[61,645,646],{"class":63,"line":89},[61,647,229],{},[61,649,650],{"class":63,"line":95},[61,651,652],{},"    fn = Mock(side_effect=[ConnectionError] * 5 + [\"ok\"])\n",[61,654,655],{"class":63,"line":100},[61,656,86],{"emptyLinePlaceholder":85},[61,658,659],{"class":63,"line":105},[61,660,661],{},"    retry(fn, attempts=6, base=0.1, cap=1.0, jitter=0.5,\n",[61,663,664],{"class":63,"line":111},[61,665,666],{},"          sleep=slept.append, rng=random.Random(20260918))\n",[61,668,669],{"class":63,"line":117},[61,670,86],{"emptyLinePlaceholder":85},[61,672,673],{"class":63,"line":123},[61,674,675],{},"    bases = [0.1, 0.2, 0.4, 0.8, 1.0]\n",[61,677,678],{"class":63,"line":129},[61,679,680],{},"    for delay, base in zip(slept, bases):\n",[61,682,683],{"class":63,"line":135},[61,684,685],{},"        assert 0.0 \u003C= delay \u003C= base * 1.5          # within ±50%, clamped at zero\n",[61,687,688],{"class":63,"line":141},[61,689,690],{},"        assert delay >= base * 0.5 or delay == 0.0\n",[10,692,693,694,697],{},"The fixed seed makes the draws reproducible, so a failure here is a real defect rather than bad luck, while the bounds-based assertion keeps the test valid if the jitter formula is refined. A second test with a generator whose ",[29,695,696],{},"uniform"," always returns the lower extreme — a tiny stub — confirms the clamp at zero, which a seeded run might never hit.",[317,699,701,785],{"className":700},[320],[322,702,330,707,330,710,330,713,330,716,330,719,330,723,330,729,330,732,330,736,330,739,330,743,330,746,330,749,330,752,330,755,330,757,330,762,330,766,330,770,330,773,330,777,330,781],{"viewBox":703,"role":325,"ariaLabelledBy":704,"xmlns":329},"0 0 800 236",[705,706],"jt-t","jt-d",[332,708,709],{"id":705},"Jitter bounds around each base delay",[336,711,712],{"id":706},"For each attempt, a shaded band shows the allowed range of half to one-and-a-half times the base delay, clamped at zero. Seeded jittered delays fall inside every band. The test asserts membership of the band rather than exact values, so it survives changes to the jitter formula.",[340,714],{"x":342,"y":342,"width":555,"height":715,"rx":345,"fill":346},"236",[348,717,718],{"x":393,"y":351,"textAnchor":352,"fontSize":559,"fontWeight":354,"fill":355},"Assert the band, not the draw",[63,720],{"x1":359,"y1":721,"x2":361,"y2":721,"stroke":355,"strokeWidth":722},"200","1.3",[340,724],{"x":394,"y":725,"width":726,"height":727,"fill":728,"stroke":368,"strokeWidth":362},"186","90","10","#e6f0ea",[565,730],{"cx":371,"cy":731,"r":607,"fill":418},"191",[340,733],{"x":734,"y":593,"width":726,"height":735,"fill":728,"stroke":368,"strokeWidth":362},"240","22",[565,737],{"cx":385,"cy":738,"r":607,"fill":418},"181",[340,740],{"x":741,"y":570,"width":726,"height":742,"fill":728,"stroke":368,"strokeWidth":362},"370","44",[565,744],{"cx":745,"cy":593,"r":607,"fill":418},"395",[340,747],{"x":575,"y":602,"width":726,"height":748,"fill":728,"stroke":368,"strokeWidth":362},"88",[565,750],{"cx":751,"cy":365,"r":607,"fill":418},"545",[340,753],{"x":754,"y":563,"width":726,"height":394,"fill":728,"stroke":368,"strokeWidth":362},"630",[565,756],{"cx":425,"cy":395,"r":607,"fill":418},[348,758,761],{"x":759,"y":760,"textAnchor":352,"fontSize":579,"fill":355},"155","218","0.1",[348,763,765],{"x":764,"y":760,"textAnchor":352,"fontSize":579,"fill":355},"285","0.2",[348,767,769],{"x":768,"y":760,"textAnchor":352,"fontSize":579,"fill":355},"415","0.4",[348,771,772],{"x":751,"y":760,"textAnchor":352,"fontSize":579,"fill":355},"0.8",[348,774,776],{"x":775,"y":760,"textAnchor":352,"fontSize":579,"fill":355},"675","1.0 (cap)",[348,778,780],{"x":365,"y":779,"fontSize":373,"fill":431},"56","green band: allowed range",[348,782,784],{"x":365,"y":783,"fontSize":373,"fill":426},"74","dot: seeded jittered delay",[438,786,787],{},"With a fixed seed the dots are reproducible; the assertion checks only that each lies in its band.",[17,789,791],{"id":790},"library-based-retries","Library-based retries",[10,793,794,795,798,799,798,802,805,806,809,810,813,814,816],{},"Most production code uses a retry library — ",[29,796,797],{},"tenacity",", ",[29,800,801],{},"backoff",[29,803,804],{},"stamina"," — rather than a hand-written loop, and the same technique applies because every serious library exposes its sleep function. ",[29,807,808],{},"tenacity.Retrying(sleep=...)"," takes it as an argument; decorated functions expose it as ",[29,811,812],{},"fn.retry.sleep",", which a test can replace for its duration. ",[29,815,804],{}," offers a testing mode that disables waiting entirely and a context manager that caps attempts.",[10,818,819],{},"The assertions do not change: script the collaborator's failures, record the requested delays, and compare both the attempt count and the schedule against the policy the code declares. Doing this for library-based retries matters as much as for hand-written ones, because the policy — which exceptions are retried, the base, the multiplier, the cap, the stop condition — is configuration written by the team, and configuration is where retry bugs live. A test that pins the recorded schedule to the documented policy turns an unnoticed change to a decorator argument into a failing test on the pull request that changed it. That is the cheapest possible moment to discuss whether the change was intended. Afterwards, the same change is discovered during an outage.",[17,821,823],{"id":822},"frequently-asked-questions","Frequently Asked Questions",[10,825,826,829,830,832,833,836],{},[479,827,828],{},"How do I test exponential backoff without the test taking minutes?","\nInject the sleep function. Production passes ",[29,831,485],{},"; the test passes a recorder that appends each requested delay to a list and returns immediately. The test then asserts the exact schedule, such as ",[29,834,835],{},"[0.5, 1.0, 2.0]",", in microseconds.",[10,838,839,842],{},[479,840,841],{},"How do I test jitter if the delays are random?","\nInject a seeded random generator as well, and assert on bounds rather than exact values: each delay lies within the jitter range around its base. With a fixed seed the delays are also reproducible if a precise check is needed.",[10,844,845,848,849,852,853,855,856,859],{},[479,846,847],{},"Can tenacity-based code be tested the same way?","\nYes. tenacity's ",[29,850,851],{},"Retrying"," accepts a ",[29,854,458],{}," argument, and its wait strategies are pure functions of the attempt number. Pass a recording sleep in tests, or patch the decorated function's ",[29,857,858],{},"retry.sleep"," attribute, and assert the recorded schedule.",[17,861,863],{"id":862},"related","Related",[22,865,866,872,879,886],{},[25,867,868,871],{},[43,869,870],{"href":45},"Injecting a Clock Instead of Patching datetime"," — a clock whose sleep advances time.",[25,873,874,878],{},[43,875,877],{"href":876},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fdeep-dive-into-unittestmock\u002Fdriving-mocks-with-side-effect-sequences\u002F","Driving Mocks with side_effect Sequences"," — scripting the failures.",[25,880,881,885],{},[43,882,884],{"href":883},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fsimulating-timeouts-and-connection-errors\u002F","Simulating Timeouts and Connection Errors"," — the network errors retries respond to.",[25,887,888,892],{},[43,889,891],{"href":890},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002Fseeding-random-and-numpy-for-reproducible-tests\u002F","Seeding random and NumPy for Reproducible Tests"," — seeding the jitter generator.",[10,894,895,896],{},"← Back to ",[43,897,899],{"href":898},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002F","Controlling Time and Randomness in Tests",[901,902,903],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":57,"searchDepth":70,"depth":70,"links":905},[906,907,908,909,910,911,912,913,914],{"id":19,"depth":70,"text":20},{"id":49,"depth":70,"text":50},{"id":447,"depth":70,"text":448},{"id":472,"depth":70,"text":473},{"id":524,"depth":70,"text":525},{"id":617,"depth":70,"text":618},{"id":790,"depth":70,"text":791},{"id":822,"depth":70,"text":823},{"id":862,"depth":70,"text":863},"Assert exact retry counts and backoff schedules in milliseconds: injected sleep, scripted failures, jitter with seeded randomness, caps, deadlines and tenacity.","md",{"slug":918,"type":919,"breadcrumb":920,"datePublished":921,"dateModified":921,"faq":922,"howto":929},"testing-retry-and-backoff-logic-without-waiting","article","Retry & Backoff","2026-09-18",[923,925,927],{"q":828,"a":924},"Inject the sleep function. Production passes time.sleep; the test passes a recorder that appends each requested delay to a list and returns immediately. The test then asserts the exact schedule, such as [0.5, 1.0, 2.0], in microseconds.",{"q":841,"a":926},"Inject a seeded random generator as well, and assert on bounds rather than exact values: each delay lies within the jitter range around its base. With a fixed seed the delays are also reproducible if a precise check is needed.",{"q":847,"a":928},"Yes. tenacity's Retrying accepts a sleep argument, and its wait strategies are pure functions of the attempt number. Pass a recording sleep in tests, or patch the decorated function's retry.sleep attribute, and assert the recorded schedule.",{"name":930,"description":931,"steps":932},"How to test retry and backoff without waiting","Inject sleep and randomness, script the collaborator's failures, and assert the attempt count and the delay schedule exactly.",[933,936,939,942,945],{"name":934,"text":935},"Make sleep injectable","Give the retry helper a sleep parameter defaulting to time.sleep, or asyncio.sleep for async code.",{"name":937,"text":938},"Script the failures","Use a side_effect sequence so the collaborator fails a known number of times before succeeding.",{"name":940,"text":941},"Record requested delays","Pass a sleep function that appends its argument to a list and returns immediately.",{"name":943,"text":944},"Assert count and schedule","Check the number of attempts and the exact list of delays, including the cap.",{"name":946,"text":947},"Cover the give-up path","Script permanent failure and assert the final error and that no extra attempt was made.","\u002Fadvanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002Ftesting-retry-and-backoff-logic-without-waiting",{"title":5,"description":915},"advanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002Ftesting-retry-and-backoff-logic-without-waiting\u002Findex","D33vpkWyek2k-Y-9DM4uGDCdByt19bpIFY_cIdaRfsI",1789718768891]