Skip to main content

The fan-out layer for rate-limited APIs — parallel map with rate limiting, retry, and structured errors. Sync and async, zero dependencies.

Project description

Pyarallel

PyPI version PyPI Downloads

The fan-out layer for rate-limited APIs — one function over many inputs, with rate limiting, retry, resume, and structured errors. Sync and async.

Fanning out over a service that throttles you — LLM calls, embeddings, scraping, any SaaS API — means the same hand-rolled stack every time: a semaphore, tenacity, a token bucket, ad-hoc 429 handling, and a "TODO: resume" you never get to. Pyarallel is that stack, already built and tested. Not DAGs, not queues, not a distributed system — just concurrent.futures and asyncio with the policies and result handling already built in.

Zero dependencies. Python 3.12+ — free-threaded 3.13t/3.14t tested, sub-interpreter executor on 3.14.

pip install pyarallel

Before / After

Fetch 10,000 URLs with rate limiting and error handling.

concurrent.futures:

import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch(url):
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

urls = ["https://api.example.com/users/1", "https://api.example.com/users/2", ...]

results = [None] * len(urls)
errors = []

with ThreadPoolExecutor(max_workers=10) as pool:
    futures = {pool.submit(fetch, url): i for i, url in enumerate(urls)}
    for f in as_completed(futures):
        i = futures[f]
        try:
            results[i] = f.result()
        except Exception as e:
            errors.append((i, e))

# No rate limiting. No retry. No resume. And you still
# need to wire those yourself every time.

pyarallel:

from pyarallel import Limiter, RateLimit, Retry, parallel_map

limiter = Limiter(RateLimit(100, "minute"))

result = parallel_map(
    fetch, urls,
    workers=10,
    rate_limit=limiter,
    retry=Retry.for_http(on=(requests.HTTPError,), attempts=4),
    checkpoint="fetch.ckpt",
    checkpoint_version="fetch-v1",
    max_errors=20,
)

for idx, val in result.successes():
    save(val)
for idx, exc in result.failures():
    log_error(idx, exc)

Same thing, async:

import httpx
from pyarallel import Limiter, RateLimit, Retry, async_parallel_map

limiter = Limiter(RateLimit(100, "minute"))

async with httpx.AsyncClient(timeout=10) as client:
    async def fetch_async(url):
        response = await client.get(url)
        response.raise_for_status()
        return response.json()

    result = await async_parallel_map(
        fetch_async, urls,
        concurrency=10,
        rate_limit=limiter,
        retry=Retry.for_http(on=(httpx.HTTPStatusError,)),
        checkpoint="fetch.ckpt",
        checkpoint_version="fetch-v1",
        max_errors=20,
    )
# Same result model — result.ok, result.successes(), result.failures()

What You Get

Respect the service

  • Rate limiting — token bucket with burst, per-second/minute/hour: rate_limit=RateLimit(100, "minute", burst=20)
  • Shared quota — one Limiter instance across calls and functions when the budget belongs to an API key: rate_limit=Limiter(RateLimit(100, "minute"))
  • Retry with backoff — per-item, exponential, jitter, exception filtering: retry=Retry(attempts=3, on=(ConnectionError,))
  • Server-driven backoffretry=Retry.for_http(on=(httpx.HTTPStatusError,)): 429/503 + Retry-After (numeric and HTTP-date form) prewired, no client import; the wait also pauses the shared limiter so one throttled task slows the whole pool. Custom policies via retry_if=/wait_from=

Finish expensive jobs

  • Checkpoint/resumecheckpoint="run.ckpt": a crash at item 40,000 resumes instead of restarting from zero; checkpoint_key=lambda u: u.id keys rows by identity so evolving inputs keep completed work
  • Early abortmax_errors=10: a dead API costs tens of calls, not thousands; unrun items are marked Aborted, partial results returned
  • Cooperative stopstop=StopToken(): SIGTERM/notebook-stop lands the plane — admission ceases, checkpoint rows kept, RunStatus.CANCELLED reported

Control execution and outcomes

  • One windowed engine — every API (collected and streaming, sync and async) admits work through a bounded in-flight window: lazy input, generators never materialized, no batch barriers, a straggler never stalls the items behind it
  • Streamingparallel_iter / async_parallel_iter: ordered=True for input-order yields, per-item attempts/duration
  • Async sources — async cursors and paginated generators feed async_parallel_* directly, with backpressure to the producer — no draining into a list first
  • Structured errorsParallelResult with .ok, .ok_values(), .successes(), .failures(), .raise_on_failure(), plus .status (RunStatus) for how the run ended; a truncated run is never .ok and won't hand out a partial list as if it were complete
  • Timeouts — total wall-clock on sync and async (timeout=30.0), per-task in async (task_timeout=5.0)
  • Debug modesequential=True runs inline: no pool, real stack traces, working breakpoints
  • Progress callbackson_progress=lambda done, total: print(f"{done}/{total}") on collected and streaming APIs
  • Live result callbackson_result= receives each ItemResult on the driver thread as collected-map work completes, including failures, retry metadata, and checkpoint hits
  • Application identitiesitem_key=lambda customer: customer.id adds ItemResult.key to successes, failures, callbacks, collected receipts, and streams without changing index-based ordering; checkpoint_key= supplies it automatically on checkpointed runs

