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-Language and Content-Length
  • Content-Type and Expires
  • Last-Modified and Pragma

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.

What each browser reported for a 429 without CORS headers, checked October 2026
Browserfetch() rejects withConsole message
Chromium 153TypeError: Failed to fetchAccess 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 155TypeError: 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.6TypeError: Load failedOrigin 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

fetch with retry 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);
  }
}

  1. Retry only what can recover

    Retry on 429, 5xx and TypeError, because a blocked 429 arrives as a TypeError. Anything else, such as an AbortError, is rethrown.

  2. Use the server's wait first

    Retry-After is parsed as seconds or as an HTTP date.

  3. 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 at maxDelayMs.

  4. Spread the retries

    Every wait gets up to 250 ms of extra jitter so many clients don't retry in lockstep.

  5. Give up cleanly

    When attempts run out or Retry-After asks for more than maxDelayMs, 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.

Rate limits through a shared CORS proxy

A CORS proxy calls the API from its own server addresses, not from each visitor's IP. An API that limits per IP therefore counts every site using that proxy together, and a proxy never raises that limit.

Through https://proxy.cors.dev/ on 28 October 2026, the iTunes Search API answered 429 with a 79-byte body naming the rate-limited bucket and the proxy's IPv6 address, with Retry-After of 12 and later 39 seconds; one retry got 403. CoinGecko /api/v3/ping answered 429 with Retry-After of 25 and later 60, and fetchWithRetry got 429 on all 5 attempts over about 100 seconds. A direct request from another IP a moment later returned 200 with Access-Control-Allow-Origin: *.

Where the 429 came from, checked October 2026
SourceStatus and bodyCORS headersRetry-After
CoinGecko direct, Throttled429, 10-byte body ThrottledAllow-Origin *, nothing exposedNone
CoinGecko direct, JSON error429, JSON error bodyNoneSent but unreadable
CoinGecko via proxy.cors.dev429, JSON error bodyOrigin echoed, Retry-After exposed25, then 60
iTunes Search via proxy.cors.dev429, 79-byte text/htmlOrigin echoed, Retry-After exposed12, then 39
cors.dev free limit429, JSON error rate_limitedOrigin echoed, Retry-After exposedSeconds

The proxy does make an upstream 429 readable: cors.dev echoes your origin, exposes Retry-After and sets X-Cors-Source: upstream. Its own free limit is about 600 requests a minute per IP (per /64 for IPv6, approximate, counted per location), and past it you get 429 with Retry-After, X-Cors-Error: rate_limited, X-Cors-Source: gateway and an X-Cors-Signup link.

If an API already sends Access-Control-Allow-Origin, as CoinGecko and GitHub do, call it directly so each visitor uses their own per-IP allowance; keep the proxy for APIs without CORS headers. cors.dev Pro at $5 a month for 500,000 requests (7-day trial with 1,000 requests, no card) allows 3,000 requests a minute per account and 100 in flight, and X-Cors-Cache: 60 can cache a GET for up to 60 seconds (the header takes 1 to 300), but only when the upstream answers 200 and its headers allow caching; a 429 is never cached. Cached responses go only to the Connection key and origin that stored them, caching is best-effort, and hits still count as requests, but repeated calls for the same URL reach the API less often.

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.