Retry Failed Requests

Su Suriya Ravichandran Updated 10 Nov 2026
9 min read ·Lesson 143 of 200

A Retry Is a Second Chance, Not a Guarantee

Lesson 141 showed that errors fall into groups: some call for a fix, and some call for waiting. Retrying is the response to the second group. When a request fails for a reason that may pass, sending it again often succeeds, and the caller's work is saved without anyone noticing.

Retrying also has costs and risks. Each attempt takes time and may be billed. A retry can multiply load on a service that is already struggling. And for some operations, repeating a request is not the same as doing it once. This lesson covers the decision of whether to retry, the difference between the SDK's built-in retries and a retry layer of your own, and the idempotency questions that decide whether a retry is safe.


Which Errors Are Worth Retrying

The SDK's own policy, described in the documentation, retries these cases by default: connection errors, timeouts, 408 (request timeout), 409 (conflict), 429 (rate limit), and any status of 500 and above. Anything else, such as 400, 401, 403, 404, and 422, is not retried, because waiting does not change those outcomes.

The SDK also honors a special response header, x-should-retry. When the server sets it to true or false, that value overrides the default rule. A server that knows a request will never succeed can say so, and a server that expects recovery can invite a retry. A handler that writes its own policy should check this header first, since it carries the server's direct instruction.

The policy above is a starting point, and the decision still depends on the operation. Retrying a read is harmless. Retrying a write may not be, as the idempotency section below explains.


The SDK's Built-In Retries

The client retries automatically, with no code of your own. The documentation and the SDK source give these defaults:

SettingDefaultMeaning
max_retries2Up to two retries, so three attempts in total
Initial delay0.5 secondsThe first wait before a retry
Maximum delay8 secondsThe longest single wait between attempts
Read timeout10 minutesHow long to wait for a response (Lesson 144)

The delay grows with each attempt, and includes randomness so that many clients do not retry in lockstep. Lesson 146 explains the pattern in detail. When the server sends a retry-after value, the SDK uses that value rather than its own calculation, within limits.

Configuration can be set once for the whole client, or per request:

from anthropic import Anthropic

client = Anthropic(max_retries=5)                  # More patience for every call
client = Anthropic(max_retries=0)                  # Disable the built-in retries entirely
response = client.with_options(max_retries=4).messages.create(...)   # One call only

The built-in retries cover many transient problems with no code. Their limit is that they are tuned for a single request. A long-running job may need a policy that considers the whole job, and that is the reason for building a retry layer of your own.


Stacking Retries

The SDK retries inside each call, and an application may retry around each call as well. The two multiply. If the SDK makes three attempts and the application makes four calls, each of which the SDK retries, the total number of HTTP requests can reach twelve for a single unit of work. That is rarely what anyone intended, and it can turn a brief outage into a flood of traffic.

The usual solution is to choose one layer. Either the SDK retries, with its default or a configured count, and the application makes one call, or the SDK retries are disabled and the application owns the whole policy. The second option is what the rest of this section uses, because it allows one place to count attempts, log them, and decide when to stop.

The arithmetic is simple enough to check in code:

def worst_case_requests(sdk_retries: int, app_attempts: int) -> int:
    return (sdk_retries + 1) * app_attempts


print(worst_case_requests(sdk_retries=2, app_attempts=4))   # Both layers active
print(worst_case_requests(sdk_retries=0, app_attempts=4))   # One layer only

Output:

12
4

Idempotency: The Question Behind Every Retry

An operation is idempotent if doing it twice has the same effect as doing it once. Reading a batch's status is idempotent, since it changes nothing. A message request is more subtle, and the subtlety matters.

Consider a request that the server processes successfully, but whose response is lost on the way back. The caller sees a timeout and retries. The server processes the second request too. The caller now has two answers, and has been billed for both. For a question-and-answer call, this is wasteful but harmless. For an operation with side effects, it can be a serious problem.

This is why the answer to "is it safe to retry?" depends on what the request does:

OperationRetry safetyReason
Reading a batch, a file, or a listSafeNothing changes on the server
A message request with no side effectsUsually safeThe cost of a duplicate is money, not data
A request whose answer triggers an actionNot safe without a guardA duplicate action may run twice
A tool that books, sends, or deletesNot safe without a guardThe side effect repeats (Lesson 177)
Creating a batchNot safe without a guardA duplicate batch processes the same work twice

A guard can be as simple as recording the intent before acting, and checking the record before retrying. A tool that books a session should record the booking key first, so a repeated attempt finds the existing booking instead of creating another.


Designing an Application-Level Retry Layer

With the SDK's retries disabled, the application's policy has four parts: which errors to retry, how many attempts to make, how long to wait between them, and what to record. The decision function below covers the first part. It follows the SDK's policy and adds one rule the SDK cannot know: the caller's own limit on attempts.

"""retry_policy.py: an application-level retry layer around SDK calls."""

import time

from anthropic import APIConnectionError, APIError, APIStatusError

RETRYABLE_STATUS = {408, 409, 429}


def should_retry(error: Exception) -> bool:
    if isinstance(error, APIConnectionError):           # Includes timeouts
        return True
    if isinstance(error, APIStatusError):
        return error.status_code in RETRYABLE_STATUS or error.status_code >= 500
    return False


def call_with_retries(fn, log: list[str], max_attempts: int = 4,
                      base: float = 0.5, cap: float = 8.0, sleep=time.sleep):
    for attempt in range(1, max_attempts + 1):
        try:
            return fn()
        except APIError as error:
            name = type(error).__name__
            if not should_retry(error) or attempt == max_attempts:
                log.append(f"attempt {attempt}: {name} -> stop")
                raise
            delay = min(cap, base * 2 ** (attempt - 1))
            log.append(f"attempt {attempt}: {name} -> wait {delay:g}s")
            sleep(delay)

How It Works

  1. should_retry() implements the SDK's rule: connection problems and timeouts, the three retryable status codes, and every status of 500 and above. A 400 or 401 returns False immediately.
  2. call_with_retries() counts attempts, and stops when the policy says no or when the attempt limit is reached. The last attempt re-raises, so the caller sees the real error instead of a silent failure.
  3. The wait doubles each time, capped at eight seconds, which matches the SDK's own range. Lesson 146 explains why the doubling is useful.
  4. The log records every decision, so a person can see afterward why a call took as long as it did, or why it gave up.
  5. sleep is a parameter, so tests can record the waits instead of performing them.

Practical Example: Three Retry Scenarios

The example below runs the retry layer against three scripted services: one that recovers after two transient failures, one that fails with a permanent error, and one that never recovers. Each run prints its decision log, so the behavior is visible without any network calls.

"""retry_scenarios.py: the retry layer against scripted services."""

from api_errors import fake_status_error
from retry_policy import call_with_retries


def scripted(outcomes):
    """Return a function that yields each scripted outcome in turn."""
    queue = list(outcomes)

    def call():
        outcome = queue.pop(0)
        if isinstance(outcome, Exception):
            raise outcome
        return outcome
    return call


def run(label, outcomes):
    log: list[str] = []
    waits: list[float] = []
    try:
        result = call_with_retries(scripted(outcomes), log, sleep=waits.append)
        log.append(f"result: {result}")
    except Exception as error:
        log.append(f"raised: {type(error).__name__}")
    print(f"{label}:")
    for line in log:
        print("  " + line)


if __name__ == "__main__":
    run("transient, then recovers", [
        fake_status_error(529, "overloaded_error", "overloaded", "req_a"),
        fake_status_error(429, "rate_limit_error", "rate limit", "req_b"),
        "ok",
    ])
    run("permanent error", [
        fake_status_error(400, "invalid_request_error", "bad input", "req_c"),
    ])
    run("never recovers", [
        fake_status_error(503, "api_error", "unavailable", "req_d"),
        fake_status_error(503, "api_error", "unavailable", "req_e"),
        fake_status_error(503, "api_error", "unavailable", "req_f"),
        fake_status_error(503, "api_error", "unavailable", "req_g"),
    ])

Output:

transient, then recovers:
  attempt 1: InternalServerError -> wait 0.5s
  attempt 2: RateLimitError -> wait 1s
  result: ok
permanent error:
  attempt 1: BadRequestError -> stop
  raised: BadRequestError
never recovers:
  attempt 1: InternalServerError -> wait 0.5s
  attempt 2: InternalServerError -> wait 1s
  attempt 3: InternalServerError -> wait 2s
  attempt 4: InternalServerError -> stop
  raised: InternalServerError

How It Works

  1. The first scenario recovers. The overloaded 529 is treated as a server error, so it is retried after half a second. The rate limit is retried after a second, and the third attempt succeeds. Nothing in the calling code needed to know about either failure.
  2. The second scenario stops immediately. A 400 means the request itself is wrong, and waiting would not help, so the layer raises on the first attempt.
  3. The third scenario exhausts its budget. Four attempts are allowed, and the waits double each time: half a second, one second, and two seconds. The fourth failure is raised rather than swallowed, so the caller can record it as a failure.
  4. The logs explain every outcome, which is the difference between a retry layer that people trust and one they must debug blindly.
  5. Waits are recorded, not performed. The waits list is the value passed as sleep, so the same tests run in milliseconds while still checking the timing.

Retrying Within a Batch of Work

A retry layer around one call protects that call. A job that makes thousands of calls needs the same policy applied to each one, plus a limit on the total effort. Without that limit, a service outage can make every call retry in full, and the job spends hours on attempts that were always going to fail. Lesson 96 introduced budgets for tool calls, and the same idea applies here: a total number of retries for the job, reported when it is used up, so the job stops and reports rather than continuing to spend.


Summary

Retry the errors that waiting can fix, and only those. The SDK retries by default, with a sensible policy, and an application that needs more control turns those retries off and owns the policy, so that attempts are counted once. Idempotency decides whether a retry is safe, because a repeated request can repeat its side effects. The decision function and the retry layer make the policy explicit, and the logs make it accountable. The next lesson looks at the other half of the timing question: how long to wait before giving up.

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 Retry Failed Requests and get answers drawn from it.

Signed-in readers only.