I was re-reading the Platform release notes one night when the October 20 entry stopped me. An organization admin can now turn web fetch off for the whole organization from the Capabilities page in the Console. With it off, a Messages API request that includes the web fetch tool fails with a 400.
As an indie developer running small research scripts, I'm both the person who touches the admin page and the person who writes the code. On a team key, those are two different people. And if a 400 showed up with nothing wrong in the code, I know exactly where I'd start looking: the date-stamped type string on the tool.
So I went back through the docs and sorted out how the failures present themselves. What I found is that web fetch failures show up in two layers, and each layer sends you somewhere completely different.
What I'd like to say up front is this: if the organization has switched web fetch off, you get a 400 even when your code is fine.
Two layers of failure
Here is how the docs break down.
| Layer | What you see | What it means |
|---|---|---|
| Whole request | HTTP 400, invalid_request_error, with a message saying web fetch is not enabled | An org admin turned web fetch off on the Capabilities page in the Console |
| Result block | HTTP 200, with an error_code inside web_fetch_tool_result_error | The tool was available, but fetching that URL did not go through |
Because the second layer comes back as a 200, exception handling alone will not notice it. Claude receives the error result and carries on with the turn, so the user just sees a sentence saying the page could not be fetched while your program counts the call as a success.
The error codes, and where to look first
I reordered the documented codes by where I would look first.
| error_code | What is happening | Look here first |
|---|---|---|
url_not_allowed | Blocked by domain rules, by your org's settings, or by Anthropic-side limits (private addresses, robots.txt, URLs that look like they contain a credential) | allowed_domains / blocked_domains and the org-level lists. The URL has to pass both |
url_not_in_prior_context | The URL did not appear earlier in the conversation, or only appeared in a source excluded by url_sources | Whether the URL is in a user message or in an allowed tool_result |
url_not_accessible | An HTTP error from the target | Open the URL in a browser |
max_uses_exceeded | Over max_uses; failed fetches count too | The limit, and how often the same URL was retried |
unsupported_content_type | Anything other than text, HTML, or PDF | Whether the link points at an image or a zip |
invalid_tool_input / url_too_long | Malformed URL, or longer than 250 characters | An oversized query string |
too_many_requests / unavailable | Rate limit or internal error | Wait and retry |
The one that trips people up is url_not_in_prior_context. A URL that exists only in Claude's own output or only in the system prompt cannot be fetched. If you want a system-prompt URL fetched, put the same URL in a user message too.
Putting the triage in a function
I stopped reading these by eye and added a small function that tells me which layer failed. It catches BadRequestError for the 400 case and walks the result blocks for the 200 case.
#!/usr/bin/env python3
"""Separate web fetch failures into a whole-request 400 and a result-block error_code."""
import anthropic
# For each error_code on a result block (HTTP 200), decide where to look first
NEXT_STEP = {
"url_not_allowed": "Check allowed_domains / blocked_domains AND the org-level allow/block lists",
"url_not_in_prior_context": "Confirm the URL is in a user message or an allowed tool_result",
"url_not_accessible": "HTTP error from the target. Open the URL by hand",
"max_uses_exceeded": "Revisit max_uses. Failed fetches are counted",
"unsupported_content_type": "Only text / HTML / PDF can be fetched",
"invalid_tool_input": "Check the URL spelling and scheme (http / https)",
"url_too_long": "The URL is over 250 characters",
"too_many_requests": "Wait a moment and retry",
"unavailable": "Internal error. Retry after a pause",
}
def triage(call):
"""call is a zero-argument function that calls the Messages API. Returns (layer, detail)."""
try:
resp = call()
except anthropic.BadRequestError as e:
msg = str(getattr(e, "message", e))
low = msg.lower()
if "web fetch" in low and "not enabled" in low:
return ("org-setting", "Web fetch is off for the org. Ask an admin to check the Capabilities page")
return ("other-400", msg[:200]) # other 400s, such as temperature, land here
found = []
for b in getattr(resp, "content", []) or []:
if getattr(b, "type", "") != "web_fetch_tool_result":
continue
c = getattr(b, "content", None)
if getattr(c, "type", "") == "web_fetch_tool_result_error":
code = getattr(c, "error_code", "?")
found.append((code, NEXT_STEP.get(code, "See the error list in the docs")))
return ("result-error", found) if found else ("ok", None)You use it by wrapping the call: triage(lambda: client.messages.create(...)).
There are two reasons it looks like this. First, I don't want to lean on the wording of a 400: only the two phrases web fetch and not enabled mark an org setting, and every other 400 falls into other-400. Second, I want a failure inside a 200 to leave at least one line in the log every time.
What I verified, and what I did not
Let me be plain about this. My own organization has not turned web fetch off, so I have not reproduced the org-setting 400 myself. That layer is described from the documentation.
What I did check: I built a BadRequestError by hand, and a message containing "web fetch is not enabled" came back as org-setting while a different message came back as other-400. A response with a url_not_allowed result block came back as result-error, and a response with no error came back as ok. All four returned what I expected.
So the branches work, but I have not seen the real 400 wording. Before you rely on it in production, I'd suggest looking at the actual message once in a test organization where you can flip the setting.
The order I check now
When web fetch does not go through, I look in this order.
- Is it a 400 or a 200? If it is a 400 that mentions web fetch, ask an admin about the org setting instead of editing code
- If it is a 200, match the
error_codeagainst the table - For
url_not_allowed, check the request-level domain lists and the org-level lists — both - For
url_not_in_prior_context, trace how the URL entered the conversation
Steps 2 and 3 are easy to confuse. The docs say a URL has to pass the organization's allow and block settings as well as the tool's own allowed_domains / blocked_domains. A URL you allowed on the tool can still come back as url_not_allowed if the org side rejects it.
For the design side of web fetch, I wrote Pulling live context in with the Web Fetch tool, and for reading other 400s there is Tracing invalid_request_error back to its cause. A 400 that comes from the API version itself is closer to my notes on sweeping call sites before temperature becomes a 400.
Closing
Split the failure by its HTTP layer first, and only then suspect the code. I try to hold on to that line even on a tired night.
If you'd like a first step, wrap one existing call in triage and log the error_code whenever a 200 contains one. If nothing ever shows up, that is itself a useful confirmation that the calls are going through.