Skip to main content

Explicit waits

PyPI Python License

English | Русский

Functions for explicit waits in automated tests: synchronous and asynchronous, with timeout diagnostics and no external dependencies.

Installation

Requires Python 3.13 or later.

pip install cloudru-qa-explicit-wait

Import the library as the explicit_wait module.

Skills for AI agents

The package includes the explicit-wait-usage skill with examples and usage rules for AI agents. Install the library in your project environment, then run this from its root (requires uv):

uvx library-skills install --skill explicit-wait-usage --yes

For Claude Code, add --claude. The instructions are shipped with the library version.

Quick start

from explicit_wait import WaitOptions

from explicit_wait import wait_until


def get_users() -> list[User]:
    """Get the list of users."""
    response = client.send(...)
    return response.model()


# Wait until the list contains 2 users
wait_until(
    lambda: len(get_users()) == 2,
    options=WaitOptions(timeout_seconds=30, message="the list contains 2 users"),
)

All parameters except the predicate are keyword-only. This makes calls unambiguous and prevents accidentally passing the timeout as the sleep interval.

Wait functions

Function Behavior Returns
wait_until waits until the predicate returns a non-falsy value predicate value
wait_or_none the same, but does not raise on failure predicate value or None
wait_while waits until the predicate stops returning a truthy value None
async_wait_until wait_until in an event loop predicate value
async_wait_or_none wait_or_none in an event loop predicate value or None
async_wait_while wait_while in an event loop None
wait_for decorator: calls the function until its first truthy result function value
async_wait_for the same for a coroutine function function value

Falsy values are values that act as false in boolean operations: False, 0, "", None, [], {}.

Parameters

Pass settings for a wait through options=WaitOptions(...). You can reuse this object.

timeout_seconds

Maximum wait time in seconds. None uses the configuration value (64 seconds by default). To wait forever, request it explicitly:

from explicit_wait import WaitOptions

import math

wait_until(predicate, options=WaitOptions(timeout_seconds=math.inf, max_attempts=100))

Zero, negative values, and NaN raise ValueError.

sleep_seconds

Pause between attempts. Set it with a number, a tuple, or a Backoff object:

from explicit_wait import WaitOptions

from explicit_wait import Backoff, wait_until

# Constant 1-second interval
wait_until(predicate, options=WaitOptions(sleep_seconds=1))

# Grow by a factor of 2 up to the maximum: 1, 2, 4, 8, 16, 32, 64, 100, 100, ...
wait_until(predicate, options=WaitOptions(sleep_seconds=(1, 100)))

# Grow by a specified factor: 1, 3, 9, 27, 81, 100, 100, ...
wait_until(predicate, options=WaitOptions(sleep_seconds=(1, 100, 3)))

# Grow without an upper bound: 1, 2, 4, 8, 16, ...
wait_until(predicate, options=WaitOptions(sleep_seconds=(1, None)))

# Explicit strategy with jitter: randomly subtract up to 20% from each pause
wait_until(
    predicate,
    options=WaitOptions(
        sleep_seconds=Backoff(
            initial_seconds=1,
            maximum_seconds=30,
            multiplier=2,
            jitter=0.2,
        ),
    ),
)

Jitter helps when parallel tests poll the same service: without it, their requests arrive in synchronized waves.

Values are checked when the strategy is created, rather than later inside time.sleep:

  • initial interval — a finite number greater than zero;
  • maximum — a finite number at least as large as the initial interval, or None;
  • multiplier — a finite number greater than or equal to 1;
  • jitter — a number in the range [0, 1);
  • tuple — exactly 2 or 3 items; None is allowed only for the maximum.

max_attempts

Limit the number of attempts instead of, or together with, the time limit. Exhausting attempts raises AttemptsExhaustedError.

from explicit_wait import WaitOptions

wait_until(predicate, options=WaitOptions(max_attempts=5, sleep_seconds=2))

# Remove the configured limit for one wait
wait_until(predicate, options=WaitOptions(max_attempts=None))

An omitted argument means "use the configuration value"; explicit None means "no limit on the number of attempts". The same distinction applies to configure and configured.

If the deadline has already passed, time is the stopping reason, rather than attempts: first check the deadline, then the attempt limit, then whether the next pause fits.

message

A readable description of what you are waiting for. It appears in the error message, so a failed test explains itself. Without message, the predicate function's name is used; for a lambda this is not informative, so always provide message with lambdas.

The object address and argument repr are excluded from the message, keeping useless addresses and sensitive data from functools.partial arguments out of logs.

expected_exceptions and retry_on_exception

expected_exceptions lists predicate exceptions treated as a normal part of waiting. retry_on_exception adds a filter: retry or propagate the error.

from explicit_wait import WaitOptions

