In a local limiter fed SambaNova's free-tier row, with each call counted as 20,000 tokens, the first HTTP 429 was request 11. Ten calls spend the 200,000-token day that row publishes.
I had been treating "20 RPM" as "sleep for three seconds and you will not be refused." That sleep keeps a client inside the requests-per-minute cell. It does nothing for a token cap that fills up first. The harness is a local clock and a counter. It did not call SambaNova, OpenRouter, Cohere, or Cerebras. The GitHub burst is the only live, observed result.
This provider half is a documented comparison: I copied published caps on 29 October 2026 and ran them through one admission rule. The evidence label on the GitHub burst is tested. Those two should not get mixed. A number in the tables below is either a cell from a docs page, a parameter I set (500 tokens, 20,000 tokens, 40 jobs), or a field in experiments/free-tier-429-results.json.
A three-second sleep only protects the RPM cell
The job I care about is a loop of N calls. The page gives me requests per minute, sometimes requests per day, sometimes tokens per minute or per day. The first refusal is the minimum of those budgets, not the RPM cell on its own. Google's rate-limit page, last updated 2026-10-28 UTC, says it with an example rather than a free-tier quota: if your RPM limit is 20, making 21 requests within a minute will result in an error, even if you have not exceeded TPM. I did not model a Gemini free-tier number, because that page no longer prints one. It tells you to read the live limits in AI Studio. I did not open AI Studio.
Groq's rate-limit page prints a Free Plan table (openai/gpt-oss-120b: 30 RPM, 1K RPD, 8K TPM, 200K TPD); I left it out to keep the comparison to four rows.
The harness has two ways to enforce a per-minute cap, because most of these pages do not say which one they use. A sliding window holds every accepted call for 60,000 ms. A token bucket starts full and refills continuously, at cap / 60,000 per millisecond. Day and month caps are fixed windows from t = 0, a UTC day if you start at midnight, except on Cerebras in bucket mode. That page describes continuous replenishment, so in bucket mode I let their hourly and daily token caps drip as well. Rejected calls do not consume quota. Service time is 0. The virtual clock is the whole point: I wanted the quota math, not a round trip.
Five clients share that clock.
| Strategy | What it does |
|---|---|
| Naive burst | Sends immediately. After a 429, retries every 50 ms and ignores headers. |
| Fixed sleep | Waits ceil(60000 / RPM) ms between attempts. For 20 RPM that is 3,000 ms. |
| Exponential backoff | After a 429, waits min(60000, 200 * 2^k) ms plus a jitter of 0 to 199 ms. Seed is 1. |
| Header-aware | Sends until a 429, then sleeps Retry-After, which the model sets to max(1, ceil(wait / 1000)) seconds. |
| Token budget | Does not send a call it can already predict will be refused. Sleeps until the same limiter would accept it. |
The finish runs ask for 40 successful jobs. Forty is past an RPM of 20 and past SambaNova's 20 requests per day, and it is under OpenRouter's 50 per day and Cohere's 1,000 calls per month, so those wider caps do not stop a paced OpenRouter or Cohere run. SambaNova's day does.
Four pages, and only the cells they print
A missing cell is not a zero and it is not "unlimited." The harness simply does not enforce a dimension the page does not publish. K in a printed cell expands as 1,000 and M as 1,000,000. That expansion is in the script, because Cerebras prints 30K, 90K, and 1M rather than digits.
| Provider | What I modeled | RPM | Per-minute tokens | Per-day or other | Page, as of 29 Oct 2026 |
|---|---|---|---|---|---|
OpenRouter :free |
ids ending in :free, under 10 credits purchased |
20 | not published | 50 requests per day | Limits |
| SambaNova free | Meta-Llama-3.3-70B-Instruct, no payment method |
20 | not published | 20 requests per day, 200,000 tokens per day | Rate limits |
| Cohere trial | Command A chat, trial key | 20 | not published | 1,000 API calls a month | Rate limits |
| Cerebras Free Trial | gpt-oss-120b |
5 | 30,000 uncached, 90,000 total | 1,000,000 tokens per hour, 1,000,000 per day | Rate limits |
OpenRouter's table has a second row, 20 per minute and 1,000 per day, once the account has purchased at least 10 credits. I did not run that row. The page also says the higher daily ceiling starts one credit below the printed threshold, currently 9, to absorb fees. SambaNova's free heading says the tier is applied when there is no payment method linked with the account. The same 20 / 20 / 200,000 numbers are printed on the other free rows. I read each row as its own cap. The page does not say whether two model ids share a pool.
Cohere says trial keys are limited to 1,000 API calls a month, and the Command A trial cell is 20 req / min. There is no TPM and no RPD. Cerebras is the awkward one. The Free Trial table is the only place in this set with a per-minute token cap, which is why the first 429 moves the furthest, and the same page's FAQ says new accounts receive $5 in free credits after adding a verified payment method. It also says there is no permanently free tier. I did not add a card, and I am not calling that row a no-billing allowance. It is a published trial table, modeled because it is the one that states both RPM and TPM.
Request 11, request 21, request 2
A cold burst sends at t = 0 until the limiter refuses. Sliding and bucket agreed on every row below. They start from a full window, so the first refusal does not depend on how the window refills. It depends on which cap is smallest.

