You are here only if a server answered. The single most expensive mistake now is reading every failure as "they detected me" and jumping to a browser with residential proxies. Match the symptom first, and read the body before you read the status.
| What came back | Most likely cause | Do NOT | Go to |
|---|
403, CDN error page, the edge identified as Cloudflare (server: cloudflare, cf-ray), no cf-mitigated header, and a blocked-page body rather than an interstitial | a rule that read your address or ASN. Nothing of yours was evaluated | change your client: a real browser can fail identically | Step 3, egress work |
403 or 503, interstitial body (Just a moment...), cf-mitigated present, CSP naming a challenge host | a managed challenge. The edge will admit a good client, and the check clears itself given seconds | escalate straight to a browser Actor, see Rung 5 for what that actually did | Rung 1, then Rung 5 |
| 403 from an edge that is not Cloudflare, or one you have not identified yet | unclassified. The Cloudflare marker cannot appear here, so its absence carries no information | read "no cf-mitigated" as "my address was judged" | name the vendor first, see below |
200, a full server-rendered page carrying a form with hidden state (__VIEWSTATE, __EVENTVALIDATION, a CSRF field), and no records | not a block and not a shell. The records are behind a POST you never issued | read the empty form as a defence, or send it to Rung 5 for a browser | the form test below |
403 with an XML or JSON error body from a storage service (AccessDenied, RequestId, Server: AmazonS3 or equivalent) | an object that does not exist. Storage answers 403 rather than 404 for a missing key | shop for proxies | Step 1, the path is dead |
| 403 only after N successful requests | rate, or a session that aged out | rotate IPs blindly | Rung 1 |
429, often with Retry-After | explicit rate limit, and they are telling you the number | ignore the header | Rung 1 |
A normal page plus an injected sensor script and edge cookies (incap_*, __cf*) set on the first hit | not a block. It is notice that the API behind this page is defended even though the page is not | assume the next layer is as open as this one | Rung 4, and carry those cookies (Rung 3) |
200, small body, a mount point and a JS bundle (<div id="root">, /assets/index-*.js) | a single-page app shell. The records arrive after load | assume blocking; assume the URL matters | Rung 4 |
| 200, a rendered site whose record links point at another domain | a brochure. The data was never on this host | keep parsing this host | Step 1, re-target |
200, tiny body, no scripts, a <meta http-equiv="refresh"> or a location assignment | a redirect stub. The real page is one hop away | parse this body for records | follow the target, then re-classify |
200, a body of a few dozen bytes that is the refusal (Invalid Password, Not authorized, Missing parameter) | not a block and not a defence. An endpoint that wants arguments you did not send | treat the 200 as success, or send it to Rung 4: there is no JSON twin behind a CGI | the parameter test below |
200, small body, a stock server welcome page (IIS Windows Server, Welcome to nginx) | a bare host or the wrong virtual host, not the application | read it as the site's answer | Step 1, re-target |
A 2xx that is not 200, especially 202 with a near-empty body | not a success. Often an edge answering for an origin that did not, and frequently the site has moved | treat 2xx as "it worked" | compare loadedUrl with what you asked for, then Step 1 |
| 200, but fewer items than the browser shows | pagination cap, API limit, or a logged-out view | assume blocking | Rung 4 |
200 titled Page not found, with full site chrome | a soft 404 | trust status, headers or size; only content shows it | Step 1, the path is dead |
| Worked yesterday, empty today, no error | the site changed markup or endpoints | escalate proxies | Rung 4, re-inspect |
The first two rows are one status code and two different situations. A hard block and a managed challenge both arrive as 403 from the same CDN vendor, they route in opposite directions, and the header separates them. Both were measured on the same afternoon from one egress: a government portal answered a plain HTTP client with a bare 403 and no cf-mitigated, a vendor platform answered the same client with cf-mitigated: challenge and Just a moment.... Your diagnostic Actor cannot see either header until you unblind it, and until then both arrive as an empty item. The one-line fix is in "Making refusals visible" below; apply it before using this table.
Name the edge before you use its marker, because cf-mitigated is one vendor's header and absence is not evidence. On any edge that is not Cloudflare the header cannot appear, so "no cf-mitigated" is guaranteed and tells you nothing, while the rule that reads it happily returns "your address was judged" for every one of them. Identify the vendor from what did arrive:
| Edge | What identifies it | What separates hard block from challenge there |
|---|
| Cloudflare | server: cloudflare, cf-ray. Measured on a refusal: 4,560-byte body, Sorry, you have been blocked | cf-mitigated first, then the body. Present means a managed challenge; absent plus a blocked-page body means your address. Absent with an interstitial body is still a challenge: custom rules and newer challenge widgets do not all set the header, so let the body overrule it |
| Akamai | Server-Timing: ak_p, X-Reference-Error, a body naming errors.edgesuite.net. Measured on a refusal: 384 bytes, Access Denied, and no Server and no Via header at all | not cf-mitigated, which never appears here. Treat the refusal as unclassified and let a second egress decide |
| anything else, or unidentified | whatever Server, Via, X-Cache and Server-Timing do carry | unknown. Say so, and route by the second egress rather than by an absent header |
A refusal is a measurement, not a property of the site, and some refusals expire. The Akamai refusal above carried Server-Timing: cdn-cache; desc=HIT on a 403 whose own Cache-Control said max-age=0, meaning the error object was served from the edge cache and never reached the origin. Thirty-eight minutes later the same URL from the same address answered 302 and then 200 with 81,337 bytes, three times running. Nothing about the requester had changed. This is not a promise that refusals dissolve: an ASN or geo rule in a firewall is stable for as long as someone leaves it there, and re-measuring only confirms it. The point is the asymmetry of cost. One repeat request is free and it either upgrades a single observation into a stable fact or saves you a proxy purchase against a refusal that was never about you.
The parameter test, for an endpoint that answers 200 by refusing you. A missing argument and a credential you do not own look identical in the response, and they end in opposite places: one is Rung 3, the other stops the investigation at Step 0. Decide by where the value comes from, not by what it is called.
- The site itself hands it out to anyone who loads the page: a district code in a public dropdown, a build id in the HTML, a token the page fetches before its first search, a guest login printed in the UI. That is a parameter of a public route. Take it the way the page does, Rung 3.
- It is issued to a person, or it gates records the site does not otherwise publish. That is a credential you do not own, whatever the field is named. Stop and say so, and never guess, rotate or construct one.
- Unsure after one look at the page that issues it? Treat it as the second case and go to Step 0 to look for a published copy of the data. A source that requires an account almost always has a bulk or catalogue route that does not.
The form test, for a full page that carries a form instead of records. Server-rendered forms with hidden state are the default shape of government and utility record systems, and a GET-only crawler receives a valid 200 from one forever, with no error and no records. This is not the SPA shell row: there is no mount point and no bundle, the page is large, and the data is one POST away rather than one XHR away.
- Issue the POST the form describes, carrying every hidden field it hands you (
__VIEWSTATE, __VIEWSTATEGENERATOR, __EVENTVALIDATION and friends) plus the search field, with a Referer of the form page.
- Read where the answer goes. If the response is a redirect to a URL carrying your query in its parameters, you have the record address rather than a session.
- Then test that address cold, in a fresh client with no cookies and no
Referer. This is the step that decides the whole job.
Measured on a county assessor's system: the form page answered 200 with 5,316 bytes and a __VIEWSTATE; the POST answered 302 to .../ParcelDetail.aspx?hdnParcel=<key>&hdnInstance=pcl7; and a cold curl of that URL, no cookies, no Referer, default user agent, answered 200 with 38,817 bytes of the actual record. One of the two redirect parameters turned out to be optional, so strip parameters one at a time before you build them into a crawl. A key the system does not know answered 200 with 747 bytes reading No record found, which is a third outcome to record rather than an empty row to drop.
When the cold GET works, the crawl collapses to one request per key, no browser, no session, no postback: fetch the keys from whatever Step 0 turned up and go straight to record URLs.
Separating a shell from a brochure, since both are a 200 with no data and the advice is opposite. Do not ask where a human would go; a human really does use the SPA's host. Ask the body. A shell is small, has a single empty mount element, loads a JS bundle, and returns the same bytes and the same ETag for different record ids. A brochure is a full rendered page whose record links point at another domain. Shell means stay, go to Rung 4. Brochure means the target is wrong, return to Step 1.
Compare the URL you asked for with the URL that answered, every time. Redirect chains cross domains silently, and a site that moved hosts is the cheapest diagnosis to miss. On one sweep loadedUrl was the only field that revealed a county assessor had moved onto the city's domain, behind a 202 that looked like nothing at all.
loadedUrl only exists on a request that completed, which is the case this skill is about. While every egress is refused you have no redirect chain and no way to see that two hostnames are one site, so two refusals from two different CDN vendors read as two independent targets and double your apparent problem. Two ways out, both cheap: compare the CNAME chains, or wait for the first egress that answers and read loadedUrl there. Measured: a .com hostname refused by Cloudflare and a .gov hostname behind Akamai turned out to be one site, and the loadedUrl from the egress that was admitted said so in one field.
Write down which row you are in. Every later decision depends on it.