Handle Failed Batch Items

Su Suriya Ravichandran Updated 10 Nov 2026
8 min read ·Lesson 138 of 200

Failure Is Normal at Batch Scale

A batch of ten thousand requests will not succeed completely on the first attempt, and it should not be expected to. Some requests are malformed, some hit temporary service problems, some are canceled because the job itself was stopped, and some expire because the service was busy. Lesson 137 classified each outcome into one of five statuses: ok, needs_review, fix_request, retry, and not_run. This lesson decides what to do with the three statuses that lead to another attempt, and designs the workflow that carries work through repeated rounds until it is finished or deliberately abandoned.

The guiding idea is that a failed item is not a crisis. It is a record with a status, a count of attempts, and a next action. Treating failures as data makes them manageable.


Two Causes, Two Responses

The most important distinction is between failures caused by the request and failures caused by the environment:

CauseExamplesResponse
The request is wrongInvalid parameter, unsupported option, malformed message, a value the model cannot acceptChange the request, then resubmit. The same request will fail again.
The environment was temporaryA server error, a canceled batch, an expired batchResubmit the same request, because it may succeed

Resubmitting an invalid request unchanged wastes money only if it runs, and the batch's invalid-request errors appear at the end of processing, so the time cost is the real problem. Fixing the request first avoids repeating the same failure for hours.


Keeping Attempt Counts

Retries must be bounded. A request that fails for a reason no retry can solve should not circulate forever. The registry from Lesson 133 is the right place for an attempt counter, because it survives crashes and is visible to every process:

ATTEMPTS_DDL = """
ALTER TABLE batch_items ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0
"""

Each time a request is placed in a batch, its attempts value increases by one. A request that reaches the limit moves to a dead-letter state: it is kept with its last error, and it waits for a person, rather than being submitted again automatically.

The limit should match the kind of failure. Temporary problems justify a few attempts, because the next round may find the service healthy. Invalid requests justify fewer automatic attempts, because a retry without a fix cannot succeed.


Building the Retry Plan

A retry round is a batch built from the registry: every request whose latest status calls for another attempt, and which has not reached its attempt limit. Building it from the registry, rather than from the previous batch's file, means the plan always reflects the current state, even after several rounds:

from dataclasses import dataclass, field


@dataclass
class RetryPlan:
    to_resubmit: list[str] = field(default_factory=list)    # custom_ids for the next batch
    needs_fix: list[str] = field(default_factory=list)      # invalid requests, for a person or a repair step
    exhausted: list[str] = field(default_factory=list)      # reached the attempt limit


def plan_retries(rows: list[dict], max_attempts: int = 3) -> RetryPlan:
    plan = RetryPlan()
    for row in rows:
        if row["status"] == "fix_request":
            plan.needs_fix.append(row["custom_id"])
        elif row["status"] in ("retry", "not_run"):
            if row["attempts"] >= max_attempts:
                plan.exhausted.append(row["custom_id"])
            else:
                plan.to_resubmit.append(row["custom_id"])
    return plan

The plan separates the three destinations clearly. Items in to_resubmit go into the next batch unchanged, items in needs_fix are held back until their requests are repaired, and items in exhausted are reported. Nothing is silently dropped.


Repairing Invalid Requests

Some invalid requests have a mechanical fix. A common example is a max_tokens value above what the model accepts, which can be reduced to the limit. Repairs should be explicit, narrow, and logged, because a repair changes what the model is asked to do. The repair function below handles only one documented case, and returns None for everything else, so unknown problems still reach a person:

MODEL_MAX_OUTPUT = {"claude-sonnet-5-5": 128_000}


def repair(request: dict, error_message: str) -> dict | None:
    """Return a corrected request, or None if this repair does not apply."""
    params = request["params"]
    if "max_tokens" in error_message and "too large" in error_message:
        limit = MODEL_MAX_OUTPUT.get(params["model"])
        if limit and params["max_tokens"] > limit:
            fixed = {**request, "params": {**params, "max_tokens": limit}}
            return fixed
    return None

Notice that the repair builds a new dictionary instead of changing the original. The original request remains available in the registry, so you can always see what was sent and what was changed. A repaired request should also be recorded with its change, because a reviewer may want to know that a limit was lowered.


Testing a Repaired Request Synchronously

Batch validation is slow, so a repaired request should be checked before it joins a large batch. The synchronous Messages API returns a result immediately, which means one test call confirms the shape:

def check_with_sync_api(client, request: dict) -> bool:
    try:
        client.messages.create(**request["params"])
        return True
    except Exception:
        return False

A single successful test call does not prove the whole batch will succeed, but it removes the most common cause of repeated failure. The batch can then be submitted with confidence.


Batch-Level Failures

Sometimes the whole batch fails rather than individual items. The most common cause is size: a batch over the documented 256 MB limit is rejected, and the documentation notes that this can appear as a 413 request_too_large error. The response is not a retry but a split, using the builder from Lesson 132, which keeps each batch under the safe limits. Batch-level cancellation is the other case, handled by the status checks from Lesson 134: a canceled batch's unprocessed items return as canceled, and they flow into the same retry plan as any other temporary failure.


Practical Example: Three Rounds of Retries

