pygolem
Spawn tiny little servants, running things in parallel.
Before
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_all(fn, items, workers=10, max_retries=3, rate_limit=5):
values = [None] * len(items)
errors = {}
remaining = list(enumerate(items))
timestamps = []
lock = threading.Lock()
def rate_limited(item):
while True:
with lock:
now = time.monotonic()
timestamps[:] = [t for t in timestamps if now - t < 1]
if len(timestamps) < rate_limit:
timestamps.append(now)
break
time.sleep(0.05)
return fn(item)
for attempt in range(max_retries + 1):
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(rate_limited, item): i for i, item in remaining}
remaining = []
for future in as_completed(futures):
index = futures[future]
try:
values[index] = future.result()
except Exception as e:
remaining.append((index, items[index]))
errors[index] = e
if not remaining or attempt == max_retries:
break
time.sleep(2 ** attempt)
return values, errors
results, errors = fetch_all(fetch_url, urls)
Roughly 40 lines, and this version doesn't even validate its own inputs, guard
against the bool-is-int trap, or cap thread count safely at scale.
After
from pygolem import threads
results = threads.map_parallel(fetch_url, urls, workers=10, retries=3, rate_limit=("token", 5))
results.values # successful outputs, in original input order
results.errors # {index: {"type": ..., "message": ...}} for items that failed permanently
Four lines. Same results. Ordered output, isolated per-item failures, retries with backoff, and a global rate limit.
What it is
pygolem wraps concurrent.futures.ThreadPoolExecutor with the things a
real parallel workload eventually needs:
- Ordered results:
.valuesalways matches your input order, even though threads finish in whatever order they finish in. - Per-item error isolation: one failing item never crashes the batch.
- Retries with backoff, plus
retry_ifto skip retrying failures you already know are permanent. - Global rate limiting: cap total throughput across the entire pool, not per-worker, with a choice of token bucket, leaky bucket, or sliding window.
Dependency-free, just the standard library.
Installation
pip install pygolem
Quick example
from pygolem import threads
import requests
def fetch(url):
return requests.get(url, timeout=5).status_code
urls = [
"https://example.com",
"https://httpbin.org/status/500", # will fail, then retry
"https://github.com",
]
result = threads.map_parallel(
fetch, urls,
workers=3,
retries=2,
backoff=("exponential", 0.5),
rate_limit=("token", 5),
)
print(result.values) # [200, 500, 200]
print(result.errors) # {1: {"type": "ConnectionError", "message": "..."}}
API
threads.map_parallel(fn, items, workers=None, retries=None, retry_if=None, backoff=("linear", 1), rate_limit=None)
| Parameter | Type | Default | Description |
|---|---|---|---|
fn |
callable | — | Function to call on each item. |
items |
list / tuple | — | Inputs to process. Must be non-empty. |
workers |
int | min(32, len(items)) |
Max concurrent threads. |
retries |
int | 0 |
Retry attempts per item after a failure. |
retry_if |
callable | None |
callable(exception) -> bool — return False to stop retrying that item. |
backoff |
(mode, delay) |
("linear", 1) |
mode is "linear" or "exponential". |
rate_limit |
(algorithm, value) |
None |
algorithm is "token", "leaky", or "sliding"; value is calls/sec across all workers. |
Returns a Data object: .values (results in input order, None for
permanent failures) and .errors ({index: {"type": str, "message": str}}).
Known limitations
- A stuck
fnhangs the whole call. There's no timeout yet, and Python can't force-kill a thread, so one item that never returns meansmap_parallelnever returns either. - Retries can repeat side effects. If
fndoes something real (like a request) and then fails afterward, retrying does that thing again. Useretry_ifto skip retrying failures you know already went through. - One slow retry delays everyone. The backoff wait happens between rounds, so already-successful items still sit and wait for it.
- More workers won't speed up CPU-heavy code. Threads share one GIL, extra workers mainly help waiting on network/disk, not crunching numbers.
Benchmarks
I/O-bound workload (time.sleep(0.01) per call), measured with
time.perf_counter(). Full breakdown in tests/02_speed.
| n | sequential | pygolem | speedup |
|---|---|---|---|
| 100 | 1.014s | 0.064s | 15.8x |
| 1000 | 10.260s | 0.525s | 19.5x |
Overhead over a raw ThreadPoolExecutor stays under 1% at scale. The
retry/error/ordering machinery is close to free.
The workers default is capped at 32 for a reason: an earlier version that
defaulted to workers=len(items) crashed at 100,000 items
(RuntimeError: can't start new thread). The capped default handles the
same load in 33s without crashing.
Tested
125 pytest tests, 0 failures — input validation, ordering, error isolation, retries, all three rate-limit algorithms, and a dedicated suite that proves out the known limitations. Full breakdown in tests/TEST_REPORTS.md.
pytest tests/ -v --tb=short
Roadmap
- Token-bucket and leaky-bucket rate limiting (alongside sliding-window)
- Conditional retries via
retry_if -
pygolem.aio— asyncio engine, with item-level backoff -
pygolem.processes— multiprocessing engine, with real cancellation of hung workers - Per-item timeout support
- Optional progress callback
About the name
Inspired by Minecraft's copper golem. A small automaton that potters around doing small repetitive tasks on its own, tirelessly, until it needs a little maintenance. Not a bad description of a background worker.
License
Apache License 2.0. see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pygolem-0.1.0.tar.gz.
File metadata
- Download URL: pygolem-0.1.0.tar.gz
- Upload date:
- Size: 22.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fdb17bb809c95242023a1538c26053a2e8adc938ccf919e3f86d3135b65f20c8
|
|
| MD5 |
5a68926f3d0e90d7fe8e2a6012b5b42f
|
|
| BLAKE2b-256 |
18c10b74b44b45dbd8addd229ca25266e5c871d83f160838a435192212606163
|
File details
Details for the file pygolem-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pygolem-0.1.0-py3-none-any.whl
- Upload date:
- Size: 12.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0313e70bc161c10b52f570b55f873d3f02a84643c56c0e210cf6ed1635516f0d
|
|
| MD5 |
17257a91fa241e8a67405e341d316416
|
|
| BLAKE2b-256 |
58376b7dc353d1ebd30f5ff7ff969ed5667d185a4626a15fe5e8f2d872419e08
|