[{"data":1,"prerenderedAt":1141},["ShallowReactive",2],{"page-\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Freplacing-sleep-based-waits-with-polling-assertions\u002F":3},{"id":4,"title":5,"body":6,"description":1104,"extension":1105,"meta":1106,"navigation":86,"path":1137,"seo":1138,"stem":1139,"__hash__":1140},"content\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Freplacing-sleep-based-waits-with-polling-assertions\u002Findex.md","Replacing Sleep-Based Waits with Polling Assertions",{"type":7,"value":8,"toc":1092},"minimark",[9,18,23,51,55,58,201,244,292,431,435,441,454,458,494,498,501,582,634,641,648,652,659,734,741,744,760,823,827,830,836,895,901,946,950,953,1007,1013,1019,1023,1033,1039,1048,1052,1083,1088],[10,11,12,13,17],"p",{},"Every ",[14,15,16],"code",{},"time.sleep(0.5)"," in a test is a bet that half a second is enough on the slowest machine that will ever run it, paid for on every machine that never needed it. The suite is simultaneously slower than necessary and flakier than necessary, and both problems have the same fix: wait for the condition rather than for the clock.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,38,48],"ul",{},[27,28,29,30,33,34,37],"li",{},"Python 3.9+; ",[14,31,32],{},"time.monotonic"," and ",[14,35,36],{},"asyncio"," are standard library.",[27,39,40,43,44,47],{},[14,41,42],{},"pytest >= 8.0",", and ",[14,45,46],{},"pytest-timeout"," so an unsatisfiable wait fails rather than hanging.",[27,49,50],{},"Some observable condition to wait on — a counter, a queue length, a database row, a file. If there is none, the first job is to expose one.",[19,52,54],{"id":53},"solution","Solution",[10,56,57],{},"Prefer an explicit signal. Where none exists, poll on a monotonic deadline with a message that names the condition.",[59,60,65],"pre",{"className":61,"code":62,"language":63,"meta":64,"style":64},"language-python shiki shiki-themes github-light github-dark","import time\nfrom typing import Callable\n\n\ndef wait_until(\n    predicate: Callable[[], bool],\n    *,\n    timeout: float = 2.0,\n    interval: float = 0.01,\n    description: str = \"condition\",\n):\n    \"\"\"Block until predicate() is true, or fail with a message that diagnoses.\"\"\"\n    deadline = time.monotonic() + timeout        # monotonic: immune to clock changes\n    last = None\n    while time.monotonic() \u003C deadline:\n        last = predicate()\n        if last:\n            return\n        time.sleep(interval)                     # yields the GIL; not a fixed wait\n    raise AssertionError(\n        f\"{description} still false after {timeout}s (last value: {last!r})\"\n    )\n","python","",[14,66,67,75,81,88,93,99,105,111,117,123,129,135,141,147,153,159,165,171,177,183,189,195],{"__ignoreMap":64},[68,69,72],"span",{"class":70,"line":71},"line",1,[68,73,74],{},"import time\n",[68,76,78],{"class":70,"line":77},2,[68,79,80],{},"from typing import Callable\n",[68,82,84],{"class":70,"line":83},3,[68,85,87],{"emptyLinePlaceholder":86},true,"\n",[68,89,91],{"class":70,"line":90},4,[68,92,87],{"emptyLinePlaceholder":86},[68,94,96],{"class":70,"line":95},5,[68,97,98],{},"def wait_until(\n",[68,100,102],{"class":70,"line":101},6,[68,103,104],{},"    predicate: Callable[[], bool],\n",[68,106,108],{"class":70,"line":107},7,[68,109,110],{},"    *,\n",[68,112,114],{"class":70,"line":113},8,[68,115,116],{},"    timeout: float = 2.0,\n",[68,118,120],{"class":70,"line":119},9,[68,121,122],{},"    interval: float = 0.01,\n",[68,124,126],{"class":70,"line":125},10,[68,127,128],{},"    description: str = \"condition\",\n",[68,130,132],{"class":70,"line":131},11,[68,133,134],{},"):\n",[68,136,138],{"class":70,"line":137},12,[68,139,140],{},"    \"\"\"Block until predicate() is true, or fail with a message that diagnoses.\"\"\"\n",[68,142,144],{"class":70,"line":143},13,[68,145,146],{},"    deadline = time.monotonic() + timeout        # monotonic: immune to clock changes\n",[68,148,150],{"class":70,"line":149},14,[68,151,152],{},"    last = None\n",[68,154,156],{"class":70,"line":155},15,[68,157,158],{},"    while time.monotonic() \u003C deadline:\n",[68,160,162],{"class":70,"line":161},16,[68,163,164],{},"        last = predicate()\n",[68,166,168],{"class":70,"line":167},17,[68,169,170],{},"        if last:\n",[68,172,174],{"class":70,"line":173},18,[68,175,176],{},"            return\n",[68,178,180],{"class":70,"line":179},19,[68,181,182],{},"        time.sleep(interval)                     # yields the GIL; not a fixed wait\n",[68,184,186],{"class":70,"line":185},20,[68,187,188],{},"    raise AssertionError(\n",[68,190,192],{"class":70,"line":191},21,[68,193,194],{},"        f\"{description} still false after {timeout}s (last value: {last!r})\"\n",[68,196,198],{"class":70,"line":197},22,[68,199,200],{},"    )\n",[59,202,204],{"className":61,"code":203,"language":63,"meta":64,"style":64},"import asyncio\n\n\nasync def await_until(predicate, *, timeout=2.0, interval=0.01, description=\"condition\"):\n    \"\"\"The asyncio counterpart: the deadline is a cancel scope, not a loop guard.\"\"\"\n    async with asyncio.timeout(timeout):\n        while not predicate():\n            await asyncio.sleep(interval)\n",[14,205,206,211,215,219,224,229,234,239],{"__ignoreMap":64},[68,207,208],{"class":70,"line":71},[68,209,210],{},"import asyncio\n",[68,212,213],{"class":70,"line":77},[68,214,87],{"emptyLinePlaceholder":86},[68,216,217],{"class":70,"line":83},[68,218,87],{"emptyLinePlaceholder":86},[68,220,221],{"class":70,"line":90},[68,222,223],{},"async def await_until(predicate, *, timeout=2.0, interval=0.01, description=\"condition\"):\n",[68,225,226],{"class":70,"line":95},[68,227,228],{},"    \"\"\"The asyncio counterpart: the deadline is a cancel scope, not a loop guard.\"\"\"\n",[68,230,231],{"class":70,"line":101},[68,232,233],{},"    async with asyncio.timeout(timeout):\n",[68,235,236],{"class":70,"line":107},[68,237,238],{},"        while not predicate():\n",[68,240,241],{"class":70,"line":113},[68,242,243],{},"            await asyncio.sleep(interval)\n",[59,245,247],{"className":61,"code":246,"language":63,"meta":64,"style":64},"def test_worker_drains_the_queue(worker, queue):\n    queue.put({\"id\": 1})\n    worker.start()\n\n    # Returns in about one interval when healthy; fails in two seconds with a\n    # message naming the queue when not.\n    wait_until(queue.empty, timeout=2.0, description=\"queue drained\")\n\n    assert worker.processed == [{\"id\": 1}]\n",[14,248,249,254,259,264,268,273,278,283,287],{"__ignoreMap":64},[68,250,251],{"class":70,"line":71},[68,252,253],{},"def test_worker_drains_the_queue(worker, queue):\n",[68,255,256],{"class":70,"line":77},[68,257,258],{},"    queue.put({\"id\": 1})\n",[68,260,261],{"class":70,"line":83},[68,262,263],{},"    worker.start()\n",[68,265,266],{"class":70,"line":90},[68,267,87],{"emptyLinePlaceholder":86},[68,269,270],{"class":70,"line":95},[68,271,272],{},"    # Returns in about one interval when healthy; fails in two seconds with a\n",[68,274,275],{"class":70,"line":101},[68,276,277],{},"    # message naming the queue when not.\n",[68,279,280],{"class":70,"line":107},[68,281,282],{},"    wait_until(queue.empty, timeout=2.0, description=\"queue drained\")\n",[68,284,285],{"class":70,"line":113},[68,286,87],{"emptyLinePlaceholder":86},[68,288,289],{"class":70,"line":119},[68,290,291],{},"    assert worker.processed == [{\"id\": 1}]\n",[293,294,297,427],"figure",{"className":295},[296],"diagram",[298,299,306,307,306,311,306,315,306,323,306,333,306,340,306,347,306,356,306,362,306,368,306,372,306,377,306,382,306,384,306,387,306,390,306,395,306,399,306,403,306,409,306,413,306,417,306,423],"svg",{"viewBox":300,"role":301,"ariaLabelledBy":302,"xmlns":305},"0 0 820 256","img",[303,304],"poll-t","poll-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[308,309,310],"title",{"id":303},"Fixed sleep compared with a bounded poll",[312,313,314],"desc",{"id":304},"Two timelines for the same operation, which completes after thirty milliseconds. The fixed sleep waits five hundred milliseconds regardless, so the test is slow and still fails on a loaded runner where the work takes six hundred. The bounded poll returns at about forty milliseconds and tolerates up to two seconds before failing.",[316,317],"rect",{"x":318,"y":318,"width":319,"height":320,"rx":321,"fill":322},"0","820","256","14","#fffdf8",[324,325,332],"text",{"x":326,"y":327,"textAnchor":328,"fontSize":329,"fontWeight":330,"fill":331},"410","28","middle","16","700","#3d405b","Slower when healthy, and still flaky when not",[324,334,339],{"x":335,"y":336,"fontSize":337,"fontWeight":330,"fill":338},"34","80","12","#8f3d22","sleep(0.5)",[70,341],{"x1":342,"y1":343,"x2":344,"y2":343,"stroke":345,"strokeWidth":346},"150","76","770","rgba(61,64,91,0.35)","1.3",[316,348],{"x":349,"y":350,"width":350,"height":351,"rx":352,"fill":353,"stroke":354,"strokeWidth":355},"160","60","32","7","#e6f0ea","#81b29a","1.6",[324,357,361],{"x":358,"y":359,"textAnchor":328,"fontSize":360,"fill":331},"190","81","10.5","work",[316,363],{"x":364,"y":350,"width":365,"height":351,"rx":352,"fill":366,"stroke":367,"strokeWidth":355},"222","380","#fbe9e3","#e07a5f",[324,369,371],{"x":370,"y":359,"textAnchor":328,"fontSize":360,"fill":331},"412","waiting for nothing — 470 ms",[324,373,376],{"x":374,"y":359,"fontSize":375,"fill":338},"620","11","and still too short at 600 ms",[324,378,381],{"x":335,"y":379,"fontSize":337,"fontWeight":330,"fill":380},"164","#2a5f49","wait_until",[70,383],{"x1":342,"y1":349,"x2":344,"y2":349,"stroke":345,"strokeWidth":346},[316,385],{"x":349,"y":386,"width":350,"height":351,"rx":352,"fill":353,"stroke":354,"strokeWidth":355},"144",[324,388,361],{"x":358,"y":389,"textAnchor":328,"fontSize":360,"fill":331},"165",[316,391],{"x":364,"y":386,"width":392,"height":351,"rx":352,"fill":393,"stroke":394,"strokeWidth":355},"40","#f7f0da","#f2cc8f",[324,396,398],{"x":397,"y":389,"textAnchor":328,"fontSize":360,"fill":331},"242","poll",[324,400,402],{"x":401,"y":389,"fontSize":375,"fill":380},"276","returns at ~40 ms",[70,404],{"x1":405,"y1":406,"x2":405,"y2":407,"stroke":367,"strokeWidth":408},"640","132","188","2.5",[324,410,412],{"x":405,"y":411,"textAnchor":328,"fontSize":360,"fontWeight":330,"fill":338},"124","deadline 2 s",[324,414,416],{"x":405,"y":415,"textAnchor":328,"fontSize":360,"fill":331},"206","tolerant of a loaded runner",[316,418],{"x":335,"y":419,"width":420,"height":421,"rx":422,"fill":322,"stroke":345,"strokeWidth":346},"220","752","26","8",[324,424,426],{"x":326,"y":425,"textAnchor":328,"fontSize":375,"fill":331},"238","The poll is faster in the common case and more patient in the rare one — the sleep is neither.",[428,429,430],"figcaption",{},"A fixed sleep optimises for neither speed nor reliability. The bounded poll separates the two decisions: how often to check, and how long to tolerate.",[19,432,434],{"id":433},"why-this-works","Why this works",[10,436,437,438,440],{},"The two numbers in a sleep are conflated. ",[14,439,339],{}," says both \"check after 500 ms\" and \"give up after 500 ms\", so making it more tolerant also makes it slower, and making it faster also makes it flakier. Polling separates them: the interval decides latency, the deadline decides patience, and they can be tuned independently.",[10,442,443,445,446,449,450,453],{},[14,444,32],{}," rather than ",[14,447,448],{},"time.time"," matters more than it looks. Wall-clock time can step backwards — NTP corrections, a container's clock being set at start-up, a developer changing the system time — and a deadline computed from it can then be in the past or absurdly far in the future. ",[14,451,452],{},"monotonic"," counts forward from an arbitrary origin and cannot jump.",[19,455,457],{"id":456},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,459,460,467,473,479,485],{},[27,461,462,466],{},[463,464,465],"strong",{},"A predicate with side effects."," Polling calls it dozens of times; a predicate that consumes a queue item or advances a cursor will corrupt the state it is checking. Predicates must be pure reads.",[27,468,469,472],{},[463,470,471],{},"An interval of zero."," A tight loop with no sleep never releases the GIL, so the thread doing the work may not be scheduled at all and the wait can never succeed. Always sleep at least a millisecond.",[27,474,475,478],{},[463,476,477],{},"A deadline that is too tight in CI."," Two seconds locally is not two seconds on a shared runner. Set deadlines an order of magnitude above the observed healthy case.",[27,480,481,484],{},[463,482,483],{},"Polling for something that will never change."," A test waiting for a worker that crashed at start-up waits the full deadline every run. Check for the failure condition as well, and fail early when it holds.",[27,486,487,493],{},[463,488,489,492],{},[14,490,491],{},"time.sleep"," inside a coroutine."," It blocks the loop, so the very task that would satisfy the predicate cannot run. Use the async helper instead.",[19,495,497],{"id":496},"preferring-a-signal-to-a-poll","Preferring a signal to a poll",[10,499,500],{},"Polling is the fallback. When the code under test can tell you directly, that is always better: zero latency, zero wasted checks, and no interval to tune.",[59,502,504],{"className":61,"code":503,"language":63,"meta":64,"style":64},"import asyncio\n\n\nclass Worker:\n    def __init__(self):\n        self.idle = asyncio.Event()          # part of the worker's own interface\n        self.processed: list[dict] = []\n\n    async def run(self, queue):\n        while True:\n            item = await queue.get()\n            self.processed.append(item)\n            if queue.empty():\n                self.idle.set()              # tell anyone who cares\n            else:\n                self.idle.clear()\n",[14,505,506,510,514,518,523,528,533,538,542,547,552,557,562,567,572,577],{"__ignoreMap":64},[68,507,508],{"class":70,"line":71},[68,509,210],{},[68,511,512],{"class":70,"line":77},[68,513,87],{"emptyLinePlaceholder":86},[68,515,516],{"class":70,"line":83},[68,517,87],{"emptyLinePlaceholder":86},[68,519,520],{"class":70,"line":90},[68,521,522],{},"class Worker:\n",[68,524,525],{"class":70,"line":95},[68,526,527],{},"    def __init__(self):\n",[68,529,530],{"class":70,"line":101},[68,531,532],{},"        self.idle = asyncio.Event()          # part of the worker's own interface\n",[68,534,535],{"class":70,"line":107},[68,536,537],{},"        self.processed: list[dict] = []\n",[68,539,540],{"class":70,"line":113},[68,541,87],{"emptyLinePlaceholder":86},[68,543,544],{"class":70,"line":119},[68,545,546],{},"    async def run(self, queue):\n",[68,548,549],{"class":70,"line":125},[68,550,551],{},"        while True:\n",[68,553,554],{"class":70,"line":131},[68,555,556],{},"            item = await queue.get()\n",[68,558,559],{"class":70,"line":137},[68,560,561],{},"            self.processed.append(item)\n",[68,563,564],{"class":70,"line":143},[68,565,566],{},"            if queue.empty():\n",[68,568,569],{"class":70,"line":149},[68,570,571],{},"                self.idle.set()              # tell anyone who cares\n",[68,573,574],{"class":70,"line":155},[68,575,576],{},"            else:\n",[68,578,579],{"class":70,"line":161},[68,580,581],{},"                self.idle.clear()\n",[59,583,585],{"className":61,"code":584,"language":63,"meta":64,"style":64},"async def test_worker_becomes_idle(worker, queue):\n    await queue.put({\"id\": 1})\n    task = asyncio.create_task(worker.run(queue))\n\n    # No interval, no polling: this returns the instant the worker says so.\n    async with asyncio.timeout(2):\n        await worker.idle.wait()\n\n    assert worker.processed == [{\"id\": 1}]\n    task.cancel()\n",[14,586,587,592,597,602,606,611,616,621,625,629],{"__ignoreMap":64},[68,588,589],{"class":70,"line":71},[68,590,591],{},"async def test_worker_becomes_idle(worker, queue):\n",[68,593,594],{"class":70,"line":77},[68,595,596],{},"    await queue.put({\"id\": 1})\n",[68,598,599],{"class":70,"line":83},[68,600,601],{},"    task = asyncio.create_task(worker.run(queue))\n",[68,603,604],{"class":70,"line":90},[68,605,87],{"emptyLinePlaceholder":86},[68,607,608],{"class":70,"line":95},[68,609,610],{},"    # No interval, no polling: this returns the instant the worker says so.\n",[68,612,613],{"class":70,"line":101},[68,614,615],{},"    async with asyncio.timeout(2):\n",[68,617,618],{"class":70,"line":107},[68,619,620],{},"        await worker.idle.wait()\n",[68,622,623],{"class":70,"line":113},[68,624,87],{"emptyLinePlaceholder":86},[68,626,627],{"class":70,"line":119},[68,628,291],{},[68,630,631],{"class":70,"line":125},[68,632,633],{},"    task.cancel()\n",[10,635,636,637,640],{},"Adding an ",[14,638,639],{},"Event"," to production code purely for tests can feel like contamination, and occasionally it is. More often it is an improvement: \"this component can tell you when it is idle\" is a useful property for shutdown, health checks and backpressure, and the test is simply the first consumer. Where the signal genuinely has no production use, an injected callback is the lighter alternative — the component calls it on each state change, tests pass a recording one, production passes nothing.",[10,642,643,644,647],{},"The same reasoning applies to threads. A ",[14,645,646],{},"threading.Event"," set by the worker is strictly better than a poll on a counter, because it removes both the latency and the interval. Polling remains the right tool when the state lives somewhere you cannot instrument: a database row written by another process, a file appearing on disk, an external service's status endpoint.",[19,649,651],{"id":650},"making-the-failure-message-do-the-work","Making the failure message do the work",[10,653,654,655,658],{},"A bounded wait that raises ",[14,656,657],{},"TimeoutError"," with no detail has replaced one bad diagnosis with another. The message should name the condition and report what was actually observed, because that is usually the whole investigation.",[59,660,662],{"className":61,"code":661,"language":63,"meta":64,"style":64},"def wait_for_rows(session, table, expected, *, timeout=5.0):\n    def count():\n        return session.query(table).count()\n\n    deadline = time.monotonic() + timeout\n    observed = count()\n    while time.monotonic() \u003C deadline:\n        if observed == expected:\n            return\n        time.sleep(0.02)\n        observed = count()\n    raise AssertionError(\n        f\"expected {expected} rows in {table.__tablename__} after {timeout}s, \"\n        f\"found {observed}\"\n    )\n",[14,663,664,669,674,679,683,688,693,697,702,706,711,716,720,725,730],{"__ignoreMap":64},[68,665,666],{"class":70,"line":71},[68,667,668],{},"def wait_for_rows(session, table, expected, *, timeout=5.0):\n",[68,670,671],{"class":70,"line":77},[68,672,673],{},"    def count():\n",[68,675,676],{"class":70,"line":83},[68,677,678],{},"        return session.query(table).count()\n",[68,680,681],{"class":70,"line":90},[68,682,87],{"emptyLinePlaceholder":86},[68,684,685],{"class":70,"line":95},[68,686,687],{},"    deadline = time.monotonic() + timeout\n",[68,689,690],{"class":70,"line":101},[68,691,692],{},"    observed = count()\n",[68,694,695],{"class":70,"line":107},[68,696,158],{},[68,698,699],{"class":70,"line":113},[68,700,701],{},"        if observed == expected:\n",[68,703,704],{"class":70,"line":119},[68,705,176],{},[68,707,708],{"class":70,"line":125},[68,709,710],{},"        time.sleep(0.02)\n",[68,712,713],{"class":70,"line":131},[68,714,715],{},"        observed = count()\n",[68,717,718],{"class":70,"line":137},[68,719,188],{},[68,721,722],{"class":70,"line":143},[68,723,724],{},"        f\"expected {expected} rows in {table.__tablename__} after {timeout}s, \"\n",[68,726,727],{"class":70,"line":149},[68,728,729],{},"        f\"found {observed}\"\n",[68,731,732],{"class":70,"line":155},[68,733,200],{},[59,735,739],{"className":736,"code":738,"language":324,"meta":64},[737],"language-text","E   AssertionError: expected 3 rows in order_line after 5.0s, found 1\n",[14,740,738],{"__ignoreMap":64},[10,742,743],{},"That message distinguishes \"nothing happened\" from \"two of three happened\" without a re-run, and the second case points at a different bug entirely — a partial batch rather than a stalled worker. A generic timeout message would have sent the reader to the logs to find out which.",[10,745,746,747,750,751,754,755,759],{},"The same principle applies to the predicate's description when a generic helper is used. ",[14,748,749],{},"wait_until(queue.empty)"," failing says almost nothing; ",[14,752,753],{},"wait_until(queue.empty, description=\"queue drained\")"," at least names the expectation, and passing a lambda that returns the ",[756,757,758],"em",{},"value"," rather than a boolean — with the helper truth-testing it — gives the observed state for free.",[293,761,763,820],{"className":762},[296],[298,764,306,769,306,772,306,775,306,779,306,784,306,790,306,795,306,799,306,802,306,805,306,809,306,813,306,816],{"viewBox":765,"role":301,"ariaLabelledBy":766,"xmlns":305},"0 0 800 226",[767,768],"msg-t","msg-d",[308,770,771],{"id":767},"What each kind of timeout message tells the reader",[312,773,774],{"id":768},"Three rows of increasing usefulness. A bare TimeoutError says only that something did not happen. A message naming the condition says what was expected. A message naming the condition and the last observed value distinguishes nothing happening from partial progress, which are different bugs.",[316,776],{"x":318,"y":318,"width":777,"height":778,"rx":321,"fill":322},"800","226",[324,780,783],{"x":781,"y":327,"textAnchor":328,"fontSize":782,"fontWeight":330,"fill":331},"400","15.5","The message is the diagnosis",[316,785],{"x":421,"y":786,"width":787,"height":786,"rx":788,"fill":366,"stroke":367,"strokeWidth":789},"50","748","10","2",[324,791,657],{"x":792,"y":793,"fontSize":794,"fontWeight":330,"fill":331},"46","72","11.5",[324,796,798],{"x":792,"y":797,"fontSize":375,"fill":338},"90","something did not happen · re-run with prints to find out what",[316,800],{"x":421,"y":801,"width":787,"height":786,"rx":788,"fill":393,"stroke":394,"strokeWidth":789},"110",[324,803,804],{"x":792,"y":406,"fontSize":794,"fontWeight":330,"fill":331},"\"queue drained still false after 2.0s\"",[324,806,808],{"x":792,"y":342,"fontSize":375,"fill":807},"#8a5a00","names the expectation · still silent about how far it got",[316,810],{"x":421,"y":811,"width":787,"height":812,"rx":788,"fill":353,"stroke":354,"strokeWidth":789},"170","44",[324,814,815],{"x":792,"y":358,"fontSize":794,"fontWeight":330,"fill":331},"\"expected 3 rows, found 1 after 5.0s\"",[324,817,819],{"x":792,"y":818,"fontSize":375,"fill":380},"207","distinguishes stalled from partial · usually ends the investigation",[428,821,822],{},"The third form costs one f-string and removes the re-run that the first two guarantee.",[19,824,826],{"id":825},"waiting-on-state-outside-the-process","Waiting on state outside the process",[10,828,829],{},"The hardest waits are on things the test cannot instrument: a row written by another service, a message landing in a broker, a file appearing on a shared volume. Polling is the only option there, and two refinements keep it honest.",[10,831,832,835],{},[463,833,834],{},"Back off the interval."," A database query every ten milliseconds for five seconds is five hundred queries, which on a shared test database is noticeable load. Doubling the interval up to a cap keeps the first checks fast and the later ones cheap:",[59,837,839],{"className":61,"code":838,"language":63,"meta":64,"style":64},"import time\n\n\ndef wait_until_backoff(predicate, *, timeout=10.0, first=0.01, cap=0.5):\n    deadline = time.monotonic() + timeout\n    interval = first\n    while time.monotonic() \u003C deadline:\n        if predicate():\n            return\n        time.sleep(min(interval, max(0.0, deadline - time.monotonic())))\n        interval = min(interval * 2, cap)     # 10 ms, 20, 40 … capped at 500 ms\n    raise AssertionError(f\"not satisfied within {timeout}s\")\n",[14,840,841,845,849,853,858,862,867,871,876,880,885,890],{"__ignoreMap":64},[68,842,843],{"class":70,"line":71},[68,844,74],{},[68,846,847],{"class":70,"line":77},[68,848,87],{"emptyLinePlaceholder":86},[68,850,851],{"class":70,"line":83},[68,852,87],{"emptyLinePlaceholder":86},[68,854,855],{"class":70,"line":90},[68,856,857],{},"def wait_until_backoff(predicate, *, timeout=10.0, first=0.01, cap=0.5):\n",[68,859,860],{"class":70,"line":95},[68,861,687],{},[68,863,864],{"class":70,"line":101},[68,865,866],{},"    interval = first\n",[68,868,869],{"class":70,"line":107},[68,870,158],{},[68,872,873],{"class":70,"line":113},[68,874,875],{},"        if predicate():\n",[68,877,878],{"class":70,"line":119},[68,879,176],{},[68,881,882],{"class":70,"line":125},[68,883,884],{},"        time.sleep(min(interval, max(0.0, deadline - time.monotonic())))\n",[68,886,887],{"class":70,"line":131},[68,888,889],{},"        interval = min(interval * 2, cap)     # 10 ms, 20, 40 … capped at 500 ms\n",[68,891,892],{"class":70,"line":137},[68,893,894],{},"    raise AssertionError(f\"not satisfied within {timeout}s\")\n",[10,896,897,900],{},[463,898,899],{},"Fail early on a known-bad state."," If the other process can report an error — a dead-letter queue, an error row, a crashed container — check for it in the same loop and raise immediately rather than waiting out the deadline for something that will never arrive.",[293,902,904,943],{"className":903},[296],[298,905,306,910,306,913,306,916,306,918,306,921,306,924,306,928,306,932,306,935,306,939],{"viewBox":906,"role":301,"ariaLabelledBy":907,"xmlns":305},"0 0 800 220",[908,909],"back-t","back-d",[308,911,912],{"id":908},"Fixed-interval polling versus exponential backoff",[312,914,915],{"id":909},"Two rows of check marks over a ten-second window. Fixed ten-millisecond polling produces around a thousand checks. Exponential backoff capped at half a second produces around twenty-five checks while still detecting an early success within the first few milliseconds.",[316,917],{"x":318,"y":318,"width":777,"height":419,"rx":321,"fill":322},[324,919,920],{"x":781,"y":327,"textAnchor":328,"fontSize":782,"fontWeight":330,"fill":331},"Same responsiveness early, far less load late",[316,922],{"x":421,"y":786,"width":787,"height":923,"rx":375,"fill":366,"stroke":367,"strokeWidth":789},"68",[324,925,927],{"x":792,"y":926,"fontSize":337,"fontWeight":330,"fill":331},"74","fixed 10 ms",[324,929,931],{"x":792,"y":930,"fontSize":375,"fill":338},"98","~1,000 queries over 10 s against a shared database",[316,933],{"x":421,"y":934,"width":787,"height":923,"rx":375,"fill":353,"stroke":354,"strokeWidth":789},"130",[324,936,938],{"x":792,"y":937,"fontSize":337,"fontWeight":330,"fill":331},"154","10 ms doubling to a 500 ms cap",[324,940,942],{"x":792,"y":941,"fontSize":375,"fill":380},"178","~25 queries over 10 s · early success still seen within milliseconds",[428,944,945],{},"Backoff matters only for waits that cross a process boundary. In-process predicates are cheap enough that a fixed short interval is fine.",[19,947,949],{"id":948},"finding-the-sleeps-you-already-have","Finding the sleeps you already have",[10,951,952],{},"A one-line audit finds every fixed wait in a suite, and the results are usually startling.",[59,954,958],{"className":955,"code":956,"language":957,"meta":64,"style":64},"language-bash shiki shiki-themes github-light github-dark","grep -rn \"time.sleep\\|asyncio.sleep(\" tests\u002F | grep -v \"sleep(0)\" | sort -t'(' -k2 -rn\n","bash",[14,959,960],{"__ignoreMap":64},[68,961,962,966,970,974,977,981,984,987,990,992,995,998,1001,1004],{"class":70,"line":71},[68,963,965],{"class":964},"sScJk","grep",[68,967,969],{"class":968},"sj4cs"," -rn",[68,971,973],{"class":972},"sZZnC"," \"time.sleep\\|asyncio.sleep(\"",[68,975,976],{"class":972}," tests\u002F",[68,978,980],{"class":979},"szBVR"," |",[68,982,983],{"class":964}," grep",[68,985,986],{"class":968}," -v",[68,988,989],{"class":972}," \"sleep(0)\"",[68,991,980],{"class":979},[68,993,994],{"class":964}," sort",[68,996,997],{"class":968}," -t",[68,999,1000],{"class":972},"'('",[68,1002,1003],{"class":968}," -k2",[68,1005,1006],{"class":968}," -rn\n",[59,1008,1011],{"className":1009,"code":1010,"language":324,"meta":64},[737],"tests\u002Fintegration\u002Ftest_worker.py:44:    time.sleep(5)          # \"let kafka settle\"\ntests\u002Fapi\u002Ftest_webhook.py:81:           await asyncio.sleep(2)\ntests\u002Fintegration\u002Ftest_cache.py:29:     time.sleep(1.5)\n",[14,1012,1010],{"__ignoreMap":64},[10,1014,1015,1016,1018],{},"Sum the durations and multiply by the number of tests that run them: a suite with forty sleeps averaging a second spends over half a minute per run doing nothing, and each of those forty is an independent chance of a flake. Converting the largest handful first captures most of the benefit, and the conversion is mechanical once a ",[14,1017,381],{}," helper exists in the test package.",[19,1020,1022],{"id":1021},"frequently-asked-questions","Frequently Asked Questions",[10,1024,1025,1028,1029,1032],{},[463,1026,1027],{},"Is asyncio.sleep(0) also a sleep to be removed?","\nNo. ",[14,1030,1031],{},"asyncio.sleep(0)"," yields control to the loop without waiting for wall-clock time, which is a deterministic scheduling operation rather than a bet on duration. It is the correct way to let another task run, and it is what makes forced interleavings reproducible.",[10,1034,1035,1038],{},[463,1036,1037],{},"How short should the polling interval be?","\nShort enough that the test does not add perceptible latency and long enough that the loop is not a busy wait — one to ten milliseconds covers nearly everything. The interval decides how late the test notices; the deadline decides how long it tolerates. They are independent choices.",[10,1040,1041,1044,1045,1047],{},[463,1042,1043],{},"What should a polling helper report when it times out?","\nThe condition that was still false and the last observed value. A helper that raises a bare ",[14,1046,657],{}," forces the reader to re-run with prints; one that reports \"queue still had 3 items after 2.0s\" usually ends the investigation immediately.",[19,1049,1051],{"id":1050},"related","Related",[24,1053,1054,1062,1069,1076],{},[27,1055,1056,1061],{},[1057,1058,1060],"a",{"href":1059},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002F","Timeouts, Cancellation & Deadlines"," — where these waits sit among the suite's other deadlines.",[27,1063,1064,1068],{},[1057,1065,1067],{"href":1066},"\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Fwaiting-for-container-readiness-without-sleep\u002F","Waiting for Container Readiness Without sleep"," — the same idea applied to service start-up.",[27,1070,1071,1075],{},[1057,1072,1074],{"href":1073},"\u002Ftesting-async-and-concurrent-python\u002Ftesting-threads-and-race-conditions\u002Ftesting-thread-safety-with-barriers-and-events\u002F","Testing Thread Safety with Barriers and Events"," — signal-based coordination for threads.",[27,1077,1078,1082],{},[1057,1079,1081],{"href":1080},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fdebugging-flaky-tests-with-pytest-rerunfailures\u002F","Debugging Flaky Tests with pytest-rerunfailures"," — what to do about the flakes until the sleeps are gone.",[10,1084,1085,1086],{},"← Back to ",[1057,1087,1060],{"href":1059},[1089,1090,1091],"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);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":64,"searchDepth":77,"depth":77,"links":1093},[1094,1095,1096,1097,1098,1099,1100,1101,1102,1103],{"id":21,"depth":77,"text":22},{"id":53,"depth":77,"text":54},{"id":433,"depth":77,"text":434},{"id":456,"depth":77,"text":457},{"id":496,"depth":77,"text":497},{"id":650,"depth":77,"text":651},{"id":825,"depth":77,"text":826},{"id":948,"depth":77,"text":949},{"id":1021,"depth":77,"text":1022},{"id":1050,"depth":77,"text":1051},"Remove fixed sleeps from tests: event-driven signals, bounded polling helpers, monotonic deadlines, and assertions that report what was still false when time ran out.","md",{"slug":1107,"type":1108,"breadcrumb":1109,"datePublished":1110,"dateModified":1110,"faq":1111,"howto":1118},"replacing-sleep-based-waits-with-polling-assertions","article","Polling Waits","2026-09-18",[1112,1114,1116],{"q":1027,"a":1113},"No. asyncio.sleep(0) yields control to the loop without waiting for wall-clock time, which is a deterministic scheduling operation rather than a bet on duration. It is the correct way to let another task run, and it is what makes forced interleavings reproducible.",{"q":1037,"a":1115},"Short enough that the test does not add perceptible latency and long enough that the loop is not a busy wait — one to ten milliseconds covers nearly everything. The interval decides how late the test notices; the deadline decides how long it tolerates. They are independent choices.",{"q":1043,"a":1117},"The condition that was still false and the last observed value. A helper that raises a bare TimeoutError forces the reader to re-run with prints; one that reports 'queue still had 3 items after 2.0s' usually ends the investigation immediately.",{"name":1119,"description":1120,"steps":1121},"How to replace a fixed sleep with a bounded wait","Prefer an explicit signal, fall back to bounded polling, and make the timeout message name the condition.",[1122,1125,1128,1131,1134],{"name":1123,"text":1124},"Look for a signal the code already emits","An event, a callback, a queue item or a future is better than any poll because it fires exactly when the state changes.",{"name":1126,"text":1127},"Otherwise poll on a monotonic deadline","Loop until the predicate holds or the deadline passes, using time.monotonic rather than wall-clock time.",{"name":1129,"text":1130},"Report the failure usefully","Raise with the predicate's description and the last observed value so the message diagnoses the failure.",{"name":1132,"text":1133},"Keep the interval small and the deadline generous","Poll every few milliseconds and allow seconds, so the test is fast when healthy and patient when the runner is loaded.",{"name":1135,"text":1136},"Delete the sleeps","Grep the suite for sleep calls and replace each with a signal or a bounded wait.","\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Freplacing-sleep-based-waits-with-polling-assertions",{"title":5,"description":1104},"testing-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Freplacing-sleep-based-waits-with-polling-assertions\u002Findex","M-vtjvA3bVmMNAMB0faiXD8i260sjscSJkwfUqkPf6M",1789718767424]