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.monotonicandasyncioare standard library. pytest >= 8.0, andpytest-timeoutso 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.
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})"
)
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)
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}]
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.sleepinside 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.
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()
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.
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}"
)
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.
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:
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.
Finding the sleeps you already have
A one-line audit finds every fixed wait in a suite, and the results are usually startling.
grep -rn "time.sleep\|asyncio.sleep(" tests/ | grep -v "sleep(0)" | sort -t'(' -k2 -rn
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.
Related
- Timeouts, Cancellation & Deadlines — where these waits sit among the suite's other deadlines.
- Waiting for Container Readiness Without sleep — the same idea applied to service start-up.
- Testing Thread Safety with Barriers and Events — signal-based coordination for threads.
- Debugging Flaky Tests with pytest-rerunfailures — what to do about the flakes until the sleeps are gone.
← Back to Timeouts, Cancellation & Deadlines