Skip to main content

Batch API — submit, stream, forget

This is how you should scrape in production. Fire 10,000 URLs, walk away, and come back to handled results with progress tracking, failure visibility, and zero infrastructure.

batch = client.submit_batch("daily-reviews", urls)

for result in batch.iter_results():
save(result)

# Also available: batch.pct, batch.eta_seconds, batch.success_count,
# batch.failed_count, on_progress(fn), on_complete(fn)
  • No workers to manage. The server runs your jobs. You iterate results as they finish.
  • No memory explosion. Streaming — yields results as they complete, not at the end.
  • No rate-limit math. The SDK polls the server with cursor-based filters. ~1 API call per tick, no matter how big the batch.
  • Every failure is visible. Each yielded result tells you if it worked, why it failed, and whether to retry.
  • Resume-friendly. Persist batch.collection_id + batch.run_id, reattach later with client.get_batch().

Scales from 3 URLs to 50,000+ without changing your code.

Quickstart

from scrapingpros import ScrapingPros

client = ScrapingPros("your_token")

batch = client.submit_batch("daily-reviews", [
{"url": tour["url"], "custom_id": tour["id"], "browser": True}
for tour in my_tours
])

for result in batch.iter_results():
if result.guidance and result.guidance.success:
save_to_db(my_id=result.custom_id, content=result.content)

if batch.completed_count % 100 == 0:
print(f"{batch.pct:.1f}% ({batch.success_count} OK / {batch.failed_count} failed) "
f"— ETA {batch.eta_seconds:.0f}s" if batch.eta_seconds else "")

Why this is the production path

submit_batch sends one HTTP request to the server queue and streams results back as workers complete them. The alternative — opening N parallel connections from your machine to the per-request endpoint — does not scale. We benchmarked both at production scale (N=1000 URLs with browser=True):

MethodWallThroughput
scrape_many (removed in v0.7)930 s1.07 URLs/s
batch_scrape (list return)185 s5.39 URLs/s
submit_batch + iter_results (streaming)215 s4.66 URLs/s

That's a 5× wall-time difference at N=1000. Beyond raw speed, the Collections-backed methods also give you automatic credit refunds on soft-blocks (the validator detects thin / blocked content and refunds without you having to inspect each response), per-job retries with proxy rotation, resume after a client crash, and constant memory (iter_results streams; you never hold all results in RAM).

scrape_many was removed in v0.7.0. If you had it: rename to batch_scrape — same signature.

custom_id — map results back to your data

Each request carries an opaque custom_id that the API echoes back on both the job listing and the scrape result. Use it to correlate results with your own data without depending on order:

batch = client.submit_batch("reviews", [
{"url": tour["url"], "custom_id": tour["id"]}
for tour in my_tours
])

for result in batch.iter_results():
if result.guidance.success:
# result.custom_id is your tour["id"]
save_reviews(tour_id=result.custom_id, html=result.content)

You can submit the same URL with different custom_ids — the API treats them as separate jobs (deduped by (url, custom_id)).

Progress tracking

Progress attributes update as results stream in:

batch = client.submit_batch("daily", items)

for result in batch.iter_results():
# Attributes are read-only and always current
batch.total # total jobs submitted
batch.completed_count # terminal state: completed + failed + timeout
batch.success_count # guidance.success == True
batch.failed_count # failed, timeout, or completed but unsuccessful
batch.processing_count # still running (derived)
batch.pct # percentage 0..100
batch.elapsed_seconds # since iteration started
batch.jobs_per_second # throughput
batch.eta_seconds # estimated seconds remaining (None if not enough data)
batch.status # "in_progress", "completed", "failed", "cancelled"
batch.is_finished # True when status is terminal

Knowing when the batch is done — and what happened

The run finishes when batch.status reaches one of the terminal values (completed, failed, cancelled). Three equivalent ways to see it:

  • iter_results() exits on its own once status is terminal. After the for loop, the batch is done.
  • batch.is_finished — explicit boolean. Useful in polling code that doesn't iterate (e.g. a webhook handler that already has all the results).
  • on_complete(fn) callback — fires exactly once when iteration finishes. Best place to emit the end-of-run report.

For the report itself, batch.summary() returns a BatchSummary snapshot that includes both layers of the picture:

  • What the server ran: queued, succeeded, failed.
  • What the server rejected at submit time: blocked (SSRF), invalid (per-item validation), duplicates.
