Batch LLM Calls¶
Run thousands of chat-completion calls — classification, extraction, summarization — against a rate-limited LLM API. The problems are always the same: the provider throttles you (429s), individual calls fail randomly, calls are expensive enough that you never want to pay for one twice, and a dead API at 2 a.m. must not burn the quota retrying forever.
import openai
from pyarallel import parallel_map, Limiter, RateLimit, Retry
client = openai.OpenAI()
def classify(ticket):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Classify the support ticket: billing, bug, or feature."},
{"role": "user", "content": ticket},
],
)
return response.choices[0].message.content
# One Limiter per API key — every job that spends this quota shares it.
limiter = Limiter(RateLimit(450, "minute", burst=10))
result = parallel_map(
classify, tickets,
workers=10,
rate_limit=limiter,
retry=Retry.for_http( # 429 + Retry-After, prewired (v0.9)
attempts=4,
backoff=2.0,
on=(openai.RateLimitError, openai.APIConnectionError, openai.InternalServerError),
statuses={429, 500, 502, 503, 504},
),
checkpoint="classify.ckpt", # paid-for answers survive a crash
max_errors=20, # dead API → abort near 20 failures, not at 20,000
)
for idx, label in result.successes():
save(tickets[idx], label)
for idx, exc in result.failures():
log_failed(tickets[idx], exc)
Why each policy is there:
Retry.for_http()— when the provider saysRetry-After: 30(numeric or HTTP-date form), that wait replaces the backoff and pauses the shared limiter. One throttled call slows the whole pool; without it, all 10 workers discover the 429 separately and the retry storm makes it worse.statuses=widens the default{429, 503}to the 5xx codesopenai.InternalServerErrorrepresents;APIConnectionErrorcarries no status, so the type filter alone decides for it. (Verified against the realopenaiexception types.)checkpoint=— LLM calls cost real money. Completed answers are persisted as they finish; a crash at item 8,000 of 10,000 resumes with 2,000 calls, not 10,000. Inputs that evolve between runs? Key rows by identity withcheckpoint_key=lambda t: t.id. Prompt or model changes are invisible to code inspection — declare them:checkpoint_version=("classify-v3", MODEL)fails closed on change instead of stitching old-prompt answers to new ones.max_errors=20— the overnight-job guard. If the API dies, the run stops admitting work once the 20th post-retry failure lands — at most one admission window (default2 × workers) is still in flight — and returns partial results; the morning rerun resumes from the checkpoint. Shrinkwindow_sizeto tighten that bound on expensive calls, at some throughput cost.
The recipe is client-agnostic: swap the openai call for
LiteLLM (multi-provider routing), or raw
httpx — pyarallel only sees a function and its exceptions.