Background Workers for Long-Running AI Tasks

Ma Mahalakshmi V Updated 19 Sep 2026
8 min read

Why Some Work Cannot Happen Inside a Request

A web request has an implicit expectation: the client is waiting, and it will time out after some number of seconds (often 30, sometimes less behind a load balancer or API gateway). Many OpenAI SDK workloads do not fit inside that window — summarizing a hundred-page document, running a multi-step agent loop with several tool calls, transcribing a long audio file and then analyzing the transcript, or generating a large batch of images. If work like this runs directly inside an HTTP request handler, either the client's connection times out while the work is still in progress, or you are forced to hold a connection open far longer than is healthy for a web server designed to handle many short-lived requests concurrently.

A background worker is a separate process (or pool of processes) whose only job is to execute tasks handed to it, independent of any web request's lifetime. The web server's job becomes: accept the request, record that the work needs to happen, and immediately respond — typically with a job identifier the client can use to check on progress later. The actual OpenAI API calls happen in the worker process, on its own schedule, unconstrained by any request timeout.

This lesson is about the worker itself — what it is, why it is architected as a separate process, and how to build the simplest version of one. Lesson 7 builds on this by covering the queue that sits between the request handler and the worker in more depth: delivery guarantees, retries, and job status tracking as a first-class concern. It is worth distinguishing this material from Unit 21's batch-processing pipelines as well: Unit 21 covered patterns for processing many similar items together efficiently, such as OpenAI's dedicated Batch API for large offline jobs. This lesson is about general-purpose worker infrastructure for arbitrary long-running tasks submitted one at a time by user-facing requests — the underlying execution model, not the batch-specific cost and scheduling optimizations Unit 21 focused on.

The Simplest Possible Worker

Before reaching for a production task queue library, it helps to understand the core idea with the smallest implementation that demonstrates it: a loop that continuously checks for pending work and executes it.

import time
import uuid
import threading
from dataclasses import dataclass, field
from enum import Enum


class JobStatus(str, Enum):
    PENDING = "pending"
    RUNNING = "running"
    DONE = "done"
    FAILED = "failed"


@dataclass
class Job:
    id: str
    prompt: str
    status: JobStatus = JobStatus.PENDING
    result: str | None = None
    error: str | None = None


class JobStore:
    """In-memory job store, used here for teaching. Production systems use
    a durable store (a database or a queue's own storage) so jobs survive
    a process restart — see Lesson 7."""

    def __init__(self) -> None:
        self._jobs: dict[str, Job] = {}
        self._lock = threading.Lock()

    def create(self, prompt: str) -> Job:
        job = Job(id=str(uuid.uuid4()), prompt=prompt)
        with self._lock:
            self._jobs[job.id] = job
        return job

    def get(self, job_id: str) -> Job | None:
        with self._lock:
            return self._jobs.get(job_id)

    def next_pending(self) -> Job | None:
        with self._lock:
            for job in self._jobs.values():
                if job.status == JobStatus.PENDING:
                    job.status = JobStatus.RUNNING
                    return job
        return None

JobStore tracks jobs by an id and their current JobStatus. The _lock (a threading.Lock) matters because both the web server thread (creating jobs) and the worker thread (claiming and updating jobs) touch the same dictionary concurrently — without the lock, two threads could interleave their reads and writes and either lose a job or claim the same job twice. next_pending atomically finds a pending job and marks it RUNNING in the same locked section specifically to prevent two worker threads from both picking up the same job — a fresh pending job must be claimed exactly once.

The Worker Loop

def run_worker(client, store: JobStore, model: str, poll_interval: float = 1.0) -> None:
    """Continuously poll for pending jobs and execute them.
    In production this runs as its own process, separate from the web server."""
    while True:
        job = store.next_pending()
        if job is None:
            time.sleep(poll_interval)
            continue

        try:
            response = client.responses.create(model=model, input=job.prompt)
            job.result = response.output_text
            job.status = JobStatus.DONE
        except Exception as exc:
            job.error = str(exc)
            job.status = JobStatus.FAILED

This loop is intentionally simple, and every part of it maps to a real production concern. store.next_pending() returning None when there is no work means the worker sleeps for poll_interval seconds rather than spinning in a tight loop burning CPU for no reason — this is polling, the simplest way a worker can discover new work, at the cost of up to poll_interval seconds of latency between a job being created and a worker noticing it. Lesson 7 discusses queue systems that can push work to a worker instead of requiring it to poll, trading some implementation complexity for lower latency and less wasted CPU.