Choose the runtime and interface

  • Process executor — CPU-bound work: executor="process", with worker_init= and max_tasks_per_worker=
  • Interpreter executor (Python 3.14+) — executor="interpreter": true CPU parallelism for pure-Python work without process overhead (PEP 734)
  • Contextvars propagation — correlation IDs survive into thread workers
  • Decorator API@parallel / @async_parallel with .map(), .starmap(), .stream() — typed options via Unpack[TypedDict], and single-arg named functions bind their item type (fetch.map([1]) is a type error when fetch takes str — in mypy and pyright)

Other API Shapes

Decorator

Adds .map(), .starmap(), .stream() without changing the function:

from pyarallel import RateLimit, parallel

@parallel(workers=8, rate_limit=RateLimit(100, "minute"))
def fetch(url):
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

fetch("http://example.com")          # normal call — returns dict
fetch.map(urls)                      # parallel — returns ParallelResult
fetch.stream(urls, window_size=500)   # streaming — yields ItemResult

@async_parallel mirrors the same interface for async def functions.

Streaming — Constant Memory

For ETL, pipelines, or datasets too large to hold in memory:

from pyarallel import parallel_iter

def transform(row):
    return {"id": row["id"], "name": row["name"].strip().title()}

for item in parallel_iter(transform, ten_million_rows, window_size=1000):
    if item.ok:
        db.save(item.value)
    else:
        log_error(item.index, item.error)

Error Handling

All errors collected, never silently swallowed:

def send_email(msg):
    return smtp.send(msg["to"], msg["subject"], msg["body"])

result = parallel_map(send_email, messages)

if result.ok:
    values = result.values()           # list of all results, in order
else:
    for idx, exc in result.failures():
        log_error(idx, exc)
    result.raise_on_failure()          # or raise ExceptionGroup with all errors

API Summary

Function Decorator Returns Use case
parallel_map(fn, items) .map(items) ParallelResult Results fit in memory
parallel_starmap(fn, items) .starmap(items) ParallelResult Multi-arg, fits in memory
parallel_iter(fn, items) .stream(items) Iterator[ItemResult] Streaming, constant memory

Async mirrors: async_parallel_map, async_parallel_starmap, async_parallel_iter

Config Example
RateLimit(count, per, burst) RateLimit(100, "minute", burst=20)
Limiter(rate_limit) shared budget: Limiter(RateLimit(100, "minute"))
Retry(attempts, backoff, on, retry_if, wait_from) Retry(attempts=3, on=(ConnectionError,))
Retry.for_http(on, statuses) HTTP prewired: Retry.for_http(on=(httpx.HTTPStatusError,))
checkpoint= resumable runs: checkpoint="run.ckpt"

Works with instance methods and static methods via @parallel decorator — see full docs.

See It Prove Itself

Every resilience claim — 429 handling, the pool-wide pause, rate-limit pacing, kill-and-resume — verified locally in ~10 seconds, no credentials, no dependencies, one file:

python examples/resilience_demo.py

A fake quota-enforcing API comes up on localhost; a full-speed pool draws a 429 and you watch one Retry-After pause the whole pool (the server measures the gap); the same job with a client-side RateLimit never gets throttled at all; then a checkpointed run is SIGKILLed mid-flight and rerun — it resumes from SQLite, and the server's request counter proves the paid-for calls were not repeated. The demo asserts its own claims and exits non-zero if any fail.

Documentation

  • Quick Start — one safe API job, then the policies you are likely to change
  • Which API Should I Use? — collected vs streaming, sync vs async, functions vs decorators
  • Production API Job — runnable template with shared client, retry, resume, graceful stop, and explicit status handling
  • Cookbook — LLM calls, embeddings, scraping, bulk operations, ETL, and CPU fan-out
  • Testing and Troubleshooting — deterministic tests and symptom-first diagnosis
  • Comparison — alternatives and when not to use pyarallel

License

MIT — see LICENSE.md.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyarallel-0.11.0.tar.gz (375.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyarallel-0.11.0-py3-none-any.whl (63.6 kB view details)

Uploaded Python 3

File details

Details for the file pyarallel-0.11.0.tar.gz.

File metadata

  • Download URL: pyarallel-0.11.0.tar.gz
  • Upload date:
  • Size: 375.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for pyarallel-0.11.0.tar.gz
Algorithm Hash digest
SHA256 a9617a72f20bd1ad0592975c92bae4e17eb3b5da13f3d506a318cfaee8c5736f
MD5 599925b5a2b0951a839d9e3c5d0f99a1
BLAKE2b-256 27d7c43a1e2a96c129ca55dcbc90a4572193d1e8652916c742c34cff2b758666

See more details on using hashes here.

File details

Details for the file pyarallel-0.11.0-py3-none-any.whl.

File metadata

  • Download URL: pyarallel-0.11.0-py3-none-any.whl
  • Upload date:
  • Size: 63.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for pyarallel-0.11.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e6ff002fb02bea962e23c3935c79b09a391872bff9bf24c14cbe3cb53373b467
MD5 c667f3f9327c2129c5b78ee734c0a23d
BLAKE2b-256 842f008f0496548bb9bef1075cd1bcbc03b2b84ce3e59bc1872cc4f4d6c0ab34

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page