Safe Concurrent Requests

Ma Mahalakshmi V Updated 19 Sep 2026
8 min read ·Lesson 138 of 224

Running Concurrent Requests Safely

Unit 12, Lesson 6 showed the basic pattern of firing off multiple requests with asyncio.gather and bounding them with a single asyncio.Semaphore. That pattern works for a demonstration of a few dozen calls. It starts to break down once you're processing tens of thousands of records, need partial results as they complete rather than all at once, need to cancel cleanly on a fatal error, or need to control memory usage when the full list of records doesn't comfortably fit as pending tasks in memory at once. This lesson covers the concurrency patterns that make bulk request processing robust rather than merely functional.

gather vs. as_completed: Two Different Needs

asyncio.gather(*coroutines) runs everything concurrently and returns a list of results only once every single one has finished, in the same order they were passed in. This is the right tool when you need all results together before continuing, and when the input list is small enough to hold entirely in memory as scheduled tasks.

asyncio.as_completed(coroutines) also runs everything concurrently, but instead gives you an iterator that yields each result as soon as it finishes, in completion order rather than input order. This matters for large batches: you can start acting on results (saving them, updating progress) immediately rather than waiting for the single slowest item to finish before processing anything.

import asyncio


async def fetch_with_delay(n: int) -> int:
    await asyncio.sleep(0.1 * (5 - n))  # later items finish sooner
    return n


async def demo_as_completed():
    coros = [fetch_with_delay(n) for n in range(5)]
    for coro in asyncio.as_completed(coros):
        result = await coro
        print(f"finished: {result}")  # prints in completion order, not 0,1,2,3,4


asyncio.run(demo_as_completed())

For a pipeline's Collect stage (Lesson 2), as_completed is usually the better fit, because it lets you write each result to durable storage the moment it's ready — which directly supports the progress tracking in Lesson 6 and the resumability design in Lesson 9, both of which depend on results being persisted incrementally rather than all at the end.

Bounding Concurrency with a Worker Pool Pattern

A single semaphore wrapped around individual coroutines (as in Unit 12) works, but at high volumes a producer/consumer worker pool built on asyncio.Queue gives you more control: a fixed number of worker coroutines pull items from a shared queue and process them, so memory usage stays bounded by the queue size rather than by the number of tasks you've created up front, and you can add or remove workers without changing how records are enqueued.

import asyncio
from openai import AsyncOpenAI


async def worker(name: str, queue: asyncio.Queue, client: AsyncOpenAI, results: list):
    while True:
        record = await queue.get()
        if record is None:  # sentinel value signals "no more work"
            queue.task_done()
            break
        try:
            response = await client.responses.create(
                model="gpt-5.6-terra",
                input=record.prompt,
            )
            record.model_response = response.output_text
            record.status = RecordStatus.SUCCEEDED
        except Exception as exc:
            record.error = str(exc)
            record.status = RecordStatus.FAILED
        results.append(record)
        queue.task_done()


async def run_worker_pool(records: list, concurrency: int) -> list:
    client = AsyncOpenAI()
    queue: asyncio.Queue = asyncio.Queue()
    results: list = []

    for record in records:
        queue.put_nowait(record)

    workers = [
        asyncio.create_task(worker(f"worker-{i}", queue, client, results))
        for i in range(concurrency)
    ]

    await queue.join()  # wait until every enqueued item has been processed

    for _ in workers:
        queue.put_nowait(None)  # tell each worker to stop
    await asyncio.gather(*workers)

    return results

This pattern has several properties worth calling out explicitly. The number of worker coroutines (concurrency) directly and predictably bounds how many requests are in flight at once — there is no way for the system to accidentally schedule more concurrent calls than that, unlike a naive gather over thousands of coroutines where every one of them starts immediately unless separately throttled. The None sentinel is a standard idiom for telling a fixed pool of consumers to stop: each worker exits its loop the first time it dequeues a None, so you enqueue exactly as many None values as there are workers. queue.join() blocks until every item put on the queue has had task_done() called for it, which is how the pool knows all real work items (not just the sentinels) have been processed before it starts shutting workers down.

Isolating Failures So One Bad Item Doesn't Take Down the Rest

A concurrency bug that is easy to introduce and expensive to have in production: letting one coroutine's unhandled exception propagate up through gather and cancel every other in-flight task. By default, asyncio.gather without return_exceptions=True does exactly this — the moment one coroutine raises, gather raises too, and any tasks still running are left in a cancelled state, silently discarding whatever work they had done.

async def risky(n: int) -> int:
    if n == 3:
        raise ValueError("simulated failure")
    await asyncio.sleep(0.05)
    return n


async def demo_failure_isolation():
    coros = [risky(n) for n in range(5)]

    # Without return_exceptions=True, one failure cancels the whole batch.
    results = await asyncio.gather(*coros, return_exceptions=True)
    for n, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"item {n} failed: {result}")
        else:
            print(f"item {n} succeeded: {result}")


