DEV Community

Mohamed
Mohamed

Posted on • Originally published at moaz06.com

A 200 Is Not a Read

TL;DR: Your AI agent will answer from a page it never actually read, with a straight face, and the transcript won't tell you. An HTTP 200 means the bytes arrived, not that you got the page. FetchGate turns "did we actually read it?" into a deterministic RETRIEVED / FAILED / UNKNOWN verdict, and hard-fails before the model can bluff.

Give an AI agent a web tool and watch what happens when a fetch fails quietly instead of cleanly. The page comes back as an empty JavaScript shell, a Cloudflare "just a moment" screen, or a 403 dressed up as a 200, and the agent does not stop to notice. It answers anyway, from whatever it already had in its weights, and nothing in the transcript tells you it never read the page. What you get is a confident guess wearing the costume of a retrieval.

I keep coming back to this shape: a tool answers one question with confidence and leaves a second, more important one unanswered. This time the second question is the smallest and the sharpest one I have worked on: did we actually read the page, yes or no?

The visible axis: tokens

The popular version of this problem is about cost. A raw article can be tens of thousands of tokens, so people measure it, complain about it, and reach for cleaner extraction and stealth browsers to shrink it. That cost is real, and it is being handled.

But cost was never the dangerous part. Context windows grew, extraction improved, and the cost problem mostly dissolved. The dangerous part sat underneath it the whole time and never moved: whether the fetch produced a read at all.

The invisible axis: whether the fetch happened

The failure itself is simple. A model is handed no content while a question is waiting to be answered, so it fills the gap. That is what models do; it is not misbehavior. The bug is not the model guessing but the harness that lets an empty retrieval reach a final, grounded-sounding answer.

It shows up in three ways:

  • the fetch returns nothing and the model answers from its training weights,
  • the fetch returns nothing and the model answers from a stale prior it happens to hold,
  • the fetch succeeds, technically, but returns the wrong content (a challenge page, a consent wall, a soft 404), and the model reads that and answers as if it read the article.

What I am building here is not new groundedness work, and I want to be honest about the neighbors. RAG faithfulness checks like RAGAS and TruLens, abstention work on knowing what you don't know, and citation UX like Perplexity's "could not access this source" all live near this problem and earn their keep. But they evaluate whatever text arrived, after the fact; none of them assert that a fetch of the intended resource actually happened. That narrow delta is the whole point: a check at the fetch boundary, upstream of grounding, that hard-fails before the model ever gets to answer.

The name of the mistake

The mistake is treating an HTTP 200 as evidence. A 200 means the bytes arrived; it says nothing about whether those bytes are the page you asked for or a wall standing in front of it. A JavaScript app returns a 200 whose body is almost all script and almost no readable text. Cloudflare returns a 200 with a full "verify you are human" page. Both pass every naive check, which is why the question cannot be "did the request succeed" but has to be "did we come away with the page".

The gate, not the fields

The obvious fix is to hand the model more metadata: the status code, byte counts, a note that the page looked empty. It does not work, because richer fields the model can read are richer fields the model can ignore. The model will wave past a byte count of zero the same way it waves past an empty body. If it gets a vote on whether it read the page, you have handed the decision back to the component you did not trust in the first place.

So the decision lives outside the model, in the orchestration layer, as a deterministic gate. The gate reads what the fetch runtime measured and returns one of three verdicts:

  • RETRIEVED: we can point to real, sufficient content. The model may answer and cite it.
  • FAILED: we can point to a definite reason there is no read, such as a 4xx or 5xx, a timeout, an HTTPS to HTTP downgrade, or an empty body. The agent stops.
  • UNKNOWN: we cannot classify it either way, often because the page is a JavaScript app that needs rendering. The agent stops, or renders and checks again.

Three layers feed the verdict, and the strictest of them wins. The transport layer looks at the status and the redirect chain. The extraction layer keeps readable text separate from raw bytes, so that a 17-byte price JSON counts as a read while a 400 KB script shell does not. The third layer, a content-validity fingerprint, watches for the walls. It is a best-effort heuristic that will rot over time, so it is only ever allowed to pull a verdict down to UNKNOWN; it can never manufacture a read, and the only path to a hard FAILED is a transport signal. The gate fails closed or fails labeled; it never fails fluent.

Where the browser earns its weight

For pages that are genuinely empty to a static reader, the gate escalates to a real headless browser, lets the page render, and classifies again. The point is to render, not to evade: a page that is still walled after an honest render stays UNKNOWN and hard-fails. The gate opens a browser tab the way a person would; it does not defeat a control the site put up on purpose.

Provenance is a security property

Answer quality is the obvious reason to care, but not the sharpest one. Agents act on what they read, which makes silent fetch failure an attack surface as well as a bug. If I can make your agent's fetch quietly fail, or serve it a decoy, I can steer what it does next. Retrieval suppression, cloaking, and context poisoning are all attacks on an agent that trusts a 200.

That is why every decision ships with a receipt: the final URL after redirects, the status, raw bytes versus extracted bytes, and a content hash. A receipt is only as strong as what it proves, so it comes with two honest limits. The hash pins down what was read, which makes a later claim re-checkable, but it is not tamper-evidence; a cloaked page hashes cleanly. And provenance is not authenticity: the gate closes the fail-silent-on-empty hole, not the case where a fetch succeeds on wrong content that slips past the fingerprint. It raises the bar from "no evidence" to "evidence that passed the checks". That is worth having, and it is not everything.

There is a third limit: a read is not an answer. RETRIEVED means enough content arrived, not that it contained the fact you needed. Checking that is groundedness work, and it composes downstream, after the gate has done its one job.

The pattern underneath

This is the third time I have built the same shape. ReachGate decides whether a vulnerability is actually reachable in your code before a human touches the finding. TrustGate decides whether an agent may take a risky action given the live state of the data behind it. FetchGate decides whether a page was actually read before the agent answers from it.

The domains differ, but the idea is the same: let a deterministic engine make the call, let the model only explain it, and ship every decision as proof you can check yourself.

FetchGate is the smallest of the three, a gate that comes down to nearly one bit: read or not. But the fetch boundary is where an agent's grip on reality is thinnest, and it is the one place the other two do not cover. It is open source and deterministic, runs with no model and no API keys, and ships with a test that tries to sneak a fabricated read past the gate; the forgery gets caught, by design. A 200 is not a read, and an agent that cannot tell the difference will tell you it read a page it never saw, with a straight face, every time.

Get it

pip install "fetchgate[mcp]"
Enter fullscreen mode Exit fullscreen mode

It is open source and deterministic. If it saves you one silent bad answer, a star on the repo helps others find it.

Top comments (0)