My Claude labeling job finished in 14 minutes, printed done, and exited with code 0. Every log line was green. Three days later a dashboard said I had 3,788 labeled documents. I had sent 4,000.
Nothing crashed. Nothing warned. Python's ThreadPoolExecutor swallowed exceptions from 212 API calls and kept them in objects I never looked at. This is the autopsy, the two wrong fixes I tried first, and the 15 lines that ended it.
TL;DR
-
ThreadPoolExecutor.submit()stores any exception inside the returnedFuture. If you never call.result(), the exception is never raised and never logged. - The
with ThreadPoolExecutor()block waits for all tasks, then exits cleanly even if every task failed. -
executor.map()is not the fix: it raises the first error and cancels every task that hasn't started yet. Mine killed 2,906 documents. - The fix: keep a
{future: input}dict, loop withas_completed(), call.result()insidetry/except, retry failures at lower concurrency, and exit non-zero if anything is still missing. - Always reconcile counts.
saved == sentwould have caught this on night one.
What did the broken code look like?
It was the kind of code you write in five minutes and trust forever:
from concurrent.futures import ThreadPoolExecutor
def label_one(doc):
resp = client.messages.create(
model=MODEL,
max_tokens=300,
messages=[{"role": "user", "content": PROMPT.format(text=doc.text)}],
)
data = json.loads(resp.content[0].text)
db.save(doc.id, data["label"])
with ThreadPoolExecutor(max_workers=8) as pool:
for doc in docs:
pool.submit(label_one, doc)
print("done")
Eight threads, 4,000 short documents, one label each. The worker writes its own result to the database, so the main thread has nothing to collect. That's the trap. Because I didn't need the return value, I threw away the Future, and the Future was the only thing holding the error.
Why does ThreadPoolExecutor swallow exceptions?
ThreadPoolExecutor swallows exceptions because a worker's exception is captured and stored on its Future object, and it only re-raises when someone calls future.result() or future.exception(). No caller, no raise. There's no "exception was never retrieved" warning when the Future is garbage collected.
That last part surprised me most. asyncio does warn you: an unawaited failed task prints Task exception was never retrieved when it gets collected. concurrent.futures has no such safety net. The exception sits on an object with zero references and quietly disappears.
The with block makes it feel safer than it is. Leaving the block calls shutdown(wait=True), which waits for every task to finish. It does not check how they finished. Waiting looks like supervision. It isn't.
What were the 212 errors?
I added error capture (more on how below), replayed the same 4,000 documents against a fresh table, and grouped what came back:
| Failure | Count | Cause |
|---|---|---|
| 429 rate limit | 171 | Burst exceeded my tokens-per-minute limit, survived the SDK's 2 default retries |
| 529 overloaded | 29 | Transient server overload, also out of retries |
KeyError: 'label' |
12 | Model returned {"category": ...} instead of {"label": ...}
|
| Total | 212 | 5.3% of the run |
The 429s were my fault. Eight threads start at the same moment, all hit the limit at the same moment, and the SDK's backoff retries them at roughly the same moment. Two retries weren't enough to spread them out. The Anthropic Python client defaults to max_retries=2, which is reasonable for one call and thin for a burst of eight.
The 12 KeyErrors were the scariest. Rate limits are noisy in the API console. A parser bug on a model's slightly different JSON shows up nowhere except your own missing rows.
Is executor.map safer than submit?
executor.map() is louder than submit() but not safer: it re-raises the first exception when your loop reaches that item, and then cancels every task that hasn't started yet. You trade silent data loss for loud data loss.
That was my first "fix":
with ThreadPoolExecutor(max_workers=8) as pool:
for _ in pool.map(label_one, docs):
pass
The next run crashed at document 517 with a RateLimitError traceback. Great, I thought, now I see errors. Then I counted rows. map() submits everything upfront, yields results in input order, and when the iterator dies it cancels all pending futures in a finally block. 2,906 documents were cancelled before they ever started. One rate limit took out 73% of the job.
My second "fix" was wrapping label_one in a giant try/except that logged and returned. That works, technically, but it moves error handling into every worker function I'll ever write and gives the main thread no idea how many things failed. The job still exited 0. I'd just made the silence more verbose.
How do I catch exceptions from ThreadPoolExecutor properly?
Map each future back to its input, iterate with as_completed(), call .result() inside try/except, and collect failures in the main thread. Then retry the failures and exit with a non-zero code if any remain.
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
def run(docs, workers):
failures = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(label_one, d): d for d in docs}
for fut in as_completed(futures):
doc = futures[fut]
try:
fut.result()
except Exception as e:
failures.append(doc)
log.warning("label failed doc=%s err=%s", doc.id, type(e).__name__)
return failures
failed = run(docs, workers=8)
if failed:
log.info("retrying %d docs at low concurrency", len(failed))
failed = run(failed, workers=2)
saved = db.count_labeled(batch_id)
if failed or saved != len(docs):
sys.exit(f"{len(failed)} failed, saved {saved}/{len(docs)}")
print("done")
Four things are doing the work here:
-
The dict.
futures[fut]tells you which document failed. A bare list of futures gives you a traceback with no ID. -
as_completed(). Errors surface as they happen, and one failure doesn't cancel anything else. - The retry pass at 2 workers. Most 429s and 529s are about burst shape, not volume. Fewer threads on the second pass means fewer collisions.
-
The count check. Even if my error handling has a bug,
saved != len(docs)catches it. This is the line I wish I'd written on day one.
I also bumped the client to Anthropic(max_retries=5) and replaced the free-text JSON with a forced tool schema so the model can't rename label to category. Those fixed the causes. The loop above fixes the blindness, which matters more, because next month's cause will be something I haven't thought of.
What happened after the fix?
The next full run of 4,000 documents:
-
First pass, 8 workers: 3,959 succeeded, 41 failed (all 429 or 529, zero
KeyErrors after the schema change) - Retry pass, 2 workers: 41 of 41 succeeded
- Count check: 4,000 of 4,000 saved
- Wall time: 14 minutes to 16 minutes
Two extra minutes for a job that tells the truth. The failure rate in the first pass dropped from 5.3% to 1%, mostly from the extra SDK retries. But the number I care about is the one that went from "unknown" to zero: rows lost without a log line.
What should a batch job check before printing "done"?
A batch job should verify that the number of outputs it wrote equals the number of inputs it was given, and exit non-zero if not. Exit code 0 is a claim. Make the code earn it.
My checklist for any threaded API job now:
- Never call
pool.submit()without keeping the returnedFuture. - Never use
pool.map()on calls that can fail transiently. - Log the input ID with every exception, not just the traceback.
- Retry failures in a separate pass with lower concurrency.
- Reconcile counts against the source of truth, not against your own success counter.
- Make cron or CI treat a non-zero exit as an alert.
The original bug ran for nine nights before I noticed. Nobody ignored an alert. There was no alert to ignore.
So why did ThreadPoolExecutor swallow my Claude API errors?
Python's ThreadPoolExecutor swallowed 212 of my Claude API errors because submit() stores each worker's exception on the returned Future, and I never kept or checked those futures. Without a call to future.result(), the exception is never raised and never logged, and the with block exits cleanly once all threads finish, so the script printed done with exit code 0. Switching to executor.map() made errors visible but cancelled every unstarted task after the first failure. The reliable pattern is a {future: input} dict, an as_completed() loop with .result() inside try/except, a low-concurrency retry pass, and a final saved == sent count check that fails the job loudly when the numbers don't match.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)