def report(b):
print(b.summary()) # multi-line, ready-to-paste
# or: send_to_slack(b.summary())
# or: db.runs.update(b.collection_id, asdict(b.summary()))

batch = client.submit_batch("daily", items).on_complete(report)
for r in batch.iter_results():
handle(r)
# At loop exit, `report` has fired with the full picture.

A sample print(batch.summary()) for a 1,752-item submit with 3 long custom_ids and 48 server-side failures:

Batch summary (status: completed)
submitted : 1752
queued : 1749
succeeded: 1701
failed : 48
rejected : 3
blocked : 0
invalid : 3
duplicates : 0

The two invariants that always hold:

  • submitted == queued + blocked + invalid + duplicates (after submit_batch returns).
  • succeeded + failed == queued (once batch.is_finished is True).

A 100%-valid run shows rejected: 0 and the report is unchanged from the v0.7.1 numbers.

Limitation: reattached handles cannot recover submitted

If you reattach to a batch from persisted (cid, rid) — via client.get_batch(cid, rid) or inside a webhook handler — summary().submitted is None (and the printed report shows ?? (reattached) on that line). The server's collection record does not echo back the original submit size yet, so the SDK has no way to reconstruct it after the original process exits.

The supported workaround (v0.7.2+): persist len(payload) alongside the IDs at submit time, then hand it back via the submitted_count= kwarg on get_batch or iter_results:

# Submit
batch = client.submit_batch("daily", items)
db.runs.insert(cid=batch.collection_id, rid=batch.run_id, submitted=len(items))

# Reattach (later process, webhook handler, dashboard)
row = db.runs.find(cid=saved_cid)
for r in client.iter_results(saved_cid, saved_rid, submitted_count=row.submitted):
handle(r)
# At loop exit, on_complete (if registered) sees a complete summary.

Webhook fire-and-forget (no client process kept alive)

If your process can't keep an open connection — a queue worker, cron job, AWS Lambda with a short timeout — submit the batch with a callback_url and don't iterate. The server POSTs back when the run finishes:

batch = client.submit_batch(
name="overnight-scrape",
items=urls,
callback_url="https://my.app/scrapingpros-webhook",
)

# Persist the IDs (your DB, kv store, whatever). DO NOT call iter_results().
db.batches.insert(batch.collection_id, batch.run_id, status="submitted")
# Process exits cleanly. The server will POST your webhook on completion.

The webhook payload includes run_id, status, counters, and the list of job_ids. It's signed with HMAC-SHA256 — verify the X-SP-Signature header against your shared secret before trusting it.

Inside your webhook handler, fetch the results:

@app.post("/scrapingpros-webhook")
def on_run_complete(payload):
verify_signature(payload, request.headers["X-SP-Signature"])
cid, rid = payload["collection_id"], payload["run_id"]
for r in client.iter_results(cid, rid): # SyncClient.iter_results since v0.5.2
save(r)

Use webhook fire-and-forget for: nightly batches, queue-driven pipelines, anything where keeping a process alive for 20+ minutes isn't viable. For interactive workflows where you want live progress, use the callback pattern below instead.

Callbacks — progress + completion handlers (with polling)

Register callbacks and call run_until_complete() instead of iterating manually:

batch = client.submit_batch("daily", items)

batch.on_result(lambda r: save_to_db(r) if r.guidance.success else log_error(r))
batch.on_progress(lambda b: print(f"{b.pct:.1f}% — {b.success_count}/{b.total}"))
batch.on_complete(lambda b: notify_slack(f"Done: {b.success_count}/{b.total}"))

batch.run_until_complete(timeout=7200)

Callbacks can be chained:

batch = (
client.submit_batch("daily", items)
.on_result(handle_result)
.on_progress(log_progress)
.on_complete(send_report)
)
batch.run_until_complete()

Handling failures visibly

iter_results() yields all terminal jobs by default — successful, failed, and timed out — so nothing is lost silently. result.guidance.success reflects the server's authoritative verdict (the same one used to compute run.success_requests):

for result in batch.iter_results():
g = result.guidance
if g and g.success:
save(result.content, my_id=result.custom_id)
elif g and g.stop_reason:
# retrying won't help (login wall, 404, etc.)
log_permanent_failure(result.url, reason=g.stop_reason)
elif g and g.suggested_request:
# retry with suggested params
retry_queue.add(g.suggested_request)
else:
log_unknown_failure(result.url, status=result.statusCode)

