ondelva

← Blog

Error 1042: A Worker Cannot Fetch Its Own Zone

My Open Graph checker read every site I pointed it at except the one it runs on. What came back was not HTML, it was the plaintext string error code 1042, and the fix is the static assets binding rather than the compatibility flag the docs point at.

Yunseon Hong9 min read

The Open Graph checker has one job the browser cannot do on its own. Reading another site’s <head> means fetching another origin, and CORS stops that for almost every site worth checking. The QR generator and the PDF tools never leave the tab because they have nothing to go and get. This one does.

So the shape is one server hop and no more. A Worker at /api/og fetches the HTML once and returns the meta tags as JSON. Loading the image, measuring it, and drawing the preview all happen in the browser, where they cost me nothing.

That single hop brought two problems. One of them I had written down myself three weeks earlier, in a different repository, and then walked into again. The other took an afternoon to even locate, because the symptom pointed at the wrong layer.

Cloudflare doc pages quoted here were read on 16 September 2026. Every number below is one measurement, taken on 15 or 16 September, and I say so where it matters.

Every site worked except mine

Paste https://ondelva.com/tools into the checker and it returned 422, not_html. Open the same address in a browser and you get ordinary HTML with fourteen meta tags in it. The symptom and the cause had nothing to do with each other, which is why I spent a while looking in the parser.

The body coming back over fetch is three words of plain text:

error code: 1042

My code read the content type, saw text/plain, and reported the only thing it could see. Cloudflare’s error list says what 1042 is: “Worker tried to fetch from another Worker on the same zone, which is only supported when the global_fetch_strictly_public compatibility flag is used.” It is loop prevention, and it happens above my code.

I drew the boundary too wide at first, and so did the review of my patch. The assumption on both sides was that the zone is the unit, which would mean blog.ondelva.com gets caught as well, since the Korean blog sits on the same zone as a Pages project. I measured it instead of assuming: 200, twelve tags, no error. The gate is not the zone. It is the hostnames routed to this particular Worker, which is ondelva.com and www.ondelva.com and nothing else.

On paper there is even a carve-out. The custom domains page says a same-zone fetch to a Worker running on a Custom Domain “will succeed without a service binding,” and the limitation applies to Workers on routes. Both of my hostnames are custom domains. The Worker fetching itself got 1042 anyway. That is one zone on one afternoon, not a survey, but it is enough to stop trusting the carve-out for the self-fetch case.

The other direction is worth stating plainly, because it would be easy to write this up as a Cloudflare problem. Nothing outside the account sees any of it. An external checker like opengraph.xyz sends a normal HTTPS request from somewhere on the internet and there is no gate in front of it. My checker reads Cloudflare-fronted sites without complaint too: www.cloudflare.com came back with eleven tags, developers.cloudflare.com/workers/ with twelve, and snipp.cc, which is another Worker of mine on a different zone, with ten.

Where the checker's fetch goes: requests to another zone and to a Pages site on the same zone travel over the public internet and come back as HTML, a request to a hostname routed to this same Worker is refused with error code 1042, and the same address read through the static assets binding returns the HTML with no network hop at all target of fetch() goes via result www.cloudflare.com public internet 200, 11 tags blog.ondelva.com (Pages, same zone) public internet 200, 12 tags ondelva.com (this Worker) public internet error code 1042 ondelva.com via env.ASSETS assets layer 200, 14 tags Only hostnames routed to this Worker are refused. The zone is not the unit.

The fix is the assets layer, not the flag in the error message

The error text names its own escape hatch, and the compatibility flags page describes what global_fetch_strictly_public actually does: requests to a Worker’s own zone “will loop back to the ‘front door’ of Cloudflare and will be treated like a request from the Internet, possibly even looping back to the same Worker again.”

Read that as a bill and it stops being attractive. The Workers free plan gives me 100,000 requests a day, shared between the site’s pages and this endpoint, and the whole limiter story below exists to keep that number down. Turning the flag on would send a request out to the public internet, back through the front door, and into the same Worker a second time, to collect a file that is sitting in the deployment I am already running.