| Provider | Tokens per call | RPM + 1 | Simulated first 429 | Successes before it | Reasons |
|---|---|---|---|---|---|
OpenRouter :free |
500 | 21 | 21 | 20 | rpm |
OpenRouter :free |
20,000 | 21 | 21 | 20 | rpm |
| SambaNova free | 500 | 21 | 21 | 20 | rpm, rpd |
| SambaNova free | 20,000 | 21 | 11 | 10 | tpd |
| Cohere trial | 500 | 21 | 21 | 20 | rpm |
| Cohere trial | 20,000 | 21 | 21 | 20 | rpm |
| Cerebras trial | 500 | 6 | 6 | 5 | rpm |
| Cerebras trial | 20,000 | 6 | 2 | 1 | tpm |
The headline object the script writes, and checks before it exits, is:
{
"provider": "sambanova-free",
"tokens": 20000,
"attempt_of_first_429": 11,
"successes_before_429": 10,
"reasons": ["tpd"],
"rpm_plus_one": 21
}
The arithmetic is the table, not a separate discovery. SambaNova's free row allows 200,000 tokens in the day. Ten calls of 20,000 land exactly on that cap. The eleventh would make 220,000, so the limiter returns 429 and records tpd. RPM is 20 and RPD is 20, and ten is under both, so neither of those reasons fires. The printed 20 RPM would have you look for the refusal on request 21. Request 21 is what you get when the token size cannot fill the day first. At 500 tokens, ten calls are 5,000 tokens, the day still has room, and the burst dies on request 21 with both rpm and rpd, because those two cells are the same number. A status code will not tell you which line you hit. The harness records both.
OpenRouter and Cohere have no per-minute or per-day token cell, so 500 and 20,000 are the same burst: 20 successes, request 21, reason rpm. Cohere's monthly 1,000 never appears. Forty jobs, and even a burst of 21, are nowhere near it. Cerebras at 500 tokens is the Google-shaped example with a smaller RPM: five successes, request 6, reason rpm. Five times 500 is 2,500, under the 30,000 uncached token cap, so TPM does not get a vote. At 20,000 uncached tokens the first call fits (20,000 is under 30,000) and the second would be 40,000, so request 2 is the 429 and the reason is tpm. Total TPM is 90,000 and did not bind. RPM of 5 never got a chance to be the thing that stopped the loop.
Same 30K TPM, and the finish time still splits
The cold burst cannot tell a sliding window from a bucket. The finish time can. Cerebras documents continuous replenishment. I still ran their 30,000 uncached TPM through both mechanisms, with the token-budget client, 40 jobs, 20,000 tokens each, cache hit rate zero.