asyncio.run(demo_failure_isolation())

return_exceptions=True changes gather's behavior so that a failing coroutine's exception is captured and placed in the results list at its corresponding position instead of being re-raised — every other coroutine keeps running to completion regardless. The worker pool pattern above achieves the same isolation more directly, since each worker already catches exceptions per item inside its own try/except and never lets one item's failure affect the loop that processes the next one. Either approach is valid; the worker pool additionally isolates failures within a single long-running coroutine (the worker itself never crashes), while gather(..., return_exceptions=True) isolates failures across a fixed, one-shot list of coroutines. Lesson 7 goes further into what to actually do with these captured failures at the pipeline level.

Structured Concurrency with TaskGroup

Python 3.11 introduced asyncio.TaskGroup, which addresses a subtler problem with plain gather: if you cancel or the program shuts down while tasks are running, gather gives you no built-in guarantee that every child task is cleanly awaited or cancelled before your code continues. TaskGroup is a context manager that guarantees all tasks created within it are complete (or cancelled together) before the async with block exits, which makes cleanup and cancellation behavior predictable:

async def demo_task_group():
    results = []

    async def process(n: int):
        await asyncio.sleep(0.05)
        results.append(n * n)

    async with asyncio.TaskGroup() as tg:
        for n in range(5):
            tg.create_task(process(n))
    # By this point, every task has finished (or the group raised together).

    print(sorted(results))

Unlike gather, if any task inside a TaskGroup raises an unhandled exception, the group cancels every other task in the group and raises a single ExceptionGroup containing all the failures once everything has actually stopped — there is no window where some tasks are left dangling. For new code targeting Python 3.11 or later, TaskGroup is generally the more robust default over gather for exactly this reason, though gather(..., return_exceptions=True) remains the more portable choice for anything running on 3.10 or earlier.

Note: asyncio.TaskGroup requires Python 3.11+. Confirm the Python version your production environment targets before relying on it, and check current documentation for its exact exception-grouping behavior.

Testing Concurrency Logic Without Real Calls

Because the worker pool and failure-isolation logic above are pure asyncio control flow, they can be tested with a fake client that never touches the network:

class FakeAsyncClient:
    class responses:
        @staticmethod
        async def create(model, input):
            class FakeResponse:
                output_text = f"fake response to: {input}"
            if "fail" in input:
                raise RuntimeError("simulated API error")
            return FakeResponse()


async def test_worker_pool_isolates_failures():
    records = [
        PipelineRecord(record_id=str(i), source_data={}, prompt=f"item {i}")
        for i in range(4)
    ]
    records[2].prompt = "please fail this one"

    queue: asyncio.Queue = asyncio.Queue()
    for r in records:
        queue.put_nowait(r)
    results: list = []
    workers = [
        asyncio.create_task(worker(f"w{i}", queue, FakeAsyncClient(), results))
        for i in range(2)
    ]
    await queue.join()
    for _ in workers:
        queue.put_nowait(None)
    await asyncio.gather(*workers)

    succeeded = [r for r in results if r.status == RecordStatus.SUCCEEDED]
    failed = [r for r in results if r.status == RecordStatus.FAILED]
    assert len(succeeded) == 3
    assert len(failed) == 1
    print("PASS: worker pool isolates one failure without affecting others")


asyncio.run(test_worker_pool_isolates_failures())

FakeAsyncClient mimics only the shape the worker function needs — a responses.create coroutine — which is enough to exercise the real control flow (queueing, dispatching to workers, catching exceptions, recording status) without any dependency on the actual OpenAI service, cost, or network reliability.

Common Mistakes

  • Creating unbounded concurrency by calling gather over every record at once with no limit. Ten thousand simultaneous coroutines each opening a connection will exhaust file descriptors, overwhelm the API's rate limits (Lesson 5), and consume far more memory than a bounded worker pool.
  • Letting one failing coroutine cancel the entire batch by omitting return_exceptions=True on gather, or by not catching exceptions inside a long-running worker loop — either mistake can silently discard completed-but-uncollected work.
  • Forgetting to send the correct number of sentinel values when shutting down a worker pool, which leaves some workers permanently blocked waiting on an empty queue and the program never exits.

Best Practices

  • Prefer a bounded worker pool or a semaphore-limited gather over unbounded concurrency for any batch of more than a few dozen items, and choose the pool when you also need incremental result handling or when the record list is very large.
  • Always decide explicitly how failures propagate — either isolate them per item (inside a worker's try/except) or capture them explicitly with return_exceptions=True — rather than leaving Python's default propagation behavior to determine it implicitly.
  • Reach for TaskGroup on Python 3.11+ for new code where clean, guaranteed cancellation semantics matter, and reserve gather for simpler, smaller-scale cases or environments still on older Python versions.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Safe Concurrent Requests and get answers drawn from it.

Signed-in readers only.