To skip failed jobs entirely (they still get counted):

for result in batch.iter_results(include_failed=False):
# only successful results reach here
save(result.content)

Async version

AsyncScrapingPros.submit_batch() returns an AsyncBatch with the same API:

from scrapingpros import AsyncScrapingPros

async with AsyncScrapingPros("your_token") as client:
batch = await client.submit_batch("daily", items)

async for result in batch.iter_results():
await save_async(result)

Callbacks can be sync or async — the SDK awaits them if they return a coroutine:

batch.on_result(async_save_to_db)  # async function, awaited automatically
batch.on_progress(print_progress) # sync function, called directly

Resuming after a client crash

Persist batch.collection_id and batch.run_id, then reattach later:

# Initial submit
batch = client.submit_batch("long-running", items)
save_to_db(col_id=batch.collection_id, run_id=batch.run_id)

# Later (after a restart)
batch = client.get_batch(col_id, run_id) # auto-refreshes counters since v0.5.2
for result in batch.iter_results():
if result.custom_id not in already_processed:
save(result)

Or use the client.iter_results(cid, rid) shortcut — same thing without the intermediate Batch handle:

for result in client.iter_results(saved_col_id, saved_run_id):
save(result)

Cross-process resume with a cursor (v0.7.5+)

If you want the reattach to skip already-processed jobs without dedup-by-custom_id in your DB, persist a cursor alongside the IDs and pass it back via since=:

# Persist cursor in your DB after each yielded result
for result in batch.iter_results():
save(result)
db.update(
cid=batch.collection_id,
rid=batch.run_id,
cursor=batch.last_completed_at, # max completed_at seen so far
)

# Different process / after a crash:
row = db.find(cid)
for result in client.iter_results(
row.cid, row.rid,
since=row.cursor, # resume strictly after this point
submitted_count=row.submitted, # for accurate batch.summary()
):
save(result)

since= accepts a datetime or an ISO 8601 string. Pair it with batch.last_completed_at (a read-only property on the Batch) — read it from inside or after the iteration loop and persist it to your storage. The SDK uses the server-side since_completed_at filter so the resumed run pages only new jobs from the wire, no dedup needed on your side.

Same-millisecond ties at the cursor boundary

The since= filter is strictly greater than (> since, not >= since). If you break out of iteration mid-batch and persist the cursor while several jobs share the same completed_at (common when workers finish a burst of fast scrapes in the same millisecond), the resume will skip the unyielded ties.

Two safe patterns:

  1. Persist on natural completion — only save the cursor when iter_results() exits naturally (run finished, all jobs yielded). Don't snapshot mid-iteration unless you can tolerate skipping ties.
  2. Dedup by custom_id in your DB — record processed jobs by custom_id; on resume let the SDK re-yield ties via since= minus a small slack (e.g. one second earlier) and skip the ones you've already saved. The wire cost of re-yielding a few ties is tiny.

For the low-level path, the same primitive lives as client.iter_run_jobs(cid, rid, since_completed_at=...) and client.get_run_jobs(cid, rid, since_completed_at=...) — see Collections (low-level) for the manual recipe.

Draining several batches in parallel (per-worker pattern)

If your worker holds multiple in-flight batches at once and wants to drain them concurrently while heredando the v0.7.3 (adaptive poll_interval) + v0.7.4 (counter short-circuit) optimisations, the canonical pattern is asyncio.gather over independent iter_results generators — each batch keeps its own cadence and short-circuit state automatically:

import asyncio

async def drain_one(batch, on_result):
async for r in batch.iter_results():
await on_result(r, batch)

async def drain_many(batches, on_result):
await asyncio.gather(
*(drain_one(b, on_result) for b in batches),
return_exceptions=True, # one batch failing doesn't kill the rest
)

# Inside your worker:
batches = await client.submit_batches_concurrent([...], concurrency=8)
await drain_many(batches, on_result=process)

