Retrying Failed Items Without Duplicating Successful Work

Ma Mahalakshmi V Updated 16 Sep 2026
8 min read ·Lesson 132 of 224

Retrying Failed Items Without Duplicating Successful Work

Lesson 7 established a taxonomy for classifying failures and routing the retryable ones into a retry queue. This lesson addresses a problem that appears the moment you actually act on that queue: retrying is not automatically safe. If an item has side effects — writing a row to a database, sending a notification, appending to a report, incrementing a counter — retrying it naively risks doing that side effect twice, which is often worse than the original failure. Understanding why this happens, and how to prevent it, is the difference between a retry mechanism that repairs a job and one that quietly corrupts its output.

Why Retries Risk Duplication

The core problem is that a failure can occur after a side effect has already happened, but before your code learns that it succeeded. Consider this sequence for a single item:

  1. Your code calls the model. The model call succeeds.
  2. Your code writes the result to the database. The write itself succeeds on the server.
  3. The network connection drops before the "success" acknowledgment reaches your code.
  4. Your code sees a timeout, concludes the write failed, and marks the item for retry.
  5. On retry, your code writes the result to the database again.

From your program's point of view, step 3 and step 4 look exactly like a genuine write failure — there's no way to distinguish "the write never happened" from "the write happened but the confirmation was lost" using only the error you observed. This is not a hypothetical edge case; it's an inherent property of any operation performed over an unreliable network, and it becomes a near-certainty at the scale of a bulk job with thousands of items. The fix is not to make retries less frequent — that only reduces the odds without eliminating the problem — but to make the operation being retried safe to perform more than once.

Idempotency: The Property That Makes Retries Safe

An operation is idempotent if performing it multiple times has the same effect as performing it once. UPDATE users SET status = 'processed' WHERE id = 42 is idempotent — running it five times leaves the row in exactly the same state as running it once. INSERT INTO results (record_id, output) VALUES (42, '...') is not idempotent on its own — running it twice creates two rows, unless the table has a uniqueness constraint that prevents it.

The goal of a safe retry mechanism is to make every operation your pipeline performs idempotent with respect to a stable identifier — which is exactly why Lesson 2 insisted on a stable record_id carried through the whole pipeline from the start. Idempotency isn't a property you bolt on to the retry logic; it's a property you design into the operations the retry logic calls.

Making Database Writes Idempotent

The most common and most reliable way to make a write idempotent is an upsert: insert a new row if none exists for this identifier, or update the existing one if it does, using a database-level uniqueness constraint to guarantee this atomically regardless of how many times it runs.

import sqlite3


def upsert_result(conn: sqlite3.Connection, record_id: str, output: str) -> None:
    conn.execute(
        """
        INSERT INTO results (record_id, output)
        VALUES (?, ?)
        ON CONFLICT(record_id) DO UPDATE SET output = excluded.output
        """,
        (record_id, output),
    )
    conn.commit()

ON CONFLICT(record_id) DO UPDATE requires record_id to have a UNIQUE constraint in the table's schema — that constraint is what makes this operation genuinely safe to run any number of times: the first call inserts the row, and every subsequent call with the same record_id simply overwrites the same row with the same (or corrected) value, never creating a duplicate. Without the uniqueness constraint, ON CONFLICT has nothing to detect a conflict against, and the statement degrades back into a plain insert that duplicates on retry.

Making Non-Database Side Effects Idempotent: Idempotency Keys

Not every side effect is a database write you control the schema for. Sending an email, calling a third-party webhook, or triggering a downstream system are all operations where you often cannot rely on a uniqueness constraint at the destination. The general-purpose solution is an idempotency key: a unique value (typically the record_id, or a value derived from it) that you record locally before performing the side effect, and check before performing it again.

class IdempotencyTracker:
    """Tracks which operations have already been performed, keyed by a
    stable id, so retries can skip work that already happened."""

    def __init__(self, conn: sqlite3.Connection):
        self._conn = conn
        self._conn.execute(
            """
            CREATE TABLE IF NOT EXISTS completed_operations (
                idempotency_key TEXT PRIMARY KEY,
                completed_at TEXT DEFAULT CURRENT_TIMESTAMP
            )
            """
        )
        self._conn.commit()

    def already_done(self, key: str) -> bool:
        row = self._conn.execute(
            "SELECT 1 FROM completed_operations WHERE idempotency_key = ?", (key,)
        ).fetchone()
        return row is not None

    def mark_done(self, key: str) -> None:
        self._conn.execute(
            "INSERT OR IGNORE INTO completed_operations (idempotency_key) VALUES (?)",
            (key,),
        )
        self._conn.commit()


async def send_notification_idempotently(tracker: IdempotencyTracker, record_id: str):
    key = f"notify:{record_id}"
    if tracker.already_done(key):
        print(f"skipping {record_id}: notification already sent")
        return
    await actually_send_notification(record_id)
    tracker.mark_done(key)


async def actually_send_notification(record_id: str) -> None:
    print(f"sending notification for {record_id}")

