Skip to content

Polling

After this page you can wait until a query returns what you expect, set how long and how often the call tries again, and tell when a poll is the wrong tool.

Wait for a result

Some results are not ready when a call returns. A background job finishes later. A search index catches up. A record appears after a message is handled. For these, use wait_until(). It runs a query again and again until your function says the result is good, and it returns that result.

import dataclasses

from pytest_graphql import build_client


class AppearsLater:
    # Answers that the user does not exist until the third call, as a server
    # does while a background job is still running.

    def __init__(self, inner, ready_on_call):
        self.inner = inner
        self.ready_on_call = ready_on_call
        self.calls = 0

    def send(self, request, *, timeout):
        raw = self.inner.send(request, timeout=timeout)
        self.calls += 1
        if self.calls < self.ready_on_call:
            return dataclasses.replace(raw, data={"user": None})
        return raw

    def close(self):
        pass


late = AppearsLater(gql.transport, ready_on_call=3)
client = build_client(
    url="http://localhost:8000/graphql", transport=late, schema=gql.schema
)

user = client.wait_until(
    "user",
    id="u1",
    fields=["id", "name"],
    until=lambda user: user is not None,
    timeout=5,
    interval=0.01,
)

assert user.name == "Ada Lovelace"
assert late.calls == 3

The example uses a small transport of its own to play the part of a slow server. It answers "no such user" twice and then gives the real answer. See Extending for how a transport works. In your tests, the server is the slow part, and you write only the wait_until() call. The interval is 0.01 seconds here so that the page runs fast. For a real server, use a longer interval, such as 1 second.

The call takes the same name, arguments and options as query(). These are the options that belong to the poll:

Option Default Meaning
until none, required A function. It receives what query() returns. The poll ends when its result is true.
timeout 30.0 The deadline of the whole poll, in seconds.
interval 1.0 The first sleep between two attempts, in seconds.
backoff 1.0 The factor that makes the sleep longer after each attempt.
ignore none An exception class, or a tuple of classes, that means "not ready yet".

until receives the same value that query() would return. With raw=True it receives the whole response, and wait_until() returns the response too. A variable that has the name of a poll option goes through variables={...}.

timeout here is not the time limit of one HTTP call. The limit of one call is the gql_timeout setting. See Configuration.

Set the delay

After a failed attempt, the call sleeps for interval * backoff ** (attempt - 1) seconds. It never sleeps longer than the time that is left, so the last attempt runs at the deadline. The deadline is set once, before the first attempt. Nothing else limits a sleep.

interval backoff timeout Sleeps between the attempts
1 1 5 1, 1, 1, 1, 1
1 2 30 1, 2, 4, 8, 15
0.5 1.5 5 0.5, 0.75, 1.125, 1.6875, 0.9375
1 1 0 none

The second row ends with 15 and not 16, because 15 seconds are left. The last row shows that one attempt always runs, even when the timeout is 0.

A constant interval is right for most tests. Use backoff when the thing you wait for can take a long time, and you do not want to call the server often while you wait. timeout, interval and backoff must be finite numbers. timeout and interval must be 0 or more, and backoff must be 1 or more. A value outside that range raises ValueError, and a value that is not a number raises TypeError, before the first call. An infinite timeout is refused, because a poll that cannot end is not a bounded wait.

import time

from pytest_graphql import WaitTimeoutError

start = time.monotonic()
try:
    gql.wait_until(
        "user",
        id="u1",
        fields=["id", "name"],
        until=lambda user: user.name == "Grace Hopper",
        timeout=0.1,
        interval=0.01,
        backoff=2,
    )
except WaitTimeoutError as error:
    assert error.operation == "user"
    assert error.attempts >= 1
    assert error.timeout == 0.1
    assert time.monotonic() - start >= 0.1
else:
    raise AssertionError("the name never changes, so the wait must time out")

Only queries

A poll repeats its call, so it must not change anything. wait_until() takes the name of a query. The schema decides what a name is. A mutation raises ArgumentError before the first call.

import pytest

from pytest_graphql import ArgumentError

with pytest.raises(ArgumentError, match="polls queries only"):
    gql.wait_until("updateUser", id="u1", until=lambda user: user)

To change something and then wait for the effect, call the mutation once. Then poll a query that reads the effect.

Keep waiting through an error