Each batch's adaptive cadence and short-circuit logic apply independently — so 8 batches of 200 URLs each poll at the 10 s tier and skip the jobs-page query on idle ticks. There is no SDK-side coalescing of status requests across batches (each (collection_id, run_id) pair is a separate server endpoint), so per-tick wire cost is at most one status GET per active batch. Above the optimisations, the 6-line recipe above is the supported pattern — bake it into your worker and the SDK will do the rest.

Persist IDs before anything that can crash

submit_batch returns the IDs after the server has already created the collection and started the run. If your script crashes between that return and the line that persists the IDs (a print with a unicode error, an OOM, a SIGKILL), the batch keeps running server-side but your client has no reference to it. Use the on_submitted callback (v0.5.2+) to persist IDs inside the SDK call, before returning control:

import json
from pathlib import Path

def remember(collection_id, run_id):
Path("ids.json").write_text(json.dumps({
"collection_id": collection_id, "run_id": run_id,
}))

batch = client.submit_batch("daily", items, on_submitted=remember)
# Even if the next line crashes, the IDs are already on disk.

The callback fires twice: first with (collection_id, None) after the collection exists, then with (collection_id, run_id) after the run starts. On the async client (AsyncClient), on_submitted may be a coroutine.

Recovering an orphan after SubmitTimeout

When the API takes longer than submit_timeout (default 30 s) to respond, the SDK raises SubmitTimeout. The collection and/or run may have been created server-side even though the response didn't arrive.

Since v0.5.3 submit_batch() automatically generates a per-call Idempotency-Key UUID, so the simplest recovery is to just retry — the server dedupes within 24 h and replays the original response (no duplicate run, no double cost). Pass an explicit idempotency_key= if you'd rather control the key yourself.

from scrapingpros import SyncClient, SubmitTimeout

try:
batch = client.submit_batch(name, items, submit_timeout=30.0)
except SubmitTimeout:
# Safe to retry — the server's Idempotency-Key dedup means a
# second call with the same SDK-generated UUID returns the same
# collection_id without re-charging.
batch = client.submit_batch(name, items)

For belt-and-braces (older clients, custom retry policies, or when you want to verify before retrying), find_recent_batch looks up the orphan by name and reattaches to the live run — single round-trip via the server-side ?name=&since= filters since v0.5.3:

import uuid
from datetime import datetime, timezone

batch_name = f"daily-{uuid.uuid4().hex[:8]}" # unique name → unambiguous match
fired_at = datetime.now(timezone.utc)

try:
batch = client.submit_batch(batch_name, items)
except SubmitTimeout:
batch = client.find_recent_batch(name=batch_name, since=fired_at)
if batch is None:
batch = client.submit_batch(batch_name, items) # truly retry

find_recent_batch returns a Batch already attached to the live run if there is one (via list_runs(cid, status_filter="in_progress")), or to the most recent run otherwise.

Listing runs of a collection

client.list_runs(cid) enumerates every run that belongs to a collection, ordered by created_at desc. Useful for monitoring (collection-level dashboard) or for explicit reattach:

resp = client.list_runs(cid, status_filter="in_progress")
for run in resp.items:
print(run.run_id, run.status, run.success_requests, "/", run.total_requests)

Returns a RunListPublic with items: list[RunPublic] and total: int.

Typed 404s on get_job_result

Since v0.5.3 the SDK distinguishes the four reasons a job result can be missing instead of raising a generic APIError:

from scrapingpros import (
JobResultPending, JobResultExpired, JobResultLost, JobNotFound,
)

try:
result = client.get_job_result(cid, rid, jid)
except JobResultPending:
# Job hasn't reached a terminal state yet. Retry later.
schedule_retry(jid)
except JobResultExpired:
# Result TTL'd (> 24 h since completion). Re-run the URL.
requeue(jid)
except JobResultLost:
# Service incident lost the blob. Contact support — may qualify for refund.
escalate(jid)
except JobNotFound:
# job_id doesn't exist on this run. Programming bug; retrying won't help.
log_bug(jid)

All four inherit from JobResultError (which inherits from APIError), so existing except APIError clauses keep catching them.

Per-item validation buckets (v0.7.2+)

When you submit_batch, the API can reject individual items for three different reasons and still create the collection with the items that passed. All three buckets are exposed directly on the returned Batch:

  • batch.blocked_urls — SSRF rejections (private IPs, DNS failures, disallowed protocols/ports). list[BlockedURL] with categorised reason.
  • batch.invalid_items — Pydantic body-validation or parameter-rule failures (custom_id over 255 chars, screenshot without browser, etc.). list[InvalidItem] with field-level detail.
  • batch.duplicate_urls — URLs skipped as duplicates of an earlier entry in the same submit. list[str], one entry per skipped occurrence. The legacy count remains on batch.duplicates_skipped.

