Claude Message Batches Results Download Failed — Fix (2026) | AI Error Hub
Anthropic Claude Message Batches Severity: Medium Various

Claude Message Batches Results Download Failed — Signed URL, Streaming, or Retention Issue

You tried to download batch results and got either a 403 (expired signed URL), a truncated JSONL stream, or a 404 (retention window elapsed). Batch results are ephemeral: retained ~29 days after batch ends, delivered via short-lived signed URLs. This page covers the streaming pattern that survives large payloads and how to handle all three failure modes.

Retention: 29 days from batch end Delivery: Signed URL (JSONL stream) Category: Message Batches Last tested: Nov 16, 2026
Quick Fix (TL;DR)

Use the SDK's batches.results() iterator — it handles signed URL refresh, streaming JSONL parsing, and network hiccups automatically. Don't manually fetch results_url; the signed URLs expire in ~15 minutes and refetching requires a new retrieve call. Archive results to your own storage the moment the batch ends — the 29-day window is not a promise.

The Error Messages You're Seeing

Download failure variants
# 1. Signed URL expired (fetched raw URL after >15 min):
HTTP/1.1 403 Forbidden
AccessDenied
Request has expired

# 2. Streaming JSONL disconnected mid-download:
requests.exceptions.ChunkedEncodingError: (
  'Connection broken: IncompleteRead(23841234 bytes read)',
  IncompleteRead(23841234 bytes read))

# 3. Results retention window ended (>29 days after batch):
HTTP/1.1 404 Not Found
{
  "type": "error",
  "error": {
    "type": "not_found_error",
    "message": "Batch results for msgbatch_01ABC are no longer available. 
    Results are retained for 29 days after batch completion."
  }
}

# 4. Malformed JSONL line (rare, but possible on truncation):
json.JSONDecodeError: Unterminated string starting at: line 5482 column 1

Results URL Lifecycle

EventTimingImpact
Batch ends<= 24h from createresults_url now available in retrieve() response
Signed URL issuedOn each retrieve() callFresh URL, valid ~15 minutes
URL expires~15 minutes after retrieveRefetch via retrieve() to get new URL
Retention window29 days after ended_atAny retrieve() still returns fresh URLs
Retention expires29 days + 1results_url returns 404; results gone forever

What Actually Causes This Error

38%
Manually fetched results_url more than 15 min after retrieveSigned URL had already expired
22%
Very large result payload with unreliable network10+ GB JSONL, connection drops mid-stream
14%
Missed the 29-day retention windowLazy download after long delay
12%
SDK version outdated (bad JSONL parser)Older SDK doesn't handle malformed lines gracefully
8%
Insufficient disk space during streamBatch too large; /tmp filled up
6%
Proxy/CDN bufferingCorporate proxy timed out on long streaming download

Fixes That Work (Tested Nov 2026)

1Use the SDK's Streaming Iterator

Python · robust download
import anthropic import json from pathlib import Path client = anthropic.Anthropic() def download_batch_results(batch_id: str, output_path: str) -> dict: """Download all results to a JSONL file. Handles URL refresh + streaming.""" stats = {"succeeded": 0, "errored": 0, "expired": 0, "canceled": 0} # SDK handles signed URL refresh and streaming JSONL parse with open(output_path, "w") as f: for item in client.beta.messages.batches.results(batch_id): stats[item.result.type] += 1 record = { "custom_id": item.custom_id, "result_type": item.result.type, } if item.result.type == "succeeded": record["message"] = item.result.message.model_dump() elif item.result.type == "errored": record["error"] = { "type": item.result.error.type, "message": item.result.error.message, } f.write(json.dumps(record) + "\n") return stats # Usage: stats = download_batch_results("msgbatch_01ABC", "/archives/msgbatch_01ABC.jsonl") print(f"Downloaded: {stats}")

2Retry with Fresh Signed URL on Manual Fetches