Some calls fail while the system is not ready. A gateway answers 503 while a service starts. A query can fail with an execution error until the data exists. List those exceptions in ignore. The poll counts such an attempt as "not ready yet" and tries again.

ignore covers the whole attempt. It covers the call, the reading of the response, and your until function. It accepts Exception subclasses only, so Ctrl+C and SystemExit always stop the test. An exception that is not listed leaves the poll at once, unchanged.

from pytest_graphql import GraphQLHTTPStatusError, build_client


class StartingUp:
    # Fails with 503 for the first calls, as a server does while it starts.

    def __init__(self, inner, failures):
        self.inner = inner
        self.failures = failures
        self.calls = 0

    def send(self, request, *, timeout):
        self.calls += 1
        if self.calls <= self.failures:
            raise GraphQLHTTPStatusError(
                "request failed with status 503: Service Unavailable",
                request=request.redacted(),
                status_code=503,
                body_excerpt="Service Unavailable",
            )
        return self.inner.send(request, timeout=timeout)

    def close(self):
        pass


starting = StartingUp(gql.transport, failures=2)
client = build_client(
    url="http://localhost:8000/graphql", transport=starting, schema=gql.schema
)

user = client.wait_until(
    "user",
    id="u1",
    fields=["id", "name"],
    until=lambda user: user.name,
    timeout=5,
    interval=0.01,
    ignore=GraphQLHTTPStatusError,
)

assert user.name == "Ada Lovelace"
assert starting.calls == 3

Be exact in ignore. A broad class, such as Exception, hides a real defect until the time runs out. The report then says "timed out", and the cause is gone from the first line.

Stop early on a state that cannot recover

A job that has failed will not succeed if you wait longer. Do not wait for the deadline. Raise from until. An exception that is not in ignore leaves the poll at once, so the test fails with your message and not with a timeout.

import pytest


def done(user):
    if user.name == "Ada Lovelace":  # stands in for a status such as "FAILED"
        pytest.fail("the job failed, so waiting longer cannot help")
    return user.name == "Grace Hopper"


with pytest.raises(pytest.fail.Exception, match="the job failed"):
    gql.wait_until("user", id="u1", fields=["id", "name"], until=done, timeout=5)

When the time runs out

The poll raises WaitTimeoutError. It has these attributes:

Attribute Holds
operation The name of the query.
attempts How many calls were made.
elapsed The seconds from the first attempt until the poll stopped.
timeout The limit the poll was given.
last_response The latest response of any attempt, or None.
last_exception The latest exception that ignore swallowed, or None.

The message names the attempts and the time, and it says what to check:

wait_until('user') timed out: 5 attempt(s) in 0.05 s (limit 0.05 s).
  Expected: until() to return true before the deadline.
  Last response: none, no attempt returned one.
  Last ignored exception: GraphQLHTTPStatusError: request failed with status 503: Service Unavailable
  Fix: raise timeout, check that until() can become true, or list the exception that hides the real failure in ignore.

The text of the last swallowed exception is shown only when it can be checked against the request that caused it. If it cannot, the text is withheld, because it could hold a secret. See Diagnostics. The exception itself is always on last_exception.

When polling is the wrong answer

Polling is slow, and it can hide a defect. Use it only when the product is supposed to need time. In these cases, something else is better.

  • The result is in the answer. A mutation returns the changed record. Assert on that. Do not poll for a value that the call already gave you.
  • The test waits for time to pass. A time.sleep(5) costs five seconds every run, and it is still too short on a slow day. wait_until() returns as soon as the result is ready.
  • The state can only get worse. If a "failed" state is final, stop on it, as shown above. Waiting for the deadline hides the real answer.
  • You need to prove that something does not happen. A poll cannot show a negative. Waiting for the whole timeout is slow and proves little. Check once, after the event that should have caused the effect.
  • You poll to hide a race in the product. If a test passes only after many attempts, the product is slow or it has a defect. Keep the timeout close to what the product promises, so a slowdown fails the test.
  • The wait takes minutes. A test that waits that long is hard to run. Test the job logic in a smaller test, and keep one slow test for the whole path.
  • You want to repeat a mutation. A mutation can run its side effect twice, so a poll refuses it. If a mutation may fail and you need to repeat it, repeat it in your own code, where you can see what repeats.