The static assets binding hands over that file directly. When the target host is one of mine, env.ASSETS.fetch returns the same HTML with no network hop and no second invocation. The redirects in public/_redirects survive the detour, which I half expected to lose: /abouts still answers 301 to /about through the binding, so the checker’s redirect loop follows it exactly as it would follow anyone else’s.

One case does not survive, and it is the one that would have quietly made the tool lie. Plain http:// addresses get a 301 to HTTPS from the zone, above the Worker. Read those straight from the assets layer and the hop disappears from the report. A checker that hides a redirect a crawler will see has no reason to exist, so the Worker builds that same 301 itself and feeds it back through its own redirect loop.

const self = isSelfHost(current.host, selfHost);
const redirectToHttps = self && current.protocol === "http:";
const response = redirectToHttps
  ? new Response(null, { status: 301, headers: { Location: `https://${current.host}${current.pathname}${current.search}` } })
  : self
    ? await env.ASSETS.fetch(new Request(current, { ...init, signal }))
    : await fetch(current, { ...init, signal });

In production, http://ondelva.com/tools now reports a finalUrl of https://ondelva.com/tools and fourteen tags. Three branches, and the middle one exists only so the report matches what a crawler gets.

The limiter I had already written down and trusted anyway

The second problem is not new, and pretending otherwise would be the dishonest way to tell it. In August I wrote up why Cloudflare’s rate limiting binding cannot guard a daily budget after it let 82 percent of a forty request burst through on my URL shortener. Then I shipped this endpoint with the same binding on it, at twenty requests per sixty seconds per address, and did not think about it again until deploy day.

From one address in Hong Kong: twenty-four sequential requests, all 200. Then forty sequential plus thirty concurrent. Zero 429s across roughly ninety-five requests.

The binding is not broken. Its own docs say the API “is permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system,” and each isolate checks a locally cached counter. I read that sentence a month ago, wrote a post about it, and still reached for the binding as though it were a counter. The mistake is mine, in the same shape, in a second repository.

What replaced it is a rate limiting rule on the zone, matching path /api/og, at about three requests per ten seconds per address with a ten second block. A free zone gets exactly one rule in that phase, with the counting period and the block both fixed at ten seconds and only Path and Verified Bot available as fields, so there is not much to design. The rule sits in the http_ratelimit phase, ahead of Workers, which is the point: a blocked burst never spends an invocation. Its 429 body is configured to match the JSON the Worker returns, so the page cannot tell which layer refused it.

Right after creating it, the first ten requests all passed, because the rule had not propagated yet. Thirty seconds later, in a burst of thirty, the first 429 landed on the sixth request and most of the rest were blocked. Of twenty concurrent requests, nineteen came back 429. Those counters are eventually consistent per data center, so the site copy says “about 3 calls every 10 seconds” rather than a number I would have to defend.

Two things did not come across from the shortener. The daily quota Durable Object stayed behind, because it guards a budget that does not exist here: it protects 1,000 KV writes a day, and /api/og writes nothing. Counting inside the Worker would also arrive too late, since the invocation I want to protect has already been spent by the time my code runs. The other is a hole rather than a layer. Zone rules apply to the zone, so a workers.dev subdomain would have served the same endpoint with no rule in front of it at all. It was already off, and workers_dev: false is now written down rather than inherited.

Making the rule at all needed a permission I did not have. Zone rules cannot be created with a wrangler login, because WAF is not among the OAuth scopes it requests; that list covers things like workers:write and zone:read and stops there. An API token with Zone WAF Edit does it.

Both problems came from the same assumption, which is that the Worker sits on the public internet like any other client. It does not. It sits inside the thing it was trying to call, one layer under the door it was knocking on, and the two failures are what that looks like from the inside: a refusal it cannot read, and a limit it cannot count.

Filed under Build log