The example below simulates a job across three rounds. The first round has a temporary failure, an invalid request that can be repaired, and an invalid request that cannot. The second and third rounds retry the temporary failure. The final check runs the planner once more, after the attempts have been counted, to show the attempt limit taking effect. The demonstration uses in-memory rows, so it runs offline.

"""retry_rounds.py: plan retries from registry rows, repair what can be repaired, and stop at a limit."""

from dataclasses import dataclass, field

MODEL_MAX_OUTPUT = {"claude-sonnet-5-5": 128_000}


@dataclass
class RetryPlan:
    to_resubmit: list[str] = field(default_factory=list)
    needs_fix: list[str] = field(default_factory=list)
    exhausted: list[str] = field(default_factory=list)


def plan_retries(rows: list[dict], max_attempts: int = 3) -> RetryPlan:
    plan = RetryPlan()
    for row in rows:
        if row["status"] == "fix_request":
            plan.needs_fix.append(row["custom_id"])
        elif row["status"] in ("retry", "not_run"):
            if row["attempts"] >= max_attempts:
                plan.exhausted.append(row["custom_id"])
            else:
                plan.to_resubmit.append(row["custom_id"])
    return plan


def repair(request: dict, error_message: str) -> dict | None:
    params = request["params"]
    if "max_tokens" in error_message and "too large" in error_message:
        limit = MODEL_MAX_OUTPUT.get(params["model"])
        if limit and params["max_tokens"] > limit:
            return {**request, "params": {**params, "max_tokens": limit}}
    return None


def new_round(rows: list[dict], requests: dict[str, dict], errors: dict[str, str]):
    plan = plan_retries(rows)
    repaired = []
    for custom_id in plan.needs_fix:
        fixed = repair(requests[custom_id], errors[custom_id])
        if fixed:
            requests[custom_id] = fixed
            repaired.append(custom_id)
    for row in rows:
        if row["custom_id"] in plan.to_resubmit + repaired:
            row["attempts"] += 1
    return plan, repaired


if __name__ == "__main__":
    requests = {
        "review-1": {"custom_id": "review-1", "params": {"model": "claude-sonnet-5-5", "max_tokens": 256}},
        "review-2": {"custom_id": "review-2", "params": {"model": "claude-sonnet-5-5", "max_tokens": 200_000}},
        "review-3": {"custom_id": "review-3", "params": {"model": "claude-sonnet-5-5", "max_tokens": 256}},
    }
    errors = {"review-2": "max_tokens is too large", "review-3": "unsupported option: top_k"}
    rows = [
        {"custom_id": "review-1", "status": "retry", "attempts": 0},          # temporary failure
        {"custom_id": "review-2", "status": "fix_request", "attempts": 0},    # repairable
        {"custom_id": "review-3", "status": "fix_request", "attempts": 0},    # not repairable
    ]

    plan, repaired = new_round(rows, requests, errors)
    print("round 1 resubmit:", plan.to_resubmit, "| repaired:", repaired, "| needs fix:", plan.needs_fix)
    print("round 1 repaired max_tokens:", requests["review-2"]["params"]["max_tokens"])

    for _ in range(2):                                # Rounds 2 and 3: review-1 fails again
        new_round(rows, requests, errors)

    final = plan_retries(rows)
    print("after three rounds, attempts:", rows[0]["attempts"], "| exhausted:", final.exhausted)

Output:

round 1 resubmit: ['review-1'] | repaired: ['review-2'] | needs fix: ['review-2', 'review-3']
round 1 repaired max_tokens: 128000
after three rounds, attempts: 3 | exhausted: ['review-1']

How It Works

  1. Round one builds the plan from the rows. review-1 is a temporary failure, so it is queued for resubmission. Both fix_request rows land in needs_fix, because the plan is made before any repair happens.
  2. The repair applies only to review-2. Its max_tokens is above the model's limit, so it is lowered to 128,000. The request is replaced in the request map, and the error record keeps the original message.
  3. review-3 cannot be repaired. Its error names an unsupported option, which the repair function does not recognize, so it stays in needs_fix for a person.
  4. Attempts increase only for items that are actually resubmitted or repaired. review-3 is not counted, because it has not been sent.
  5. Rounds two and three each count one attempt for review-1, reaching three in total. The final planning pass runs after those increments, so review-1 has reached the limit and appears in exhausted. The order matters: a plan made before the last increment would still show it as eligible.

Note: the demonstration uses a single attempt counter for both temporary failures and repairs. In a production system, repairs and retries may deserve separate limits, because a repair changes the request while a retry does not.


The Full Lifecycle

Putting the pieces together gives one loop per round: read the registry, plan the retries, repair what is repairable, check repaired requests synchronously, build the next batch, submit it, wait for it (Lesson 134), download its results (Lesson 136), process them (Lesson 137), and write the new statuses back to the registry. Each round starts from the registry rather than from memory, so an interruption at any point loses nothing: the next run picks up exactly where the registry says the work stands.

The loop ends when every item is ok, or when every remaining item is needs_fix or exhausted. At that point the job is finished in the only sense that matters: every item has either a result or a recorded reason for not having one.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Handle Failed Batch Items and get answers drawn from it.

Signed-in readers only.