Short answer: 429 Too Many Requests means a rate limit fired. The server counted your requests over a time window and refuses the rest until the window clears, so it is deliberate, not a crash. If you run the server, find the limit that fired (nginx limit_req, your application or Cloudflare) and tune it so real users get through. If your script or bot gets the 429 from someone else’s API, slow down and wait as long as the response tells you, usually in a Retry-After header.
curl -s -D - -o /dev/null https://example.com/api/items
# look at these response headers:
# server: nginx, cloudflare, or the API's own name
# retry-after: seconds (or a date) to wait before the next try
# x-ratelimit-*: the API's own counters (Discord, GitHub and many others)What does 429 Too Many Requests mean?
RFC 6585 defines the code. The response should explain the condition and may carry a Retry-After header, but nothing forces the server to send one, and caches must not store a 429. The spec leaves counting to the server: per IP address, per API key, per account, per route, or several of these at once. So one client can be under one limit and over another at the same time.
Two variations confuse people. nginx’s own limiter answers 503 by default, not 429, unless you set limit_req_status 429, so a “503 Service Unavailable” under load can be the same limit. And some APIs, GitHub among them, answer a rate limit with 403 or 429 and say so in their x-ratelimit-remaining: 0 header.
| Source | How you recognise it | Where to look |
|---|---|---|
| nginx limit_req / limit_conn | Small HTML page, Server: nginx (or a 503 if limit_req_status is not set) | limiting requests or limiting connections in the nginx error log |
| Your application's own limiter | JSON or an app-styled page; nginx's error log stays quiet | The application log |
| Cloudflare rate limiting rule | Cloudflare page: Error 1015, You are being rate limited | The security event log in the Cloudflare dashboard |
| Someone else's API | x-ratelimit-* headers, a retry_after field in the JSON body | Your script's or bot's own log |
Is it your nginx? Check the error log
nginx logs every rejected request at the error level by default, naming the zone that rejected it:
sudo grep 'limiting requests' /var/log/nginx/error.log | tail -n 5
# a typical line:
# [error] 2216#2216: *33 limiting requests, excess: 4.964 by zone "api",
# client: 203.0.113.50, server: example.com, request: "GET /api/items HTTP/1.1"The zone name tells you which limit_req_zone fired; excess is how far over the configured rate the client was when nginx refused it. Then see who gets the 429s, and on which URLs. In the default combined log format the status is the ninth field:
sudo awk '$9 == 429 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
sudo awk '$9 == 429 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | headIf the top “clients” are a handful of Cloudflare or load-balancer addresses, skip to step 2 below. If they are your own monitoring, a payment provider’s webhooks or your office, see step 3.
How do I fix 429s from nginx limit_req?
The causes, most common first:
- 1Add a burst: a limit without one rejects normal page loads
nginx tracks requests at millisecond granularity, so
rate=10r/sreally means one request every 100 ms. A browser loading one page fires a dozen requests at once, and with noburstevery one after the first in that 100 ms window is rejected. On a test server, three parallel requests against a 10r/s zone without a burst returned 200, 429, 429./etc/nginx/conf.d/ratelimit.conf (http context) limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s; limit_req_status 429; # the default is 503 limit_conn_status 429; # same for limit_conn, if you use itin your server block location /api/ { limit_req zone=perip burst=20 nodelay; proxy_pass http://127.0.0.1:3000; }burst=20lets a client go 20 requests over the rate before nginx refuses, and the slots refill at the configured rate. Withnodelaythose extra requests are answered at once; without it nginx queues them and releases them at the rate, so visitors see slow responses instead of errors.delay=8mixes both: the first 8 extra requests pass at once, the rest are spaced out. A 10 MB zone is plenty for most sites: on 64-bit systems one megabyte holds about 8,000 client states. - 2Make sure nginx sees the real client IP
Behind Cloudflare, a load balancer or any other proxy,
$binary_remote_addris the proxy’s address, so all your visitors share a few buckets and hit the limit together. Restore the visitor’s IP with the realip module:/etc/nginx/conf.d/realip.conf set_real_ip_from 173.245.48.0/20; # ... one line for every range on https://www.cloudflare.com/ips/ real_ip_header CF-Connecting-IP;Only list addresses you really proxy through, or anyone can forge the header to dodge the limit. The full Cloudflare setup is in the nginx reverse proxy guide. Offices and mobile carriers behind carrier-grade NAT also share one address legitimately, so keep per-IP limits generous on public pages and strict only on logins.
- 3Limit only what needs it, and exempt your own tools
A
limit_reqat server level also covers images, CSS, health checks, uptime monitors and webhooks. Put strict limits on login, search and API routes, and let static files through. To exempt known addresses, use the fact that nginx does not count requests whose key is empty:/etc/nginx/conf.d/ratelimit.conf (http context) geo $rate_exempt { default 0; 198.51.100.10 1; # your monitoring 10.0.0.0/8 1; # internal network } map $rate_exempt $rate_key { 0 $binary_remote_addr; 1 ""; # empty key: not counted } limit_req_zone $rate_key zone=perip:10m rate=10r/s;One inheritance rule catches people out: a location inherits
limit_reqfrom the server block only if it has nolimit_reqof its own. Add one to a location and the server-level limits no longer apply there. - 4Measure before you enforce
Dry-run mode counts and logs without rejecting anything, and the
$limit_req_statusvariable shows what would have happened. Both arrived in nginx 1.17 in 2019, so every current Debian and Ubuntu package has them:dry run for a day, then count # http context log_format ratelog '$remote_addr [$time_local] "$request" $status $limit_req_status'; # in the location you are testing limit_req zone=perip burst=20 nodelay; limit_req_dry_run on; # an access_log here replaces the inherited one, so list the normal log too access_log /var/log/nginx/access.log; access_log /var/log/nginx/ratelimit.log ratelog; # later: grep -c REJECTED_DRY_RUN /var/log/nginx/ratelimit.log - 5Tell clients how long to wait
nginx sends no
Retry-Afteron its own. Route the 429 to a named location that adds one, so well-behaved clients wait the right time instead of hammering:in your server block location /api/ { limit_req zone=perip burst=20 nodelay; error_page 429 @ratelimited; proxy_pass http://127.0.0.1:3000; } location @ratelimited { add_header Retry-After 10 always; default_type application/json; return 429 '{"error":"rate_limited","retry_after":10}'; }apply sudo nginx -t && sudo systemctl reload nginx
What if nginx’s error log is quiet?
Then the 429 comes from somewhere else. If the body is your application’s JSON, its own limiter fired, and the same IP problem from step 2 applies one layer down. Express, for example, sees every request as coming from nginx until you set app.set('trust proxy', 1), and nginx has to send X-Forwarded-For for that to work.
If the page shows Cloudflare’s Error 1015, a rate limiting rule in the Cloudflare dashboard blocked the request before it ever reached your server, which is why your logs show nothing. Those rules answer 429 by default, and you can set any code from 400 to 499. If real visitors trip one, raise its request count or lengthen its counting period rather than deleting it; Cloudflare’s own 1015 troubleshooting page warns that one-second windows are too strict and points to 10 seconds or more.
Getting 429 from someone else’s API? Back off properly
- Stop at once. Requests sent while you are limited are refused anyway, and some APIs count them against you. Discord counts most 429s as invalid requests.
- Wait as long as the server says.
Retry-Afterholds either a number of seconds or an HTTP date. Many APIs add their own hint: Discord’sretry_afterin the JSON body, Telegram’sparameters.retry_after, GitHub’sx-ratelimit-resettimestamp. - No hint? Back off exponentially, with jitter. Wait a random time between zero and
base × 2^attempt, capped. The randomness stops many clients from retrying in lockstep and tripping the limit again together. - Cap the retries and fail loudly, so a stuck job does not loop forever.
# retries timeouts and 408, 429, 500, 502, 503, 504 (newer curl also 522, 524) up to
# 5 times, doubling the wait from 1 s, and honours Retry-After (curl 7.66.0 and later)
curl --retry 5 --retry-max-time 120 -sS https://api.example.com/itemsimport requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(
total=5,
status_forcelist=[429, 503],
backoff_factor=1, # 0 s, 2 s, 4 s, 8 s ... when no Retry-After is sent
respect_retry_after_header=True, # the default, shown for clarity
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
resp = session.get("https://api.example.com/items", timeout=30)urllib3 does not retry POST by default, which is right: repeating a POST can repeat its effect unless the API is built for it. When the retries run out, requests raises RetryError. Writing the loop yourself is short too:
import random, time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
def retry_after_seconds(resp):
value = resp.headers.get("Retry-After")
if value is None:
return None
if value.strip().isdigit(): # "120"
return int(value)
try: # "Wed, 21 Oct 2026 07:28:00 GMT"
when = parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
def get_with_backoff(url, max_tries=6, base=1.0, cap=60.0):
for attempt in range(max_tries):
resp = requests.get(url, timeout=30)
if resp.status_code not in (429, 503) or attempt == max_tries - 1:
return resp
wait = retry_after_seconds(resp)
if wait is None:
wait = random.uniform(0, min(cap, base * 2 ** attempt))
time.sleep(wait)Why does my Discord or Telegram bot get 429?
Discord limits each route separately, in buckets, plus a global ceiling of 50 requests per second per bot. Responses carry X-RateLimit-Limit, -Remaining, -Reset, -Reset-After and -Bucket; a 429 adds Retry-After, X-RateLimit-Scope and a JSON body with retry_after in seconds (with decimals) and a global flag. discord.py and discord.js queue requests per bucket and wait out a 429 by themselves; discord.py logs a We are being rate limited warning when it does. Seeing that now and then is fine. Seeing it constantly means your code fights the limit:
- A loop that edits the same message, or changes roles or nicknames, many times a second.
- Mass DMs or bulk channel operations without spacing.
- Several processes running with the same token. Each library instance only tracks its own requests, so together they overrun the buckets.
- A crash loop that registers slash commands on every start. Discord allows 200 command creates per day per guild.
Telegram returns the wait in the body:
{"ok": false, "error_code": 429,
"description": "Too Many Requests: retry after 35",
"parameters": {"retry_after": 35}}Telegram’s bot FAQ asks you to stay at about one message per second in a single chat, at most 20 messages per minute in a group, and about 30 messages per second across all chats when you broadcast, spreading large mailings over hours. python-telegram-bot ships an optional limiter for this: install python-telegram-bot[rate-limiter] and pass AIORateLimiter() to the application builder.
Where the bot runs matters too. Discord applies its invalid-request block per IP, so on a free host where many bots share one address, other people’s mistakes count against you. A small VPS with its own IPv4 keeps that counter yours; the Discord bot hosting page has the plans, and the bot hosting guide covers the systemd setup.
How do I keep 429s from coming back?
- On your server: set
limit_req_status 429, give every limit a burst, key it on the real client IP, limit logins and APIs rather than assets, dry-run before enforcing, and sendRetry-After. - Send clients that keep tripping the limit to the firewall: enable fail2ban’s stock
nginx-limit-reqjail, which reads the same error-log lines. - In your own clients: one shared, rate-aware client per token, honour
Retry-After, jittered backoff, a retry cap, and a cache for data you read again and again. - Keep network and application defences apart. Always-on DDoS mitigation filters floods before they reach the server, but it cannot know your per-user limits. What DDoS protection covers explains where one ends and the other starts.
Everything above runs on any KVM VPS with root access and the stock nginx package on Ubuntu or Debian.
Frequently asked
Is a 429 Too Many Requests error my fault or the website's?
Usually neither is broken: a rate limit counted too many requests from your IP address, account or API key in a short time and is refusing the rest for a while. Wait a minute and try again. If it keeps happening, something on your side is sending requests in a loop, such as a script, a browser extension or several tabs polling the same site.
How long should I wait after a 429 response?
As long as the Retry-After header says, which is either a number of seconds or a date. Discord and Telegram also put the wait in the JSON body as retry_after. If the server gives no hint, back off exponentially with random jitter, starting around one second, and stop after a fixed number of tries.
Why does nginx rate limiting return 503 instead of 429?
nginx's limit_req and limit_conn answer 503 Service Unavailable by default. Add 'limit_req_status 429;' and 'limit_conn_status 429;' in the http, server or location block, then run 'sudo nginx -t && sudo systemctl reload nginx'. Clients and monitoring can then tell a rate limit from a real outage.