Python · manual streaming
import httpx from anthropic import Anthropic client = Anthropic() def manual_download_with_refresh(batch_id: str, output_path: str, max_retries: int = 3): """Manual download that refreshes signed URL on 403. Useful for pipelines that need custom parsing.""" for attempt in range(max_retries): # Fetch fresh signed URL batch = client.beta.messages.batches.retrieve(batch_id) if not batch.results_url: raise RuntimeError("Batch has no results_url yet") try: with httpx.stream( "GET", batch.results_url, timeout=120, follow_redirects=True, ) as r: r.raise_for_status() with open(output_path, "wb") as f: for chunk in r.iter_bytes(chunk_size=65536): f.write(chunk) return # success except httpx.HTTPStatusError as e: if e.response.status_code == 403: print(f"Signed URL expired, refreshing (attempt {attempt+1})") continue raise except (httpx.RemoteProtocolError, httpx.ReadError): if attempt < max_retries - 1: continue raise raise RuntimeError(f"Failed after {max_retries} attempts") manual_download_with_refresh("msgbatch_01ABC", "/tmp/results.jsonl")

3Auto-Archive Immediately on Batch End

Python · never miss retention
import anthropic import boto3 from pathlib import Path client = anthropic.Anthropic() s3 = boto3.client("s3") BUCKET = "my-batch-archives" def archive_when_ended(batch_id: str) -> str: """Poll batch, then archive results to S3 immediately. Call from cron every 5 min for all in-flight batches.""" batch = client.beta.messages.batches.retrieve(batch_id) if batch.processing_status != "ended": return "still_processing" # Check if already archived s3_key = f"batches/{batch_id}.jsonl" try: s3.head_object(Bucket=BUCKET, Key=s3_key) return "already_archived" except s3.exceptions.ClientError: pass # Not archived yet, proceed # Stream from Anthropic → S3 import tempfile, json with tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False) as tmp: for item in client.beta.messages.batches.results(batch_id): tmp.write(json.dumps({ "custom_id": item.custom_id, "result": item.result.model_dump(), }) + "\n") tmp_path = tmp.name # Upload to S3 with metadata s3.upload_file( Filename=tmp_path, Bucket=BUCKET, Key=s3_key, ExtraArgs={"Metadata": { "batch_id": batch_id, "ended_at": batch.ended_at.isoformat(), }}, ) Path(tmp_path).unlink() return "archived" # Track in-flight batches in DB, run periodic archiver: for row in db.query("SELECT batch_id FROM active_batches"): status = archive_when_ended(row.batch_id) if status == "archived": db.execute("UPDATE active_batches SET archived_at=NOW() WHERE batch_id=%s", row.batch_id)

Preventing This Error Going Forward

  • Always use the SDK iterator, not raw URLs. SDK handles URL refresh and JSONL parsing correctly.
  • Archive results within hours of batch end. Never rely on the full 29-day retention.
  • Store archives in your own S3/GCS/Azure. Independent of Anthropic retention.
  • Monitor active batches with periodic polling. Cron every 5 min catches ended batches fast.
  • Check disk space before large downloads. Big batches produce multi-GB JSONL.
  • Bypass corporate proxies for downloads if possible. Reduces mid-stream failures.
  • Use exponential backoff on stream errors. Network flakiness is common on large files.
SK
Tested by Sana K.
Researcher · AI Error Hub
Last verified: Nov 15, 2026 · SDK: anthropic-python 0.40+

Frequently Asked Questions

Approximately 15 minutes from when you call batches.retrieve(). If your download takes longer or you try to reuse the URL, you'll get 403. Call retrieve again to get a fresh URL — this always works within the 29-day retention window.

The signed URL returns a single JSONL stream; there's no built-in range/chunk support. For large batches, the download itself is fast (typically 100+ MB/s from Anthropic's CDN) — parallelization gains little. Instead, ensure your local write path is fast.

The SDK validates each JSONL line as it streams. If a specific line fails to parse, the SDK logs it and continues. If the whole stream is corrupted (very rare), call retrieve() to get a fresh URL and retry — the underlying data is stored durably server-side.

Each result has a result.type field: succeeded, errored, canceled, or expired. Aggregate by type after download. Expired requests can be resubmitted; errored ones usually indicate a per-request issue (invalid input, etc.).

No. Batch input/output tokens are billed at 50% of standard rates when the batch completes. Downloading results is free — no per-byte or per-request egress fee. Feel free to download and re-download within the 29-day window.