GuidesDIAGNOSE
HTTP 429 Too Many Requests in fetch
429 Too Many Requests means the server counted too many requests from you in a time window and wants you to wait, usually the number of seconds or until the date given in Retry-After. In the browser that status is often invisible: on 28 October 2026 CoinGecko's rate-limit 429 carried no Access-Control-Allow-Origin, so fetch() rejected with a TypeError in all 3 engines, and a readable 429 still returns null for Retry-After unless the API exposes it. The free cors.dev proxy (no signup, GET and HEAD, about 600 requests a minute per IP) relays an upstream 429 with Retry-After readable, but its shared addresses share the API's per-IP limit with every other user, so an API that already sends CORS headers is better called directly.
What a 429 response means
RFC 6585 defines 429 Too Many Requests: you sent too many requests in a given amount of time. The response should explain the condition and may include a Retry-After header saying how long to wait. RFC 6585 also says a 429 must not be stored by a cache.
APIs count limits per client IP, per API key or per endpoint, depending on the API. Some signal a rate limit with 403 instead of 429; GitHub is one, covered in GitHub API rate limits.
Retry-After: seconds or an HTTP date
RFC 9110 defines Retry-After as either a number of seconds or an HTTP date. Its examples are 120 and Fri, 31 Dec 1999 23:59:59 GMT. Handle both, and clamp a date in the past to zero, since the result depends on the visitor's clock.
Cross-origin scripts can't read Retry-After by default. The Fetch standard exposes only 7 response headers to them:
Cache-Control,Content-LanguageandContent-LengthContent-TypeandExpiresLast-ModifiedandPragma
Every other header, including Retry-After and X-RateLimit-*, must be listed in Access-Control-Expose-Headers. On 28 October 2026 a 429 with Access-Control-Allow-Origin but no expose list gave response.status 429 and response.headers.get('Retry-After') null in Chromium 153, Firefox 155 and WebKit 26.6 (Playwright 1.63). With Access-Control-Expose-Headers: Retry-After the same call returned "30", or the date string "Wed, 28 Oct 2026 22:19:55 GMT" for the date form.
Why fetch() reports a CORS error instead of 429
Rate limits are often enforced by a CDN or WAF in front of the API, and those 429 responses frequently lack Access-Control-Allow-Origin. The browser blocks the response, so fetch() rejects with a TypeError and your code never sees the status or Retry-After.
CoinGecko shows both shapes. api.coingecko.com/api/v3/ping answers 200 with Access-Control-Allow-Origin: *, but in a burst of 150 requests, 15 at a time, 135 answers were a 429 with a JSON error body, a Retry-After header and no CORS header at all. Another 13 were an application-level 429 with the 10-byte body Throttled, which kept Access-Control-Allow-Origin: * but exposed no headers, so its x-ratelimit-remaining: 0 stayed hidden.
| Browser | fetch() rejects with | Console message |
|---|---|---|
| Chromium 153 | TypeError: Failed to fetch | Access to fetch at 'https://api.coingecko.com/api/v3/ping' from origin 'http://127.0.0.1:8001' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. |
| Firefox 155 | TypeError: NetworkError when attempting to fetch resource. | Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.coingecko.com/api/v3/ping. (Reason: CORS header 'Access-Control-Allow-Origin' missing). Status code: 429. |
| WebKit 26.6 | TypeError: Load failed | Origin http://127.0.0.1:8001 is not allowed by Access-Control-Allow-Origin. Status code: 429 |
Firefox and WebKit print the real status at the end of the line; Chromium's line doesn't mention it. When a call that worked a minute ago suddenly shows a CORS error in Chrome, repeat it in Firefox or Safari and look for Status code: 429 before you touch any CORS settings.
A preflight can be rate limited too: a direct OPTIONS during the burst got the same CORS-less 429. Chromium then reports Response to preflight request doesn't pass access control check, and WebKit says Preflight response is not successful. Status code: 429.
Retry with backoff that honors Retry-After
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function retryAfterMs(response) {
const value = response?.headers.get('Retry-After');
if (!value) return null;
if (/^\d+$/.test(value)) return Number(value) * 1000;
const date = Date.parse(value);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
async function fetchWithRetry(url, options = {}, { attempts = 5, maxDelayMs = 60_000 } = {}) {
for (let attempt = 1; ; attempt++) {
let response = null;
try {
response = await fetch(url, options);
if (response.status !== 429 && response.status < 500) return response;
} catch (error) {
// A 429 without Access-Control-Allow-Origin arrives here as a TypeError
if (error.name !== 'TypeError' || attempt === attempts) throw error;
}
const delay = retryAfterMs(response) ?? Math.random() * Math.min(maxDelayMs, 500 * 2 ** attempt);
if (attempt === attempts || delay > maxDelayMs) return response;
await sleep(delay + Math.random() * 250);
}
}- Retry only what can recover
Retry on
429,5xxandTypeError, because a blocked429arrives as aTypeError. Anything else, such as anAbortError, is rethrown. - Use the server's wait first
Retry-Afteris parsed as seconds or as an HTTP date. - Fall back to full jitter
Without a readable
Retry-After, wait a random time up to 500 ms times 2 to the power of the attempt, capped atmaxDelayMs. - Spread the retries
Every wait gets up to 250 ms of extra jitter so many clients don't retry in lockstep.
- Give up cleanly
When attempts run out or
Retry-Afterasks for more thanmaxDelayMs, the last response is returned for your UI to handle.
On 28 October 2026, against an endpoint that answered 429 with Retry-After: 1 twice, fetchWithRetry returned the 200 on the third attempt after about 2.2 seconds in Chromium 153, Firefox 155, WebKit 26.6 and Node 22. A 429 with Retry-After: 30 and maxDelayMs of 10000 came back immediately, and a 429 without CORS headers threw the TypeError after all 5 attempts.
Retry only idempotent requests such as GET and HEAD automatically. Retrying a POST can repeat its side effect.
Good questions.
Why does response.headers.get('Retry-After') return null when DevTools shows the header?
Retry-After is not one of the 7 CORS-safelisted response headers, so a cross-origin script can read it only when the server lists it in Access-Control-Expose-Headers. Without that, response.headers.get('Retry-After') returns null with no error. The same rule hides X-RateLimit-Remaining and similar headers.
How do I tell whether a CORS error in Chrome is really a 429?
Chromium's console line names only the missing Access-Control-Allow-Origin header. Repeat the request in Firefox or Safari: their console lines end with Status code: 429 when a rate limit is the cause.
Should fetchWithRetry wrap POST requests?
Only when the endpoint is idempotent. An automatic retry of a plain POST can create the same order, message or record twice, so keep automatic retries for GET and HEAD.
How do I know whether the 429 came from cors.dev or from the API?
Read X-Cors-Source. upstream means the target API sent the 429, and gateway with X-Cors-Error: rate_limited means you passed cors.dev's own limit of about 600 requests a minute per IP on the free proxy.
Does a CORS proxy get around an API's rate limit?
No. The API sees the proxy's server addresses instead of your visitors' IPs, so an IP-based limit is shared by everyone using that proxy and is usually reached sooner. When the API sends CORS headers, call it from the page directly so each visitor gets their own allowance.