The sliding run took 2,340,000 ms. The bucket run took 1,540,000 ms. That is 800,000 ms, and both rows list wasted_calls: 0. Header-aware matched those two clocks and spent 39 rejected calls on each, one per job after the first.
Why the clocks differ: two calls of 20,000 tokens cannot share a 30,000 token minute, so the sliding window allows one call, then holds those tokens for the whole 60 seconds. Thirty-nine gaps of 60,000 ms is 2,340,000 ms. The bucket starts at 30,000, spends 20,000 on the first call, and has 10,000 left. The remaining 39 calls need 780,000 tokens. Subtract the 10,000 already banked and 770,000 have to refill. The cap refills at 30,000 per 60,000 ms, which is 0.5 tokens per millisecond. 770,000 / 0.5 is 1,540,000 ms. The stranded 10,000 in each sliding minute never becomes the next call. The bucket saves them up until a call fits. I would trust the 1,540,000 ms figure only for Cerebras, and only because that page describes continuous refill. For the other three providers the bucket-versus-sliding split is my assumption about the minute window, labeled as such in the results file.
Wasted calls are the part the RPM cell leaves out
OpenRouter, 500 tokens, sliding window, 40 jobs. The 20,000-token OpenRouter rows are the same numbers. There is no token cap to make them differ.
| Strategy | Finished | Wasted calls | Elapsed ms | Simulated first 429 |
|---|---|---|---|---|
| Naive burst | 40 | 1,200 | 60,000 | 21, rpm |
| Fixed sleep | 40 | 0 | 117,000 | none |
| Exponential backoff | 40 | 9 | 103,226 | 21, rpm |
| Header-aware | 40 | 1 | 60,000 | 21, rpm |
| Token budget | 40 | 0 | 60,000 | none |
The script's throughput field divides finished jobs by elapsed minutes. On the naive, header-aware, and token-budget rows that ratio is 40. It is an opening burst of 20 at t = 0 plus another 20 at t = 60,000, not a sustained 40 per minute. The cap is still 20. Fixed sleep never takes the opening burst, so it needs 39 gaps of 3,000 ms, which is 117,000 ms, and the ratio falls to 20.513. It also records no 429 at all.
Naive waste is 60,000 / 50. One minute of 50 ms retries, then every one of the original 20 events falls out of the window together and the next 20 succeed at the same timestamp. Header-aware on this sliding window burns a single 429, sleeps 60 seconds, and bursts the rest. Token budget waits that same 60 seconds without the rejected call.
Switch the minute mechanism to a bucket and header-aware stops looking cheap. After the opening 20, the bucket refills one request every 3,000 ms. Header-aware only learns the wait by getting the 429, so each of the next 20 jobs costs one rejected call. The row is wasted_calls: 20, elapsed_ms: 60000. Same clock as the sliding header-aware run, twenty times the waste. Token budget on that bucket still finishes in 60,000 ms with zero waste, because it sleeps the 3,000 ms before it sends. Exponential backoff, seed 1, landed at 9 wasted calls and 103,226 ms on the sliding OpenRouter run, and at 76 wasted calls and 61,398 ms on the bucket. I am not going to narrate the jitter sequence. The seed and the base are in the results file.
SambaNova is where a polite RPM sleep still fails. Fixed sleep, 500 tokens, first 429 on attempt 21 at t = 60,000, reason rpd only. The 3,000 ms gap had already cleared the minute. The day had not. At 20,000 tokens the same client is refused on attempt 11 at t = 30,000, reason tpd. Request 11 is at (11 - 1) * 3000 ms. Ten accepted calls have already spent 200,000 tokens.
Finishing all 40 of those heavy SambaNova jobs takes four days of the cap, ten per day. Token budget, header-aware, and the naive burst all record elapsed_ms of 259,200,000, which is three days after t = 0: each day's tenth call sits on the boundary, and the next ten wait for the reset. Token budget wastes nothing. Header-aware wastes 3, one refusal at each reset, then a Retry-After that skips the rest of the day. Naive wastes 5,184,000 calls: three gaps of 86,400,000 ms, retried every 50 ms, and 3 * 1,728,000 is 5,184,000. The throughput field on that naive row is 0.009 jobs per minute. The calls finished. The client spent the day hammering a cap it could have read.
The live check returned 403, and the counter jumped
I wanted one dataset that was not a model. GitHub's REST rate-limit docs say the primary rate limit for unauthenticated requests is 60 requests per hour, and that exceeding it returns a 403 or a 429, with x-ratelimit-remaining at 0. They also say remaining can be higher on a later response than on an earlier one, because requests are processed in more than one region. I sent no token. User-Agent was clearcontext-rate-limit-study. The archive is experiments/github-unauth-rate-limit.json, written at 2026-10-29T12:14:38.138Z.
GET /rate_limit came back 200. The core object said limit 60, remaining 58, used 2, reset 1793279043, which is 2026-10-29T13:04:03.000Z. The Date header was Thu, 29 Oct 2026 12:14:36 GMT. GitHub documents that this endpoint does not consume the primary limit. I had made one request in this process. The body already said used 2. I cannot tell what the other count was.
Then 60 calls to GET /zen. The sum of the archived elapsed_ms fields is 840. The first body was Mind your words, they are important. Its headers were not a continuation of the snapshot: remaining 43, used 17, reset 1793276890, which is 2026-10-29T12:28:10.000Z. The second call returned to reset 1793279043, with used 3 and remaining 57. From there, used climbed by one on each success: 3, 4, 5, up to 60. That 58th success on the original reset was still HTTP 200, with remaining 0. Call 60 of the run was HTTP 403, remaining 0, used 60, retry-after absent. The message was:
API rate limit exceeded for [runner IP]. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)
Count it against the snapshot rather than against "my 60th request." Used was already 2, remaining 58. I then received 58 more 200s on that same reset, which takes used from 2 to 60, and the next call was refused. One extra 200, the first /zen, landed on a different reset and a different used count. Every x-ratelimit-limit header in the run was 60. None of the statuses was 429. I stopped on that 403. The same docs page says that continuing while you are limited can get an integration banned, and one refusal was the observation.
So the live half does not say "the first 429 arrives at request 60." It says the published 60 is the window size on the header, the refusal I actually got was a 403, and the counter I had just read was not the counter that answered the next call. That is a similar lesson, in a messier form. The number in the docs is real. It is not the index of your next refusal unless you know which window you are in and which cap will fill first.
The last time a refusal ate a result I had not written down, the re-run was the thing that failed. That is the whole of the unarchived test post. This time the headers are in the repo. I am not going to spend the rest of the hour proving I can get a second 403.
What the run does not settle
I did not send a prompt to any of the four model APIs, and I do not know their real bucket code beyond the Cerebras sentence I followed. If a vendor counts rejected calls against the daily cap, my wasted-call totals are a lower bound. The 500 and 20,000 token sizes are inputs I chose so that one workload would fit under TPM and one would not. They are not measured tokenizations. SambaNova may pool free models. I read the table as one row, one cap. The GitHub sample is one IP, one burst, stopped at the first non-2xx. A later call in the same window will not reproduce the climb from used 3 to used 60.
Reproduce it
node experiments/free-tier-429-harness.mjs
That rewrites experiments/free-tier-429-results.json and exits non-zero if the SambaNova heavy burst is not request 11 with reason tpd. The figures are drawn from that file:
node scripts/post-images.mjs first-429-is-request-11
The GitHub archive is the file above. Regenerating it is:
node experiments/github-unauth-rate-limit.mjs
Do that on a fresh unauthenticated window if you want the climb. On a spent window the first /zen is the 403.
I would size the burst from the tightest cell, not from RPM. Divide the token cap by the tokens you actually send. On this row, 200,000 / 20,000 is 10, so the call to fear is request 11, and a three-second sleep will still deliver it. Where the page has no token column, a cold burst dies at RPM + 1, which was request 21 for OpenRouter and Cohere and request 6 for the small Cerebras job. If you can predict the wait, sleep before the call. If you only have Retry-After, honor it, and expect one rejected call per refilled slot when the server is a bucket. If the API sends a counter, trust the headers on the response you just received. The previous response may have been a different host.