This pattern works for genuinely any side effect, not just notifications, because it doesn't depend on the destination system supporting uniqueness constraints at all — the safety lives entirely in your own tracking table. The one requirement it does have is that mark_done must be called after the side effect definitely succeeded, and that the check-then-act sequence (already_done then perform the effect then mark_done) needs to be structured so that a crash between "effect performed" and "marked done" is the only failure window left — and that window is exactly the same one described at the start of this lesson, just narrowed as far as it can go. It cannot be eliminated entirely without a distributed transaction spanning both systems, which is rarely available in practice; the goal is to shrink the unsafe window, not claim perfect safety.

Idempotency at the Pipeline Level: Skipping Already-Succeeded Records

The same idea applies one level up, to the pipeline's own retry loop. Before retrying any record, check its already-recorded status — if a record's status is already SUCCEEDED, retrying it is pure waste (and a real risk, if any part of processing has non-idempotent side effects that weren't fully guarded).

def build_retry_batch(all_records: list, previous_results: dict[str, ItemResult]) -> list:
    """Return only the records that genuinely need to be retried:
    excludes anything already marked successful."""
    retry_batch = []
    for record in all_records:
        prior = previous_results.get(record.record_id)
        if prior is not None and prior.success:
            continue  # already done — never re-submit
        retry_batch.append(record)
    return retry_batch

This function is the safety net that makes the rest of the retry mechanism forgiving of mistakes: even if something upstream mistakenly includes an already-succeeded record in a retry list, build_retry_batch filters it back out before any request is made. Building this kind of defensive check at the boundary between "decide what to retry" and "actually retry it" is cheap insurance against a class of bugs that are otherwise very easy to introduce when retry logic evolves over time.

Retrying from the Correct Stage, Not from Scratch

Recall from Lesson 7 that a DOWNSTREAM_WRITE failure preserves the model's output on the ItemResult even though the item overall failed. A correct retry uses that preserved output to skip re-calling the model entirely:

async def retry_item(client, result: ItemResult, record) -> ItemResult:
    if result.output is not None:
        # The model call already succeeded last time; only the write failed.
        try:
            await write_result_downstream(result.record_id, result.output)
            return ItemResult(record_id=result.record_id, success=True, output=result.output)
        except Exception as exc:
            return ItemResult(
                record_id=result.record_id, success=False, output=result.output,
                failure=classify_exception(exc, FailureStage.DOWNSTREAM_WRITE),
            )
    # No prior output — the model call itself needs to be retried from scratch.
    return await process_item_with_failure_tracking(client, record)

Checking result.output is not None first is what turns Lesson 7's decision to preserve partial output into an actual cost and correctness saving: an item that only failed at the write stage is repaired with zero additional model calls, and an item that failed at the model call stage correctly starts over from that stage.

Testing Idempotency Logic

Idempotency logic is a natural fit for dependency-injected tests using an in-memory SQLite database and no network access:

def test_idempotency_tracker_prevents_duplicate_action():
    conn = sqlite3.connect(":memory:")
    tracker = IdempotencyTracker(conn)

    assert tracker.already_done("notify:1") is False
    tracker.mark_done("notify:1")
    assert tracker.already_done("notify:1") is True
    print("PASS: idempotency tracker correctly remembers completed operations")


def test_build_retry_batch_excludes_succeeded_records():
    records = [PipelineRecord(record_id=str(i), source_data={}) for i in range(3)]
    previous = {
        "0": ItemResult(record_id="0", success=True, output="done"),
        "1": ItemResult(record_id="1", success=False),
    }
    retry_batch = build_retry_batch(records, previous)
    ids = {r.record_id for r in retry_batch}
    assert ids == {"1", "2"}  # "0" already succeeded, "2" was never attempted
    print("PASS: retry batch excludes already-succeeded records")


test_idempotency_tracker_prevents_duplicate_action()
test_build_retry_batch_excludes_succeeded_records()

Common Mistakes

  • Assuming "the write failed" and "the write never happened" are the same thing. A timeout or dropped connection after a successful server-side write looks identical to your code as a genuine failure, which is exactly why idempotent writes (via upsert or an idempotency key) are necessary rather than optional.
  • Relying on application-level checks alone without a database-level uniqueness constraint. Checking "does this row already exist?" in application code before inserting has a race condition under concurrency — two workers can both check, both see nothing, and both insert. A UNIQUE constraint enforced by the database is what actually guarantees no duplicates, regardless of timing.
  • Re-running the entire item on any retry, including stages that already succeeded. This wastes cost on repeated model calls and, for any side effect not fully protected by idempotency tracking, increases the chance of a duplicate action.

Best Practices

  • Design every side-effecting operation to be idempotent with respect to a stable record_id before writing retry logic, using upserts for database writes and an idempotency key table for anything else.
  • Check whether a record already succeeded before including it in any retry batch, as a defensive filter independent of how the retry batch was constructed.
  • Preserve partial results across failed stages so a retry can resume from the point of actual failure instead of repeating already-successful, and potentially costly, work.

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 Retrying Failed Items Without Duplicating Successful Work and get answers drawn from it.

Signed-in readers only.