# Wait for the service to start, ignoring connection errors
wait_until(predicate, options=WaitOptions(expected_exceptions=(ConnectionError, TimeoutError)))

# Retry only 5xx responses and propagate 4xx errors immediately
wait_until(
    predicate,
    options=WaitOptions(
        expected_exceptions=httpx.HTTPStatusError,
        retry_on_exception=lambda error: isinstance(error, httpx.HTTPStatusError) and error.response.status_code >= 500,
    ),
)

The filter receives the exception as Exception, so use isinstance before accessing class-specific fields: the types of expected_exceptions and retry_on_exception are not linked.

An exception outside expected_exceptions propagates unchanged with its original traceback. KeyboardInterrupt, SystemExit, and asyncio.CancelledError are never caught.

The library's own errors (WaitError and its subclasses) are never expected, regardless of expected_exceptions. Otherwise, a nested wait's timeout would silently become another failed attempt in the outer wait. To retry a nested wait anyway, convert its error to a falsy result inside the predicate:

from explicit_wait import WaitOptions

wait_until(lambda: wait_or_none(inner_condition, options=WaitOptions(timeout_seconds=5)) is not None)

on_attempt, on_timeout, and on_attempts_exhausted

Hooks receive an Attempt object:

from explicit_wait import WaitOptions

from explicit_wait import Attempt, wait_until


def log_attempt(attempt: Attempt) -> None:
    print(f"attempt {attempt.number}: {attempt.result!r}, elapsed {attempt.elapsed_seconds:.1f}s")


wait_until(predicate, options=WaitOptions(on_attempt=log_attempt))
Attempt field Value
number attempt number, starting at 1
elapsed_seconds time since the wait started
result predicate result (None if it raised an expected exception)
exception expected predicate exception or None
next_sleep_seconds pause before the next attempt, or None if none is planned
has_no_planned_sleep whether no pause is planned at the time of the decision

has_no_planned_sleep is a snapshot of the plan, not a guarantee that the attempt is final: an attempt with a planned pause may still be the last if the wait is canceled externally, wakes up late, or the hook itself uses up the remaining time.

on_attempt runs after each attempt, including falsy results and expected exceptions. It does not run if the predicate raises an unexpected exception: that stops the wait immediately.

on_timeout runs only when the time limit stops the wait; on_attempts_exhausted runs only when max_attempts is exhausted. Neither runs on cancellation through cancel_event.

Time spent in hooks counts toward the deadline: if on_attempt uses up the remaining budget, the planned pause is skipped and the elapsed deadline becomes the stopping reason, even if attempts had already been exhausted before the hook. Exception diagnostics and the Attempt passed to the terminal hook come from a single clock reading before the hook, so they do not include the hook's own duration.

An exception raised by the hook itself propagates and is not treated as a predicate exception, even if its type is listed in expected_exceptions.

cancel_event

Allows a long wait to be canceled externally. When set, the pause between attempts uses Event.wait, so the sleeper parameter is not used.

from explicit_wait import WaitOptions

from threading import Event

cancel = Event()
# from another thread: cancel.set()

wait_until(predicate, options=WaitOptions(timeout_seconds=600), cancel_event=cancel)

Cancellation raises WaitCancelledError. wait_or_none does not suppress it: cancellation is different from failing to satisfy the condition.

The asynchronous version needs no separate parameter: standard task cancellation works.

clock and sleeper

Time source and sleep function. These let you test waiting code without real pauses. Defaults are time.monotonic and time.sleep (asyncio.sleep in asynchronous functions).

Timeout diagnostics

The stopping error carries everything needed to investigate a failed test:

from explicit_wait import WaitOptions

from explicit_wait import TimeoutExpiredError, wait_until

try:
    wait_until(
        lambda: get_order().status == "ready",
        options=WaitOptions(timeout_seconds=30, message="the order becomes ready"),
    )
except TimeoutExpiredError as error:
    print(error.attempts)  # number of attempts
    print(error.elapsed_seconds)  # actual elapsed time
    print(error.last_result)  # last predicate result
    print(error.last_exception)  # last expected exception

The error message uses the same fields. Most waits stop because the next pause would no longer fit before the deadline; in this case, deadline_reached is False:

No further attempt could start before the 30 second deadline waiting for the order becomes ready (attempts: 4, elapsed: 14.000s, last result: False)

If the deadline has actually passed, for example because the predicate used up the time or the wait woke up late, the message differs and deadline_reached is True:

Timeout of 30 seconds expired waiting for the order becomes ready (attempts: 3, elapsed: 30.500s, last result: False)

If attempts raised an expected exception, it appears in both the message and __cause__, so the traceback shows the original cause:

No further attempt could start before the 30 second deadline waiting for the service responds (attempts: 4, elapsed: 14.002s, last exception: ConnectionError: [Errno 61] Connection refused)

An overly long repr of the last result is truncated to keep the report compact.

