iOS/Android アプリの運用で、商品画像や生成アートのサムネイルを Claude API に投げて分類・キャプション付けする処理を書いていると、ある日ふいに 400 invalid_request_error: Could not process image で落ち始めることがあります。直前まで動いていたコードが、画像を URL 渡しに切り替えた瞬間からエラーになる、という相談を最近よくいただきます。
私自身、壁紙系アプリの自動分類パイプラインを base64 から URL 形式に置き換えたとき、最初の 3 時間ぶん(約 800 枚)が全部 invalid_request_error で弾かれて凍りつきました。原因は意外と単純で、画像配信側の CDN が一部リージョンで 302 を返していた、というだけのものでした。
このタイプのエラーは原因が「URL スキーム・MIME・サイズ・到達性」のいずれかに収束することが多く、順序立てて切り分ければ 10 分以内に潰せます。2014 年から個人アプリ開発を続けて累計 5,000 万 DL を超え、AdMob 月収 100 万円規模で運用してきた現場感も交えて、診断手順を整理します。
出るエラーの代表的な文面
URL ソースで画像を渡したとき、API が返してくるエラーは主に次の 3 系統です。
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.0.content.0.source.url: Could not process image https://example.com/photo.jpg"
}
}{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.0.content.0.source.url: Image must be a valid HTTPS URL"
}
}{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "Image exceeds 5 MB size limit"
}
}文面で原因をある程度絞れます。Could not process は「URL は読めたが中身がダメ」、must be a valid HTTPS URL は「URL そのものが弾かれている」、exceeds X MB は「容量超過」という棲み分けです。
切り分けの起点:URL を curl してから API に渡す
エラーメッセージだけ眺めても確証は得られないので、最初に必ずやっておきたいのが「Anthropic 側で見えている URL の挙動を、自分の端末からも再現する」ことです。
# ヘッダーだけ取得して内容を確認
curl -sIL -A "Anthropic-Image-Fetcher/1.0" "https://example.com/photo.jpg"出てきた HTTP/2 200 の有無、Content-Type、Content-Length、リダイレクトチェーンの長さ、この 4 つを最初に確認します。これが揃わない限り、API 側がいくら正しい呼び出しでも通せません。
原因①: URL スキームが HTTP のまま
Claude API の画像 URL ソースは HTTPS 必須 です。CMS や自前サーバから画像を引いていると、開発環境で誤って http:// のままハードコードされていることが意外とあります。
# NG
content = [{
"type": "image",
"source": {"type": "url", "url": "http://example.com/photo.jpg"},
}]修正は単純で、必ず https:// に揃えること。混在を避けるため、本番投入前に URL を正規化するヘルパーを噛ませておくと安全です。
from urllib.parse import urlparse, urlunparse
def to_https(url: str) -> str:
p = urlparse(url)
if p.scheme == "https":
return url
if p.scheme == "http":
return urlunparse(p._replace(scheme="https"))
raise ValueError(f"Unsupported scheme: {p.scheme}")S3 / R2 の Presigned URL を使う場合は、必ず HTTPS で発行されるようにバケットポリシーを締めておきます。
原因②: MIME タイプがサポート外
Claude API がサポートする画像 MIME は image/jpeg、image/png、image/gif、image/webp の 4 種類です。HEIC、AVIF、BMP、TIFF、SVG は通りません。
落とし穴になりやすいのが「拡張子は .jpg だが、CDN の Content-Type が application/octet-stream で返ってきている」ケース。Claude API は URL のレスポンスヘッダーを尊重するので、拡張子ではなく MIME で弾かれます。
# 期待: Content-Type: image/jpeg
curl -sI "https://example.com/photo.jpg" | grep -i content-typeapplication/octet-stream や binary/octet-stream が返ってきていたら、配信側で MIME を正しく設定するか、対象オブジェクトに ContentType を明示して上書きします。Cloudflare R2 や AWS S3 ではアップロード時に --content-type image/jpeg を必ず添えるようにしています。
iPhone から直接アップロードされた素材を扱う場合、HEIC のまま CDN に乗ってしまうのが定番の罠です。heif-convert か Sharp で JPEG/PNG に再エンコードしてから配信パスに置く運用にしないと、年に何度か必ずインシデントになります。
原因③: ファイルサイズが 5 MB を超えている
URL ソースで参照できる画像の上限は 1 ファイルあたり 5 MB、解像度の上限は長辺 8000 px です。私の壁紙アプリは 4K 素材を扱うので、生のままだと頻繁に超過します。
事前チェックを HEAD リクエストで挟むのが一番手堅い対策です。
import httpx
async def is_within_limit(url: str, max_mb: int = 5) -> bool:
async with httpx.AsyncClient(follow_redirects=True, timeout=10) as c:
r = await c.head(url)
size = int(r.headers.get("content-length", "0"))
return 0 < size <= max_mb * 1024 * 1024超過していたら、配信側で長辺 2000 px ・ JPEG quality 85 に縮める変換 URL を別途用意しておくと、API 側の品質を犠牲にせずに済みます。Cloudflare Images の ?width=2000&quality=85 のようなオンザフライ変換が便利です。
原因④: URL が認証必須・リダイレクトで落ちる
CDN 側でアクセス元 IP を絞っていたり、Referer/Origin ヘッダーをチェックしていたりすると、Anthropic 側のフェッチャーが 401/403 を踏みます。署名期限の切れた Presigned URL も同様です。
検証は、HTTP ヘッダーを「外から」何も渡さない素の curl で 200 が返るかどうかでだいたい判定できます。
curl -sI -L "https://example.com/photo.jpg" | head -5HTTP/2 401、HTTP/2 403、HTTP/2 302 が連鎖して HTTP/2 200 に着地しない場合、URL ソースのまま渡しても通りません。次のいずれかで対処します。
- 認証を外せる公開バケットに画像を一旦コピーする(ライフサイクルポリシーで 24 時間後に削除)
- Presigned URL の有効期限を 5 分以上 残して発行する(フェッチが即時に走る保証はないため)
- どうしても認証が外せないなら、URL ソースを諦めて自分で
download → base64 → source.type: base64の経路に変える
302 のリダイレクトは最大 5 ホップまでは追従されますが、Cloudflare の Access ログインリダイレクトのような HTML 経由のものは追えません。最終 URL を事前解決してから渡す方が安全です。
原因⑤: クエリパラメータでの動的変換が間に合っていない
意外と踏むのが、https://example.com/photo.jpg?width=2000&quality=85 のような URL を渡したとき、CDN 側の変換ワーカーが初回アクセスで 5〜10 秒かかり、Anthropic 側のフェッチャーがタイムアウト判定する、というケースです。
私のパイプラインでは、API に渡す前に自分で HEAD を打って 200 を確実に引き出すウォームアップ処理を挟むようにしています。
async def warmup_and_call(url: str):
async with httpx.AsyncClient(timeout=15) as c:
await c.get(url) # キャッシュをウォームアップ
# ↓ Claude API 呼び出し
response = await client.messages.create(...)「先に自分で取りにいって CDN に焼く」というだけの手当てですが、これだけで失敗率が体感で 2% から 0.1% を切る程度まで下がりました。
URL ソースが本当に使えないときの退避策
上記すべてを試しても通らないときは、おとなしく base64 形式に切り替えます。コードは増えますが、フェッチ失敗の不確実性をローカル側に持ってこられるので、デバッグが圧倒的に楽になります。
import base64
import httpx
import anthropic
async def image_block_from_url(url: str) -> dict:
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as c:
r = await c.get(url)
r.raise_for_status()
return {
"type": "image",
"source": {
"type": "base64",
"media_type": r.headers["content-type"].split(";")[0].strip(),
"data": base64.standard_b64encode(r.content).decode("ascii"),
},
}base64 だとリクエストペイロードが 33% ほど膨らみますが、画像 1 枚ごとに失敗してもアプリ全体が止まらない構造になるのが大きな利点です。私は本番では「URL でまず試す → 失敗したら base64 でリトライ」の二段構えにしています。
振り返り
URL ソースの invalid_request_error は、Anthropic 側で何が起きているかの可視性が低い分、自分の側で curl -sIL の結果をスナップショットとして残しておくのが結局いちばん早い解決策になります。エラー文面を眺めて推測する時間が長くなったら、まず端末から HEAD を打つ習慣を持っておくと、原因が画像配信側にあるのか API 呼び出し側にあるのかが 5 分でわかります。
似たような切り分けが必要になる場面は他にもあります。同じ Claude API 周りで invalid_request 系の取り回しに困ったら、Claude API の invalid_request_error メッセージ別対処 と Claude API のプロンプトキャッシュがヒットしない時の診断手順 もあわせて参考にしてみてください。
実装の参考になれば幸いです。お読みいただきありがとうございました。