submit_batch emits a RuntimeWarning whenever any of these are non-empty — so silent data loss (a long custom_id quietly dropped on the floor) shows up during testing. submit_batch_lenient does not warn; it's the explicit opt-in for handling rejections in code:

batch = client.submit_batch("daily", items)   # RuntimeWarning if any rejects

for it in batch.invalid_items:
for err in it.errors:
log.warning("invalid item [%d] %s: %s (%s)", it.index, err.field, err.message, err.error_type)
for u in batch.duplicate_urls:
log.info("duplicate dropped: %s", u)
for b in batch.blocked_urls:
log.warning("blocked: %s (%s)", b.url, b.reason)

batch.total already accounts for every rejected bucket — len(payload) − blocked − invalid − duplicates — so pct, success_count / total, and eta_seconds are correct from the first tick.

Lenient submit — explicit opt-in for partial-success

When you expect some items to be rejected (a CSV with flaky URLs, user-supplied input you didn't pre-validate), submit_batch_lenient swallows the warning and gives you the SSRF rejections back as part of the return value:

batch, blocked = client.submit_batch_lenient(
"daily-with-flaky-urls",
items,
)
print(f"submitted {batch.total}, blocked {len(blocked)}, "
f"invalid {len(batch.invalid_items)}, dup {batch.duplicates_skipped}")

for b in blocked:
print(f" - {b.url} ({b.reason}): {b.message}")
for it in batch.invalid_items:
print(f" - [{it.index}] {it.url} field={it.errors[0].field} type={it.errors[0].error_type}")

blocked is a list[BlockedURL] with categorised reasons (private_ip, invalid_protocol, dns_failed, blocked_hostname, invalid_port, malformed_url, blocked). The other two buckets (invalid_items, duplicate_urls) live on the returned Batch — the tuple signature stayed (batch, blocked) to keep older code unchanged.

Available on both clients since v0.7.2:

# Sync
batch, blocked = client.submit_batch_lenient("daily", items)

# Async
async with AsyncClient(token) as client:
batch, blocked = await client.submit_batch_lenient("daily", items)

Tuning for very large batches

for result in batch.iter_results(
poll_interval=10.0, # override the adaptive default (see below)
timeout=7200, # raise TimeoutError after N seconds (default: no limit)
fetch_workers=10, # parallel result fetches per tick (default 5)
include_failed=True, # yield failed/timeout too (default True)
):
...

Adaptive poll_interval (v0.7.3+)

When you don't pass poll_interval=, the SDK picks one sized to the batch — short batches stay responsive, long batches stop saturating the rate limit. Each iter_results tick is heavy (status + jobs page + N parallel result fetches), so the cadence ramps up faster as the batch grows:

Items in queueDefault poll_interval
< 1005 s
100 – 49910 s
500 – 1,99915 s
≥ 2,00030 s

The chosen value is logged at INFO level the first time iteration starts ("Batch <id>: iterating <N> item(s) with poll_interval=15s (auto). Pass poll_interval= to override."). Pass an explicit poll_interval=N to bypass the adaptive selection.

For 10k–50k URL batches the auto-default lands at 30 s and that's usually right; consider raising fetch_workers=10 if your downstream processing is CPU-bound. run_and_wait uses the same idea with smaller numbers (status polling is one cheap GET per tick): <100→2s, <500→5s, <2000→10s, ≥2000→20s.

If you're running several batches in parallel, the savings compound — five 5,000-URL batches polling at 30 s instead of 5 s frees up roughly 3,000 requests per hour for actual scraping work.

Pin the success policy in tests

Each run exposes the classification policy the server used (is_success for each job, run.success_requests aggregate). Pin the version in integration tests so silent policy changes can't break your counts silently:

run = client.get_run(batch.collection_id, batch.run_id)
assert run.success_criterion.version == "content_success_v1"

The current policy classifies a job as success when status == "completed", 200 <= status_code < 300, potentiallyBlockedByCaptcha is false, and block_reason is null or "none". This catches soft-blocks (Google CAPTCHA pages, Amazon "Robot Check") that a naive status_code == 200 check would count as success.

Cleanup

Collections persist after the run completes (for 90 days by default). To delete early:

batch.delete()  # removes the collection from your account

Results already fetched are not affected.

When NOT to share an AsyncClient (v0.7.1+)

If your workflow does an extensive read/fetch phase before the submit phase — say, hundreds of GETs to build a per-domain config catalog and then 20+ parallel submit_batch calls — do not share one AsyncClient across both phases. The fix is one method call.

What goes wrong

httpx keeps connections per client in a pool capped by max_connections (default 200 in this SDK). A long prep phase fills the pool with connections that are kept alive for reuse. When the submit phase starts, its requests have to wait for a free slot. If the prep phase fanout was large enough, the submit requests sit in the queue for longer than the timeout and fail — even though the server is healthy and ready.

The failure mode is opaque: the SDK raises PoolExhausted (since v0.7.0), but earlier versions surfaced it as SubmitTimeout pointing at the API endpoint, sending users to chase a server-side issue that wasn't there.

The fix: submit_batches_concurrent

import asyncio
from scrapingpros import AsyncClient

async def main():
async with AsyncClient(TOKEN) as client:
# Long prep phase using the parent client — fills its pool with
# short-lived keepalive connections to the API.
configs = await client.batch_scrape(catalog_url_chunks)

# Submit phase: per-worker isolation, each worker has its own
# fresh AsyncClient with a clean pool.
batches = await client.submit_batches_concurrent(
[(f"daily-{i}", chunk) for i, chunk in enumerate(items_chunks)],
concurrency=15,
)

# Drain results. The returned handles are reattached to the
# parent client, so iter_results uses its pool (which has had
# time to drain by now).
for batch in batches:
async for r in batch.iter_results():
save(r)

asyncio.run(main())

submit_batches_concurrent accepts a list of (name, items) tuples or full dicts ({"name": ..., "items": [...], "callback_url": ...}). Returns a list[AsyncBatch] in input order. Each handle is reattached to the parent client, so subsequent iter_results calls work normally after the worker clients have closed.

Measured on a reproduction (2026-05-18): 25 batches × 500 items each (12,408 items total) submitted in 20 seconds with zero errors on a script that previously failed with a single shared client.

Alternative: open a fresh client just for the submit phase

If you want full control:

async with AsyncClient(TOKEN) as parent:
configs = await parent.batch_scrape(catalog_url_chunks)
# Open a NEW client right before submits — fresh pool, isolated.
async with AsyncClient(TOKEN) as submit_client:
await asyncio.gather(*[
submit_client.submit_batch(name, items) for name, items in chunks
])

This works but is more code. submit_batches_concurrent does the same thing in one call.

Sync counterpart

SyncClient.submit_batches_concurrent exists with the same signature, internally backed by a ThreadPoolExecutor with one fresh SyncClient per thread. Same per-worker pool isolation guarantee. For most production code, however, prefer the async variant — asyncio scales better for I/O-bound parallel work than threads.

FAQ: "Why two client classes? Why does the URL say /sync/?"

These come up enough in logs and error messages that it's worth one paragraph.

The SDK ships two Python client classes (SyncClient and AsyncClient) and the API exposes two endpoint families (/v1/sync/* and /v1/async/collections). They are independent axes:

  • The Python class determines the local I/O loop — blocking calls (SyncClient) vs await (AsyncClient). It has nothing to do with which API endpoint is hit.
  • The API endpoint family determines response delivery — inline (/v1/sync/scrape returns the result in the same call) vs queued (/v1/async/collections queues work and you poll / stream results).

Both Python clients call both endpoint families. AsyncClient.scrape(url) calls /v1/sync/scrape (one URL, response inline, awaitable). SyncClient.submit_batch(...) calls /v1/async/collections (Collections-backed, blocking polling). When you see /v1/sync/ in a log or error, the "sync" refers to response shape, not to which Python class made the call.

The recommendation: use AsyncClient for any production code (it integrates with asyncio, enables asyncio.gather of parallel submit_batch calls, and doesn't block when used inside an async app). SyncClient is convenience for REPL sessions, notebooks, and short one-off scripts — inside a running event loop it emits a RuntimeWarning since v0.7.0 because each call blocks the loop.

See also