Exception hierarchy

WaitError
├── WaitStoppedError            — the condition was not satisfied
│   ├── TimeoutExpiredError     — time expired (also inherits from built-in TimeoutError)
│   └── AttemptsExhaustedError  — max_attempts exhausted
├── InvalidPredicateError       — predicate violated the contract (also inherits from TypeError)
└── WaitCancelledError          — wait interrupted through cancel_event

InvalidPredicateError inherits from TypeError, so it can be caught as usual, and from WaitError, so expected_exceptions cannot accidentally swallow it.

All exceptions support pickle, copy, and deepcopy, including their fields, add_note notes, and custom subclass state. An exception cannot be serialized if its last_result or last_exception cannot be serialized.

TimeoutExpiredError inherits from built-in TimeoutError, so one except TimeoutError catches timeouts from both this library and asyncio. httpx timeouts are outside this hierarchy: httpx.TimeoutException inherits from httpx.TransportError and must be caught separately:

except (TimeoutError, httpx.TimeoutException):
    ...

The deadline_reached field on TimeoutExpiredError distinguishes between a deadline that has actually passed and a next pause that could not fit before it, making another attempt pointless.

Combining predicates

AllOf is true when all predicates are true at once; AnyOf is true when at least one is true. Predicates run in the listed order with short-circuit evaluation.

from explicit_wait import WaitOptions

from explicit_wait import AllOf, AnyOf, wait_until

wait_until(
    AnyOf([lambda: get_status() == "ready", lambda: get_status() == "completed"]),
    options=WaitOptions(timeout_seconds=30, message="the status becomes ready or completed"),
)

wait_until(
    AllOf([users_loaded, settings_loaded, profile_loaded]),
    options=WaitOptions(timeout_seconds=60, message="all data is loaded"),
)

Combinations keep no state between calls: reuse the same object across waits and call it from multiple threads. Build compositions with operators:

ready = AnyOf([status_is("ready"), status_is("completed")]) & AllOf([cache_warm, index_built])

For asynchronous predicates, use AsyncAllOf and AsyncAnyOf: they await each predicate and also use short-circuit evaluation:

from explicit_wait import WaitOptions

from explicit_wait import AsyncAllOf, async_wait_until

await async_wait_until(
    AsyncAllOf([order_is_paid, invoice_is_issued]),
    options=WaitOptions(timeout_seconds=30, message="the order is paid and the invoice is issued"),
)

Synchronous AllOf and AnyOf reject asynchronous predicates with TypeError: a coroutine is truthy, so accepting it would turn a false condition into a success.

Asynchronous waits

The pause between attempts uses asyncio.sleep without blocking the event loop, so background tasks continue between attempts. A predicate may be a coroutine or a regular function. Regular functions run directly in the event loop, so they and all hooks (on_attempt, on_timeout, on_attempts_exhausted) must be non-blocking: time.sleep, file I/O, or a synchronous HTTP client inside them blocks the entire loop.

If the predicate returns an awaitable, it is always awaited rather than treated as the final value.

from explicit_wait import WaitOptions

from explicit_wait import async_wait_until


async def order_is_ready() -> bool:
    response = await client.get(f"/orders/{order_id}")
    return response.json()["status"] == "ready"


await async_wait_until(order_is_ready, options=WaitOptions(timeout_seconds=30, message="the order becomes ready"))

Cancellation works as usual: asyncio.CancelledError is not caught, so task.cancel() interrupts the wait even with expected_exceptions=Exception.

Decorators

from explicit_wait import WaitOptions

from explicit_wait import wait_for


@wait_for(options=WaitOptions(timeout_seconds=30, sleep_seconds=1))
def deploy_finished() -> bool:
    return client.get("/deploy").json()["done"]


deploy_finished()  # returns when deployment finishes

The decorated function's name appears in the error message. For coroutine functions, use async_wait_for.

Configuration

from explicit_wait import configure, configured, get_config, reset_config

# Defaults for all waits in the process
configure(sleep_seconds=(2, 64), timeout_seconds=64)

# Temporary override within the block, restored even if an exception occurs
with configured(timeout_seconds=5):
    wait_until(predicate)

# Current values and reset to original defaults
get_config()
reset_config()

Configuration is an immutable WaitConfig object. It cannot be left partly applied with inconsistent fields, and invalid values are rejected as soon as you call configure. An explicit wait function argument always overrides the configuration. Concurrent configure calls from different threads are serialized with a lock, so updates are not lost.

configure and configured have different scopes:

Scope configure configured
Current thread visible visible
Another thread, ThreadPoolExecutor visible not visible
asyncio task created inside the block visible visible
asyncio.to_thread visible visible

configured stores the override in contextvars, so coroutine contexts inherit it, but regular threads do not. A task created inside the block that outlives it keeps the override: its context is captured when the task is created.

Reset global configuration between tests, otherwise results can depend on the test execution order:

import pytest
from explicit_wait import reset_config


@pytest.fixture(autouse=True)
def reset_wait_config():
    reset_config()
    yield
    reset_config()

Read values from environment variables to increase timeouts on slow CI without changing the code:

from explicit_wait import WaitConfig, configure

config = WaitConfig.from_env()  # default prefix is EXPLICIT_WAIT_
configure(
    timeout_seconds=config.timeout_seconds,
    sleep_seconds=config.backoff,
    max_attempts=config.max_attempts,
)
Variable Format Example
EXPLICIT_WAIT_TIMEOUT_SECONDS number 120
EXPLICIT_WAIT_SLEEP_SECONDS number or initial,maximum[,multiplier] 2,64,2 or 1,none
EXPLICIT_WAIT_MAX_ATTEMPTS integer 10

Logging and reports

The library logs each attempt at DEBUG level using the standard logger and does not configure handlers itself:

import logging

logging.getLogger("explicit_wait").setLevel(logging.DEBUG)

Build Allure steps with a hook; no separate dependency is needed:

from explicit_wait import WaitOptions

import allure
from explicit_wait import Attempt, wait_until


def report_attempt(attempt: Attempt) -> None:
    with allure.step(f"Attempt {attempt.number}: {attempt.result!r}"):
        pass


wait_until(predicate, options=WaitOptions(on_attempt=report_attempt, message="the order becomes ready"))

Extending the library

The package root exports only the stable API: wait functions, combinations, Backoff, Attempt, configuration, exceptions, and the UNSET marker. Helper type aliases and abstract base classes are available from their own modules and are not part of the stable API:

from explicit_wait.aggregate import Aggregate, AsyncAggregate
from explicit_wait.backoff import SleepSecondsType
from explicit_wait.types import AsyncPredicate, Clock, ExceptionFilter, ExceptionsType, Predicate, Sleeper

To implement a custom combination, inherit from Aggregate (or AsyncAggregate) and implement __call__:

from explicit_wait.aggregate import Aggregate


class MajorityOf(Aggregate):
    """True when most predicates are true."""

    def __call__(self) -> bool:
        passed = sum(1 for predicate in self.predicates if self.check(predicate))
        return passed * 2 > len(self.predicates)

Call predicates in a custom combination only through self.check(...): it rejects asynchronous results that would otherwise silently turn a false condition into a success.

Development

The library is developed in the qa-libraries monorepo alongside the other cloudru-qa-* libraries. You need uv, GNU Make, and checkmake. Run commands from the repository root:

make sync                        # install dependencies for all libraries from uv.lock
make test-explicit-wait          # tests for this library
make typecheck-explicit-wait     # mypy for this library
make check                       # formatting, ruff, mypy, and tests for the entire workspace

Tests run on Python 3.13 and 3.14. Create a separate environment for another version: make sync test-explicit-wait PYTHON_VERSION=3.14.

Publish a release with the git tag explicit-wait/v<version>. The tag version must match the version in packages/explicit-wait/pyproject.toml.

Metadata

Release files for cloudru-qa-explicit-wait 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cloudru-qa-explicit-wait 1.0.0
File Size Uploaded Provenance Actions
cloudru_qa_explicit_wait-1.0.0.tar.gz 53.1 kB With provenance Details for cloudru_qa_explicit_wait-1.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for cloudru-qa-explicit-wait 1.0.0
File Interpreter ABI Platform Actions
cloudru_qa_explicit_wait-1.0.0-py3-none-any.whl Python 3 none any Details for cloudru_qa_explicit_wait-1.0.0-py3-none-any.whl

Total release size: 99.3 kB

Release files / cloudru_qa_explicit_wait-1.0.0.tar.gz

Download URL cloudru_qa_explicit_wait-1.0.0.tar.gz
Size 53.1 kB
Tags Source
SHA-256 checksum
How to use checksums
93259679f7236f2185c2cfbb30b7b1458f64d45091764b4edfbf24afde720791
BLAKE2b-256 checksum
How to use checksums
fdcd92d74965c4a69d2e018d4aff6a66519429426129c4cfa4e2b30456811789
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.

Transparency log

Release files / cloudru_qa_explicit_wait-1.0.0-py3-none-any.whl

Download URL cloudru_qa_explicit_wait-1.0.0-py3-none-any.whl
Size 46.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9e0fce14de12435f39a6409f1349e222bb0fe003cc7216d4ef002af39e554b49
BLAKE2b-256 checksum
How to use checksums
d5968d3a0cfb3cb9a74517726db400f462b2ff2088a75ffec29570179adc80cd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.

Transparency log

Security for cloudru-qa-explicit-wait 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for cloudru_qa_explicit_wait-1.0.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page