Objective
Asyncio.to_thread: concurrency without rewriting 6 functions as async
import asyncio
import time
async def run_all_generators(source_text: str, voice_profile: str) -> dict:
generators = {
"blog": generate_blog_post,
"twitter": generate_twitter_thread,
"linkedin": generate_linkedin_post,
"newsletter": generate_newsletter_section,
"youtube": generate_youtube_script_outline,
"email": generate_email,
}
start = time.time()
tasks = {
name: asyncio.to_thread(fn, source_text, voice_profile)
for name, fn in generators.items()
}
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
print(f"All 6 generators completed in {time.time() - start:.1f}s")
return dict(zip(tasks.keys(), results))
asyncio.to_thread runs Lesson 3's ordinary synchronous functions in a background thread, which is enough to get real concurrency for I/O-bound work like waiting on an API response -- you don't need to rewrite all 6 generators using async def and await just to parallelize them. This is a genuinely useful pattern anytime you have existing synchronous functions you want to run concurrently without a full async rewrite.
return_exceptions=True is the difference between "one fails" and "all fail"
Without it, asyncio.gather raises the first exception it sees and cancels everything else in flight -- one rate-limited generator call would silently discard 5 successful results you already paid for. With return_exceptions=True, a failed generator's slot in the results just contains the exception object instead of a string, and the other 5 complete normally. Lesson 5's output step needs to handle both cases in the same dict.
Aggregating results, including the failures
A silent partial failure is worse than a loud one
If 5 of 6 generators succeed and one raises a rate-limit error, do NOT quietly write "email: None" to Notion and move on -- log which generator failed and why, distinctly from a genuinely empty result. The whole point of building a pipeline instead of running one prompt at a time is trusting the aggregate output; that trust breaks the first time a silent failure goes unnoticed.
You run the orchestrator on 5 different real inputs over a week. Twice, the Twitter thread generator specifically fails with a rate-limit error while the other 5 succeed both times. What does this suggest, and what would you check first?
A pattern that repeats on the same generator specifically (not randomly across all 6) points to something structural about that generator, not bad luck -- in this case, the fast model you deliberately chose for cost/speed may have a different rate-limit ceiling on your account tier than the model used elsewhere. This is exactly the kind of thing worth checking with real usage over several runs rather than assuming from one failure.
What you're building
orchestrator.py: a run_all_generators function using asyncio.to_thread + asyncio.gather(..., return_exceptions=True) to run all 6 generators concurrently, timed and logged, with failed generators clearly distinguished from successful-but-empty ones in the returned results dict.
Unlock the full lesson
You've read the first 2 sections. The rest of this lesson covers The Problem, Build, Check Yourself, Key Terms & Next — plus a hands-on lab, quiz, and project artifact.
Create a free account — the opening phases of 29 of 35 courses are free, no credit card.