httpx MockTransport: test a Sume poll loop with no network

Pass httpx.MockTransport to httpx.Client to feed a Sume status poller canned replies, assert it stops on terminal and honors next_poll_after_seconds.

5 min readSume
All posts

Short answer

Build the client with httpx.Client(transport=httpx.MockTransport(handler)), where handler takes a request and returns httpx.Response(200, json=...), as shown on the HTTPX transports page. Your poller runs unchanged, the test controls every reply, and no request ever leaves the machine.

Why this seam is enough

A Sume job poller has three behaviors worth testing: it keeps going while the job is not terminal, it waits as long as the server asked, and it stops the moment terminal is true. All three depend only on the JSON the status route returns, so a mock transport covers them.

Inject the sleep function rather than patching time. That makes the test instant and lets you assert on the exact waits.

Status reply fields a poller reads, per the Sume jobs docs (read 2026-10-03)
FieldPoller behavior
terminalStop when true
next_poll_after_secondsSleep this long before the next read, when present
sume_statusOne of queued, processing, completed, failed, canceled
result_readyFetch /result only when true

The test

The handler below returns one non-terminal reply and then a completed one. The assertion on waits proves the poller used the server's hint, not a hard-coded delay. The base_url means the poller can call relative paths, which is how it will run in production against api.sume.com.

import httpx


def poll(client, job_id, sleep):
    while True:
        s = client.get(f"/v1/jobs/{job_id}/status").json()
        if s["terminal"]:
            return s
        sleep(s.get("next_poll_after_seconds") or 3)


def test_poll_stops_on_terminal():
    replies = iter([
        {"terminal": False, "next_poll_after_seconds": 2},
        {"terminal": True, "sume_status": "completed"},
    ])
    waits = []
    transport = httpx.MockTransport(
        lambda request: httpx.Response(200, json=next(replies)))
    with httpx.Client(transport=transport,
                      base_url="https://api.sume.com") as client:
        out = poll(client, "job_123", waits.append)
    assert out["sume_status"] == "completed"
    assert waits == [2]


test_poll_stops_on_terminal()
print("ok")

Failure cases to add

Once the happy path passes, add replies that mirror real errors. A handler can inspect request.url and return a 404 for a job the key does not own, since job reads are scoped to the member whose key created the job. It can return 429 with a retry-after header, and your retry layer should wait that long. It can return a failed terminal reply, and your code should read the error from the job record rather than ask for /result, which answers 409 job_not_completed for anything not completed.

Also test the stop condition you did not intend: a reply with no next_poll_after_seconds. The fallback delay then applies, and the loop must still end on terminal.

What this does not prove

A mock transport proves your logic, not the service. Keep one small live check for the contract, such as the unauthenticated GET /v1/health route, and let the mocked tests carry everything that would otherwise cost credits. Check your replies against the live OpenAPI document that Sume publishes, so that a field rename shows up as a failing comparison.

For the async variant, httpx also accepts a mock transport on its async client, and the same handler shape applies. The async submit and poll post shows the production loop that this test exercises.

Making the test strict

A canned iterator hides one mistake: if the poller reads more often than expected, next() raises StopIteration and the test fails with an unhelpful message. Prefer a handler that records each request and asserts on the count and the path. Also assert the header your client sends. The API accepts either Authorization or x-api-key, but a request with both is rejected with 401 unauthorized, so a test that inspects request.headers can prove your code sets exactly one.

Keep each test about one behavior. A test named for stopping on terminal should not also check retry-after handling; split them, and the failing name tells you what broke.

Running it in CI

Because the transport is in process, the suite needs no secrets and no outbound network. That means it can run on every pull request, including from forks, where secrets are normally withheld. Store the real key only in the job that runs your live smoke check, and read it from an environment variable there. The mocked tests should pass with the variable unset, which is itself a useful guard against code that reads the key at import time.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume