Handle File Errors
Files Add New Ways to Fail
Requests with images, PDFs, and uploaded files fail in ways that plain text requests do not. A file can be the wrong type, too large, corrupt, or no longer available. A stored file ID can expire or be deleted. Each of these problems needs a different response. Some are fixed by the user, some by re-uploading, and some by waiting.
Lessons 43, 57, 77, and 105 covered stop reasons, stream errors, async errors, and invalid output. This lesson covers errors specific to files, and shows how to turn them into the right action.
Errors by Cause
| Cause | Typical signal | Right response |
|---|---|---|
| Unsupported or corrupt file | 400 (BadRequestError) | Tell the user; do not retry |
| File or request too large | 413 (APIStatusError with status 413) | Resize or split; do not retry as is |
| Stored file ID missing or deleted | 404 (NotFoundError) | Upload the file again, then retry once |
| Rate limit | 429 (RateLimitError) | Wait and retry |
| Service problem | 5xx (InternalServerError) | Wait and retry |
| Network problem | APIConnectionError | Wait and retry |
| Refusal or truncation | stop_reason | Handle as in Lesson 43 |
The key distinction is between permanent problems, where retrying the same request cannot succeed, and temporary problems, where it can. Retrying a 413 produces another 413, and wastes time and tokens.
Check Before You Send
Most file errors are predictable, so catch them locally whenever possible. A validation function checks the content, the type, and a local size policy before any request is made (Lessons 111–114):
MAX_FILE_BYTES = 20 * 1024 * 1024 # Local policy, not an API limit
def validate_upload(name: str, data: bytes) -> list[str]:
problems = []
if not data:
problems.append("the file is empty")
if len(data) > MAX_FILE_BYTES:
problems.append(f"{name} is {len(data) // (1024 * 1024)} MB; the limit is 20 MB")
if not (data.startswith(b"%PDF-") or detect_media_type(data)):
problems.append("unsupported file type; use PDF, JPEG, PNG, GIF, or WebP")
return problems
A local check is free, instant, and gives a message the user can act on. An API error is none of these, so local checks should catch everything they can.
Translating Errors for Users
Error messages from the API are written for developers. Users need a message that says what happened and what to do. The mapping below keeps the technical details in logs, and returns only what the user needs:
import anthropic
def explain_file_error(error: Exception) -> tuple[str, bool]:
"""Return (message for the user, whether trying again could help)."""
if isinstance(error, anthropic.APIConnectionError):
return "The connection failed. Please try again.", True
if isinstance(error, anthropic.RateLimitError):
return "The service is busy. Please wait a moment and try again.", True
if isinstance(error, anthropic.InternalServerError):
return "The service had a problem. Please try again shortly.", True
if isinstance(error, anthropic.NotFoundError):
return "The stored file is no longer available. It will be uploaded again.", False
if isinstance(error, anthropic.APIStatusError):
if error.status_code == 413:
return "This file or request is too large. Try a smaller file or fewer pages.", False
if error.status_code == 400:
return "The file could not be read. Check that it is a valid, uncorrupted file.", False
return "Something went wrong with this file. Please try again.", False
The order of the checks matters. RateLimitError, InternalServerError, and NotFoundError are all subclasses of APIStatusError, so the specific cases must come before the general one.
Practical Example: Asking About a File, Safely
This example combines the pieces. It validates the upload, sends the file, re-uploads once if the stored ID has expired, retries temporary failures with backoff, and returns either an answer or a clear message.
"""safe_file_question.py: ask about a file, with validation, re-upload, and bounded retries."""
import time
import anthropic
from anthropic import Anthropic
from media_utils import detect_media_type
client = Anthropic()
MODEL = "claude-opus-5-5"
MAX_FILE_BYTES = 20 * 1024 * 1024
ATTEMPTS = 3
def validate_upload(name: str, data: bytes) -> list[str]:
problems = []
if not data:
problems.append("the file is empty")
if len(data) > MAX_FILE_BYTES:
problems.append(f"{name} is {len(data) // (1024 * 1024)} MB; the limit is 20 MB")
if not (data.startswith(b"%PDF-") or detect_media_type(data)):
problems.append("unsupported file type; use PDF, JPEG, PNG, GIF, or WebP")
return problems
def media_type_of(data: bytes) -> str | None:
return "application/pdf" if data.startswith(b"%PDF-") else detect_media_type(data)
def explain_file_error(error: Exception) -> tuple[str, bool]:
if isinstance(error, anthropic.APIConnectionError):
return "The connection failed. Please try again.", True
if isinstance(error, anthropic.RateLimitError):
return "The service is busy. Please wait a moment and try again.", True
if isinstance(error, anthropic.InternalServerError):
return "The service had a problem. Please try again shortly.", True
if isinstance(error, anthropic.NotFoundError):
return "The stored file is no longer available. It will be uploaded again.", False
if isinstance(error, anthropic.APIStatusError):
if error.status_code == 413:
return "This file or request is too large. Try a smaller file or fewer pages.", False
if error.status_code == 400:
return "The file could not be read. Check that it is a valid, uncorrupted file.", False
return "Something went wrong with this file. Please try again.", False
def ask(data: bytes, file_id: str, question: str) -> str:
block_type = "document" if data.startswith(b"%PDF-") else "image"
message = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": [
{"type": block_type, "source": {"type": "file", "file_id": file_id}},
{"type": "text", "text": question},
]}],
)
return "".join(block.text for block in message.content if block.type == "text").strip()
def ask_about_file(name: str, data: bytes, question: str) -> str:
problems = validate_upload(name, data)
if problems:
raise ValueError("; ".join(problems))
file_id = None
for attempt in range(1, ATTEMPTS + 1):
try:
if file_id is None:
file_id = client.files.upload(file=(name, data, media_type_of(data))).id
return ask(data, file_id, question)
except anthropic.NotFoundError:
file_id = None # The stored file is gone: upload again next time
except (anthropic.RateLimitError, anthropic.InternalServerError, anthropic.APIConnectionError) as error:
message, _ = explain_file_error(error)
if attempt == ATTEMPTS:
raise RuntimeError(message) from error
time.sleep(2 ** attempt) # Back off before the next attempt
except anthropic.APIError as error:
message, _ = explain_file_error(error)
raise RuntimeError(message) from error
raise RuntimeError("The file could not be used. Please try again.")
if __name__ == "__main__":
# Offline demonstrations: no API calls
print(validate_upload("notes.txt", b"plain text, not an image"))
print(validate_upload("empty.pdf", b""))
request = anthropic.httpx.Request("POST", "https://api.anthropic.com/v1/messages")
samples = [
("HTTP 413", anthropic.APIStatusError("too big", response=anthropic.httpx.Response(413, request=request), body=None)),
("rate limit", anthropic.RateLimitError("busy", response=anthropic.httpx.Response(429, request=request), body=None)),
("missing file", anthropic.NotFoundError("gone", response=anthropic.httpx.Response(404, request=request), body=None)),
("network", anthropic.APIConnectionError(request=request)),
]
for label, error in samples:
message, retry = explain_file_error(error)
print(f"{label} | retry={retry} | {message}")
Output:
['unsupported file type; use PDF, JPEG, PNG, GIF, or WebP']
['the file is empty', 'unsupported file type; use PDF, JPEG, PNG, GIF, or WebP']
HTTP 413 | retry=False | This file or request is too large. Try a smaller file or fewer pages.
rate limit | retry=True | The service is busy. Please wait a moment and try again.
missing file | retry=False | The stored file is no longer available. It will be uploaded again.
network | retry=True | The connection failed. Please try again.
The anthropic.httpx reference works because the SDK imports httpx. In your own code, import httpx is clearer.
How It Works
validate_upload()catches empty, oversized, and unsupported files before any request is made. The output shows that an empty file produces two problems, since it is both empty and unidentifiable.explain_file_error()separates the error types that users can understand, and records whether a retry could help. Theretryflag is what the caller uses to decide.ask_about_file()handles each case deliberately. A missing stored file triggers a fresh upload on the next attempt. Temporary failures wait with exponential backoff, for at most three attempts. Permanent failures stop immediately.- The
exceptclauses are ordered from specific to general, soNotFoundErrorand the temporary errors are handled before the generalAPIErrorbranch. - Technical details are kept out of the messages. The user sees the mapped text, and the original error is chained with
from error, so logs can still show the full cause (Lesson 49).
Common Mistakes
Retrying Permanent Errors
What happens: a 413 or 400 is retried several times, adding delay and cost, and the result never changes.
Fix: retry only temporary errors, and fix the cause for the rest.
Showing Raw API Messages to Users
What happens: messages such as "invalid_request_error" confuse people and reveal internal details.
Fix: translate errors into actionable messages, and log the originals.
Checking Only After Sending
What happens: every invalid file costs a request, and users wait for a failure they could have been told about immediately.
Fix: validate locally first.
Ignoring Missing Stored Files
What happens: conversations that reference an old file ID fail permanently.
Fix: catch NotFoundError, re-upload, and retry once (Lesson 116).
Retrying Without Backoff
What happens: a busy service receives more requests from every retry, and the problem grows.
Fix: wait longer after each failed attempt, and limit the number of attempts.
Best Practices
- Validate files before sending, using the content, not the name.
- Classify errors as temporary or permanent before deciding on a retry.
- Order exception handlers from specific to general.
- Translate errors into messages users can act on.
- Log the original error with its request ID (Lesson 49).
- Re-upload when a stored file is missing, and retry only once.
- Cap attempts and back off between them.
Exercise
- Add a check to
validate_upload()that rejects images whose width or height exceeds 8000 pixels, usingImage.open()from Lesson 113. - Add a message for
400errors that tells the user to check the file type, and a test that aBadRequestErrormaps to it. - Write a function that returns the number of attempts actually made, so that tests can confirm that permanent errors are not retried.
- Explain why
explain_file_error()checksNotFoundErrorbefore the generalAPIStatusErrorbranch.
Step 3: count attempts inside the loop and return the count with the result, or include it in the exception message:
def ask_counting(data: bytes, question: str) -> tuple[str, int]:
attempts_made = 0
for attempt in range(1, ATTEMPTS + 1):
attempts_made = attempt
try:
... # the same logic as ask_about_file, returning the answer on success
except anthropic.APIError:
...
return answer, attempts_made
A test can then send a 413 through a fake client and assert that attempts_made == 1. Testing the retry count is the most direct way to verify that permanent errors are not retried, and it is the kind of test Lesson 110 recommends.
Step 4: NotFoundError is a subclass of APIStatusError, which means it matches the general branch too. If the general branch came first, the 404 would be reported as a generic status error, and the specific advice (re-upload and try again) would never appear. Specific classes must be checked before their parent classes, in every exception handler.