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.

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):
    return requests.get(url, timeout=10).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 parallel_map, RateLimit, Retry

result = parallel_map(
    fetch, urls,
    workers=10,
    rate_limit=RateLimit(100, "minute"),
    retry=Retry(attempts=3, on=(ConnectionError, TimeoutError)),
)

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 async_parallel_map, RateLimit, Retry

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

result = await async_parallel_map(
    fetch_async, urls,
    concurrency=10,
    rate_limit=RateLimit(100, "minute"),
    retry=Retry(attempts=3, on=(ConnectionError, TimeoutError)),
)
# Same result model — result.ok, result.successes(), result.failures()

Install

pip install pyarallel

What You Get

  • 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=
  • 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
  • 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
  • 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)

Quick Start

Sync

import requests
from pyarallel import parallel_map, RateLimit, Retry

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

# Fan out over a list, get ordered results
result = parallel_map(fetch, urls, workers=10)

# Rate-limited API calls with retry
def call_api(user_id):
    return requests.get(f"https://api.example.com/users/{user_id}").json()

result = parallel_map(
    call_api, user_ids,
    workers=10,
    rate_limit=RateLimit(100, "minute"),
    retry=Retry(attempts=3, backoff=1.0, on=(ConnectionError, TimeoutError)),
)

# CPU-bound with processes
from PIL import Image

def resize_image(path):
    img = Image.open(path)
    img.thumbnail((800, 600))
    img.save(path.replace(".png", "_thumb.png"))

result = parallel_map(resize_image, paths, executor="process")

Async

import httpx
from pyarallel import async_parallel_map

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

result = await async_parallel_map(
    fetch_async, urls, concurrency=20, task_timeout=5.0,
)

Decorator

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

from pyarallel import parallel, async_parallel, RateLimit

@parallel(workers=8, rate_limit=RateLimit(100, "minute"))
def fetch(url):
    return requests.get(url).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(concurrency=10)
async def fetch_async(url):
    async with httpx.AsyncClient() as c:
        return (await c.get(url)).json()

await fetch_async.map(urls)          # async parallel

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

Full docs — API reference, advanced features, best practices.

Deciding whether pyarallel fits? Comparison vs tenacity+ThreadPoolExecutor, aiometer, mpire, joblib — including when not to use it. Then the Cookbook: batch LLM calls, embeddings with resume, polite scraping, bulk GitHub/registry ops, NCBI fetches, secrets rotation.

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.9.0.tar.gz (481.1 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.9.0-py3-none-any.whl (56.9 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: pyarallel-0.9.0.tar.gz
  • Upload date:
  • Size: 481.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pyarallel-0.9.0.tar.gz
Algorithm Hash digest
SHA256 4175672ddf6d26bad645b2a0735c1d28dd5038f1604455997a33ee3d1cfe9ff1
MD5 394273ecf2510ca72a32e5f32f884581
BLAKE2b-256 9a1b52936c50c58a5970f300fa167eaaf943658f695fdd598c5d6a623d8942c3

See more details on using hashes here.

File details

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

File metadata

  • Download URL: pyarallel-0.9.0-py3-none-any.whl
  • Upload date:
  • Size: 56.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pyarallel-0.9.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8d56776cb495903edd3106ce5e1f418249f6547e707eaa70031fbaa63819a1e8
MD5 50f17755cb28945ad51f7837e7b48de9
BLAKE2b-256 53b0892c41fb60fec5cff5aeef45f999fd2d7207d94371548373950ded85bb44

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