Async & Concurrency

Replacing Sleep-Based Waits with Polling Assertions

Every 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.

Prerequisites

  • Python 3.9+; time.monotonic and asyncio are standard library.
  • pytest >= 8.0, and pytest-timeout so an unsatisfiable wait fails rather than hanging.
  • 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.

Solution

Prefer an explicit signal. Where none exists, poll on a monotonic deadline with a message that names the condition.

Python
import time
from typing import Callable


def wait_until(
    predicate: Callable[[], bool],
    *,
    timeout: float = 2.0,
    interval: float = 0.01,
    description: str = "condition",
):
    """Block until predicate() is true, or fail with a message that diagnoses."""
    deadline = time.monotonic() + timeout        # monotonic: immune to clock changes
    last = None
    while time.monotonic() < deadline:
        last = predicate()
        if last:
            return
        time.sleep(interval)                     # yields the GIL; not a fixed wait
    raise AssertionError(
        f"{description} still false after {timeout}s (last value: {last!r})"
    )
Python
import asyncio


async def await_until(predicate, *, timeout=2.0, interval=0.01, description="condition"):
    """The asyncio counterpart: the deadline is a cancel scope, not a loop guard."""
    async with asyncio.timeout(timeout):
        while not predicate():
            await asyncio.sleep(interval)
Python
def test_worker_drains_the_queue(worker, queue):
    queue.put({"id": 1})
    worker.start()

    # Returns in about one interval when healthy; fails in two seconds with a
    # message naming the queue when not.
    wait_until(queue.empty, timeout=2.0, description="queue drained")

    assert worker.processed == [{"id": 1}]
Fixed sleep compared with a bounded poll 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. Slower when healthy, and still flaky when not sleep(0.5) work waiting for nothing — 470 ms and still too short at 600 ms wait_until work poll returns at ~40 ms deadline 2 s tolerant of a loaded runner The poll is faster in the common case and more patient in the rare one — the sleep is neither.
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.

Why this works

The two numbers in a sleep are conflated. sleep(0.5) 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.

time.monotonic rather than 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. monotonic counts forward from an arbitrary origin and cannot jump.

Edge cases and failure modes

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

Preferring a signal to a poll

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.

Python
import asyncio


class Worker:
    def __init__(self):
        self.idle = asyncio.Event()          # part of the worker's own interface
        self.processed: list[dict] = []

    async def run(self, queue):
        while True:
            item = await queue.get()
            self.processed.append(item)
            if queue.empty():
                self.idle.set()              # tell anyone who cares
            else:
                self.idle.clear()
Python
async def test_worker_becomes_idle(worker, queue):
    await queue.put({"id": 1})
    task = asyncio.create_task(worker.run(queue))

    # No interval, no polling: this returns the instant the worker says so.
    async with asyncio.timeout(2):
        await worker.idle.wait()

    assert worker.processed == [{"id": 1}]
    task.cancel()

Adding an 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.

The same reasoning applies to threads. A 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.

Making the failure message do the work

A bounded wait that raises 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.

Python
def wait_for_rows(session, table, expected, *, timeout=5.0):
    def count():
        return session.query(table).count()

    deadline = time.monotonic() + timeout
    observed = count()
    while time.monotonic() < deadline:
        if observed == expected:
            return
        time.sleep(0.02)
        observed = count()
    raise AssertionError(
        f"expected {expected} rows in {table.__tablename__} after {timeout}s, "
        f"found {observed}"
    )
Plain text
E   AssertionError: expected 3 rows in order_line after 5.0s, found 1

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.

The same principle applies to the predicate's description when a generic helper is used. wait_until(queue.empty) failing says almost nothing; wait_until(queue.empty, description="queue drained") at least names the expectation, and passing a lambda that returns the value rather than a boolean — with the helper truth-testing it — gives the observed state for free.

What each kind of timeout message tells the reader 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. The message is the diagnosis TimeoutError something did not happen · re-run with prints to find out what "queue drained still false after 2.0s" names the expectation · still silent about how far it got "expected 3 rows, found 1 after 5.0s" distinguishes stalled from partial · usually ends the investigation
The third form costs one f-string and removes the re-run that the first two guarantee.

Waiting on state outside the process

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.

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:

Python
import time


def wait_until_backoff(predicate, *, timeout=10.0, first=0.01, cap=0.5):
    deadline = time.monotonic() + timeout
    interval = first
    while time.monotonic() < deadline:
        if predicate():
            return
        time.sleep(min(interval, max(0.0, deadline - time.monotonic())))
        interval = min(interval * 2, cap)     # 10 ms, 20, 40 … capped at 500 ms
    raise AssertionError(f"not satisfied within {timeout}s")

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.

Fixed-interval polling versus exponential backoff 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. Same responsiveness early, far less load late fixed 10 ms ~1,000 queries over 10 s against a shared database 10 ms doubling to a 500 ms cap ~25 queries over 10 s · early success still seen within milliseconds
Backoff matters only for waits that cross a process boundary. In-process predicates are cheap enough that a fixed short interval is fine.

Finding the sleeps you already have

A one-line audit finds every fixed wait in a suite, and the results are usually startling.

Bash
grep -rn "time.sleep\|asyncio.sleep(" tests/ | grep -v "sleep(0)" | sort -t'(' -k2 -rn
Plain text
tests/integration/test_worker.py:44:    time.sleep(5)          # "let kafka settle"
tests/api/test_webhook.py:81:           await asyncio.sleep(2)
tests/integration/test_cache.py:29:     time.sleep(1.5)

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 wait_until helper exists in the test package.

Frequently Asked Questions

Is asyncio.sleep(0) also a sleep to be removed? 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.

How short should the polling interval be? 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.

What should a polling helper report when it times out? 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.

← Back to Timeouts, Cancellation & Deadlines