Every response promised a rate limit. Nothing enforced it.
2026-07-18
A response header advertised a request limit that no code enforced. The investigation separates a published policy, the code and what a probe can show.
One function in this site's Worker attaches security headers to every response it renders. Until yesterday two of them were RateLimit-Limit: 100 and RateLimit-Policy: "default";q=100;w=60. They went out with the homepage, with all 24 guides, with every markdown twin and with every 404. This site also publishes a guide on response headers for agents, and that guide teaches the RateLimit family on the reasoning that a well-behaved agent reads the declared budget and throttles itself before anyone has to throttle it.
No code enforced either number. The Worker had no rate limiting logic and no path that returned a 429, and the config declared no limiter. The 100 had never been connected to a counter, so every response was advertising a budget the server had no way to spend. It surfaced while I was preparing answers to the hardest questions this site could be asked, which is the only reason it surfaced at all.
That is the exact defect this business sells finding. A declared surface that no code resolves is the first thing an agent-readiness audit looks for, and an agent polite enough to trust the header would have been rationing itself against a limit that lived only in text.
What enforcement looks like
Cloudflare's Workers rate limiting binding does the work now. The configuration is a block in wrangler.jsonc naming a limiter with a simple limit of 100 requests per 60 seconds, so the config carries the same numbers the header had been promising on its own. At the top of the fetch handler the Worker calls limit() keyed on the client IP, and past the limit it returns 429 with Retry-After: 60, built by the same security header function as every other response, so the rate limit header rides on the 429 itself.
It fails open, deliberately. If the binding is missing or limit() throws, the request is served normally, because a limiter that takes the site down when its own plumbing breaks is a worse trade than a burst that gets through. That choice has a cost this post comes back to: under a probe that stays below the refusal threshold, a guard that has failed open and a guard doing its job both return 200, so from the outside they look the same, and only the logs or the code can tell them apart.
The key is the client IP, and Cloudflare advises against that. Their best practices say it plainly: "It is not recommended to use IP addresses or locations (regions or countries), since these can be shared by many users in many valid cases." They are right. The identifiers they recommend are stable properties of a caller, an API key in an Authorization header or a user or tenant ID, and none of those exist here. This site is public documentation with no accounts and no login, so the IP is what is left, and the cost is real. Several agents behind one corporate proxy or one mobile NAT share a single budget of 100, and the one that gets refused may be the one that asked politely.
The test that found nothing
Reading the code is what caught this. That finding needed no instrument: no limiter logic anywhere, nothing that could say 429. On the same morning a probe seemed to corroborate it, 130 requests in about six seconds, zero 429s back. I would have quoted the probe over the code reading in a heartbeat, because it has a number in it and numbers travel.
The probe proves nothing, and that is now measured rather than suspected. I ran the same burst against the fixed site, enforcement live, from a network that had never touched it. 130 requests, ten in parallel, and all 130 came back 200 with not one 429. The identical result the broken site gave. In the larger burst below, not one of the first 130 requests was refused either. In these runs, 130 requests did not reach the point where this platform started refusing, so the working site and the absent limiter answered that probe in the same voice.
A test that returns the same answer whether or not the thing is broken is not weak evidence. It is not evidence. It is worse than no number at all, because a number gets quoted, and this one would have been quoted by me. The code reading found the defect. The curl agreed with it by coincidence.
Cloudflare documents the looseness
The binding's documentation says, in a section titled Accuracy, that the API "is permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system." The Performance section above it explains why: the counters are cached on the machine the Worker runs on and updated asynchronously against a backing store in the same Cloudflare location, which is how limit() costs no meaningful latency. And the counters are local. For each key there is a separate limit per Cloudflare location, so 100 per 60 seconds is a budget per IP per location, never a global one.
Measured from here, permissive looks like this. A parallel burst of 300 requests, ten at a time, returned 279 responses of 200 and 21 of 429. That run tagged each request with its index, so it could see where the refusals sat: all of them pooled at the end, indexes 240 through 299. A second run from another network split 281 and 19. A single request sent straight after the burst was refused on one network and served on the other, which is eventual consistency behaving exactly as advertised, and why the Retry-After: 60 on the 429 states the minimum wait RFC 9110 defines, not a measured one.
The contrast that stings is local. A wrangler dev run enforces the limit exactly, 100 requests pass and the rest are refused, so the environment where you would naturally verify your own code is the one environment that behaves nothing like production.
There is also a slower path to the limit, and our first test walked toward it without knowing. The budget refills at 100 per 60 seconds, a little under two requests a second. A sequential loop at three per second drains faster than the refill, so on a bucket model it would meet its first refusal somewhere past request 220. Ours stopped at 115 requests and 38 seconds, saw nothing but 200s, and I misread that as a broken deploy and asked for a second one. The deploy had been fine. The measurement was too small to say anything in either direction, and the possibly wasted deploy is part of this story's bill.
If you want to test it
In the runs above, a slow loop and a burst of 130 requests both came back all 200s, on the Cloudflare binding measured here, and neither could tell an enforced limit from an absent one. Counters in another location or with other traffic can behave differently, and a different limiter could draw a refusal at a different size. What reached the limit in my runs was a parallel burst big enough to outrun the counters:
seq 300 | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" https://turva.dev/ | sort | uniq -c
Expect most of the burst to pass and a tail of it to be refused. My two runs split 279 to 21 and 281 to 19. Yours will be a third pair of numbers, because you will be filling a counter in your own Cloudflare location rather than in mine. The shape repeated in my two runs, the arithmetic did not. And if a burst of 300 gets you no 429 at all, I want to hear about it, because that is either the fail-open path hiding a broken binding or a behavior I cannot presently explain. The address is info@turva.dev.
One header was from a retired revision
The guide on this site names the current pair of fields, RateLimit and RateLimit-Policy. The code was sending RateLimit-Limit and RateLimit-Policy. Before touching anything I went to the IETF archive to check which surface was right, and the answer was unambiguous. Revision 11 of draft-ietf-httpapi-ratelimit-headers, the active revision from May 2026, defines exactly two fields, RateLimit-Policy and RateLimit. RateLimit-Limit belongs to the early revisions, and where revision 11 mentions it at all is inside a section whose own heading says it is to be removed before publication as an RFC, in a survey of the legacy header names the draft is trying to replace. The site was sending one field from the current draft and one from a retired lineage in the same response. The guide had been right all along. Only the code was wrong.
The fix shipped yesterday: RateLimit-Limit is gone and RateLimit-Policy stays. The field the current draft does define, RateLimit, was deliberately not added. Revision 11 makes its r parameter, the remaining quota, required, and Cloudflare's limit() returns a success boolean and nothing else, no remaining and no reset, so sending RateLimit would mean inventing the very number the field exists to carry. The draft also says the policy field alone lets a client control its own flow of requests, and positions the RateLimit field for limits that are highly dynamic. This limit is a static 100 per 60 seconds. For a static limit, RateLimit-Policy alone is the correct form.
What to take from it
A declared limit is a claim about behavior, and claims about behavior rot silently, because nothing breaks when they do. The check that catches this class of defect is reading the code. A probe smaller than the burst sizes measured here did not catch it in these runs, because on an eventually consistent platform it returns the same comfortable 200s for a working guard, for a missing one and for one that has failed open. A large parallel burst can draw refusals, as the runs above did, and it still does not show which line of code refused. If a header on your site promises something, the interesting question is not whether the value looks sensible. It is which line of code makes it true.
If you want your own agent-facing claims read the way a skeptic would read them, an audit is what I do. Email info@turva.dev.
Corrected 2026-09-27. A sentence said probing the endpoint cannot catch this defect. The larger bursts in this post did draw refusals, so it now says a small probe cannot, and that a burst still does not show which code refused. No number changed.
Corrected 2026-09-28. Two sentences said a slow loop or a small probe tells you nothing about this rate limit, worded as true of any limiter. Both now name the 130 and 300 request burst sizes this post measured and the platform's eventually consistent counters behind them. No number changed.
Corrected 2026-09-28. Two more sentences stated a general law rather than an observation. One now says a probe smaller than the measured burst sizes did not catch the defect in these runs, and the other names the Retry-After value as the minimum wait RFC 9110 defines rather than a measured one. No number changed.
Corrected 2026-09-29. One more sentence said 130 requests never reach the point where this platform starts refusing. It now says that is what happened in these runs.
Corrected 2026-09-29. The test section stated the all-200 result for a slow loop and a hundred requests as a general rule. It now says that is what my slow loop and my burst of 130 returned, and that counters in your own location can differ. No number changed.
Corrected 2026-09-29. A sentence said that from the outside a guard that has failed open is indistinguishable from a guard doing its job. It now says that under a probe that stays below the refusal threshold both return 200, so they look the same from the outside, and that the logs or the code tell them apart. No number changed.