The try/except around the actual client.responses.create call is not incidental — it is the reason a worker architecture is more resilient than inline request handling in the first place. If this call raises (a timeout, a rate limit, a malformed prompt), the exception is caught, the job is marked FAILED with the error recorded, and — critically — the worker loop itself keeps running to process the next job. Without this try/except, one failing job would crash the entire worker process, taking down every other job queued behind it.

Running It End to End

class FakeResponse:
    def __init__(self, text: str) -> None:
        self.output_text = text


class FakeClient:
    class _Responses:
        def create(self, model: str, input: str) -> FakeResponse:
            return FakeResponse(f"Summary of: {input[:20]}")

    @property
    def responses(self) -> "FakeClient._Responses":
        return FakeClient._Responses()


def test_worker_processes_a_pending_job() -> None:
    store = JobStore()
    client = FakeClient()
    job = store.create("A very long document that needs summarizing.")

    # Run one iteration of the worker's core logic directly, rather than
    # starting the infinite loop, so the test terminates deterministically.
    pending = store.next_pending()
    assert pending is not None and pending.id == job.id

    response = client.responses.create(model="gpt-5.6-terra", input=pending.prompt)
    pending.result = response.output_text
    pending.status = JobStatus.DONE

    finished = store.get(job.id)
    assert finished is not None
    assert finished.status == JobStatus.DONE
    assert finished.result is not None and finished.result.startswith("Summary of:")
    print("PASS: worker processes a pending job end to end")


test_worker_processes_a_pending_job()

This test does not call run_worker directly, because run_worker contains an infinite while True loop that would never return. Instead, it exercises the same logic — claim a pending job, call the client, record the result — as a single, deterministic sequence of steps. This is a common and useful pattern when testing loop-based worker code: extract or replicate the loop's body into something testable, rather than trying to run the loop itself under test with an artificial exit condition.

Why the Worker Is a Separate Process

In a real deployment, run_worker does not share a process with your web server. It runs as its own container (built from the same image described in Lesson 3, but started with a different CMD — for example, CMD ["python", "worker.py"] instead of CMD ["uvicorn", ...]) or as a separately deployed service. This separation matters for a few concrete reasons: the web server can be scaled independently of the number of worker processes (Lesson 8 covers horizontal scaling in depth); a worker crash does not take down request handling for users who are not waiting on background jobs; and worker processes can be given different resource limits (more memory, longer allowed runtimes) appropriate to long-running AI tasks, without over-provisioning every web server replica the same way.

Note: Production systems rarely hand-roll a polling loop and an in-memory dict as shown here. Mature task-queue libraries — Celery, RQ, and Arq are common choices in the Python ecosystem — provide durable job storage (typically backed by Redis or a message broker), automatic retries, scheduled and periodic jobs, and worker pools out of the box. The teaching implementation in this lesson exists to make the underlying mechanism explicit before you adopt one of those libraries; the concepts (claim a job, execute it, record the outcome, keep the loop alive across individual failures) are the same either way.

Common Mistakes

Letting an unhandled exception in job processing crash the worker loop. Without a try/except around the actual task execution, one bad input can take down the worker entirely, silently stalling every job queued behind it until someone notices and restarts the process.

Running background work inside the request-handling thread or process instead of a genuinely separate one. This defeats the purpose — a slow job still blocks the web server's ability to handle other requests promptly, especially in a single-threaded or limited-worker-pool web server configuration.

Polling too aggressively. A poll_interval of zero or a few milliseconds turns the worker into a tight loop that consumes CPU and, if it is also checking a shared datastore, adds unnecessary load there for no meaningful latency benefit over a more reasonable interval.

Best Practices

Isolate the failure of one job from every other job with a try/except around task execution, so the worker loop's resilience does not depend on every individual task being bug-free.

Deploy workers as a separately scalable process from the web server, using the same container image but a different startup command, so the two can be resourced and scaled according to their very different workload characteristics.

Start with the simplest mechanism that works, and adopt a mature task-queue library once you need its guarantees — durable storage across restarts, automatic retries, and visibility into queue depth are exactly the concerns Lesson 7 covers next.

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.out 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 Mem 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
Ask about this post
AI Ask about this post

Ask questions about Background Workers for Long-Running AI Tasks and get answers drawn from it.

Signed-in readers only.