Your request failed. The status code said 429. So you slept sixty seconds. Then it failed again.
What did that 429 actually tell you? On its own, almost nothing. A status code is a complaint, not a diagnosis.
I keep reading the same four claims in free-tier threads. Let's test each one, then replace the guessing with a script you can run.
Myth 1: "429 means I sent too many requests per second"
A 429 means "refused right now". The reason lives in headers and body, not in the number. Rate limits, concurrency caps, exhausted quota windows, and shared keys all return 429.
Here's what to actually check, in order:
-
Retry-Afterpresent? The server is telling you when. Obey it. -
x-ratelimit-remaining: 0? You hit a quota window, not a burst limit. Sleeping one second won't help. - Different key, same failure? The limit is per-account or per-IP, not per-request.
- Identical timing on every retry? That pattern looks like a concurrency cap or a shared pool.
Corrected mental model: 429 is a refusal with an unknown cause. Read the headers before you read your code.
Myth 2: "Backoff fixes a 429"
Backoff fixes congestion. It does not fix auth, quota, or a bad payload. And retries are not free.
Every retry adds load to a box that already said no. If the request is a write, a retry can duplicate it. Is your endpoint idempotent? If you can't answer in one sentence, don't retry it yet.
Three rules I'd apply before any retry loop:
- Cap total attempts. Three is usually enough to learn something.
- Add jitter. Synchronized clients retry in lockstep and rebuild the same spike.
- Log the attempt number with the status. A retry you can't count is a retry you can't reason about.
If attempt one and attempt three both return 429 with the same headers, stop retrying. You have a policy problem, not a timing problem.
Myth 3: "A 200 from /health means the service is ready"
A health endpoint that returns 200 proves one thing: the process answered. It does not prove the database is reachable, the model call works, or the config loaded.
Ask the blunt question. Can this instance serve one real request right now? If nobody has verified that since the last deploy, the answer is unknown.
Split the two checks:
- Liveness: is the process alive? Cheap, no dependencies, never fails on a slow dependency.
- Readiness: can it serve? Checks the things it needs, returns non-200 when they are missing.
Most free-tier checkers only ping liveness. That's why a box can look green while every real request fails.
Also watch for the inverse: some proxies return 200 with an error body. Check the payload, not just the status.
Myth 4: "A timeout means the network is slow"
A timeout is a duration, not a location. The delay could sit in DNS, TLS, cold start, a queue, or the model call itself.
Split the number before you blame the network. Here's a one-liner that breaks a single request into phases:
curl -sS -o /dev/null \
-w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
-X POST "$YOUR_ENDPOINT"
Read it like this:
-
time_namelookuphigh → DNS, not your app. -
time_connecthigh → routing or a cold host. -
time_starttransferhigh, connect fast → the work is slow, not the wire. - First call slow, second call fast → cold start. Repeat the test three times before concluding.
The artifact: a failure classifier you can copy
This script sends one request at a time. No concurrency, no load generation. It labels each response with a probable cause bucket so you stop guessing.
#!/usr/bin/env python3
"""classify.py - bucket failures before changing anything.
Sequential requests only. Runs against a URL you own.
Prints one JSON line per attempt, then a summary.
"""
import json
import sys
import time
import urllib.error
import urllib.request
WANTED = (
"retry-after",
"x-ratelimit-limit",
"x-ratelimit-remaining",
"x-ratelimit-reset",
"x-request-id",
"server",
)
def classify(status, headers, elapsed_ms, body):
if status == 429:
if "retry-after" in headers:
return "throttle:retry-after-present"
if headers.get("x-ratelimit-remaining") == "0":
return "quota:window-exhausted"
return "throttle:cause-hidden-read-body"
if status in (401, 403):
return "auth-or-scope"
if status in (404, 405):
return "path-or-method"
if status == 408:
return "server-gave-up-waiting"
if status in (502, 503, 504):
return "upstream-or-booting"
if 500 <= status < 600:
return "app-crash"
if status == 200 and elapsed_ms > 5000:
return "ok-but-slow:check-cold-start"
if status == 200 and b"error" in body.lower():
return "ok-status-error-body"
return "ok"
def attempt(url, timeout=30.0):
started = time.monotonic()
req = urllib.request.Request(url, headers={"User-Agent": "failure-classifier/1"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
status, headers, body = resp.status, resp.headers, resp.read(2048)
except urllib.error.HTTPError as exc:
status, headers, body = exc.code, exc.headers, exc.read(2048)
except urllib.error.URLError as exc:
return {
"status": None,
"bucket": "transport",
"detail": str(exc.reason),
"elapsed_ms": round((time.monotonic() - started) * 1000),
}
elapsed_ms = round((time.monotonic() - started) * 1000)
hdrs = {k.lower(): v for k, v in headers.items() if k.lower() in WANTED}
return {
"status": status,
"bucket": classify(status, hdrs, elapsed_ms, body),
"elapsed_ms": elapsed_ms,
"headers": hdrs,
"body_prefix": body[:160].decode("utf-8", "replace"),
}
def main():
if len(sys.argv) < 2:
print("usage: classify.py <url> [attempts] [gap_seconds]", file=sys.stderr)
return 2
url = sys.argv[1]
attempts = int(sys.argv[2]) if len(sys.argv) > 2 else 5
gap = float(sys.argv[3]) if len(sys.argv) > 3 else 2.0
rows = []
for i in range(attempts):
row = attempt(url)
row["attempt"] = i + 1
rows.append(row)
print(json.dumps(row))
if i < attempts - 1:
time.sleep(gap)
summary = {}
for row in rows:
summary[row["bucket"]] = summary.get(row["bucket"], 0) + 1
print("\nsummary:", json.dumps(summary, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())
Run it like this:
python3 classify.py "$YOUR_ENDPOINT" 5 2
Output shape, for illustration only. These are not measurements of anyone's service:
{"attempt": 1, "status": 200, "bucket": "ok-but-slow:check-cold-start", "elapsed_ms": 6412}
{"attempt": 2, "status": 200, "bucket": "ok", "elapsed_ms": 240}
summary: {"ok": 1, "ok-but-slow:check-cold-start": 1}
Attempt 1 slow, attempt 2 fast. That's a warm-up story. Now you have a hypothesis to test, not a feeling to defend.
From bucket to first action
| Observed signal | Bucket | First action |
|---|---|---|
429 + Retry-After
|
throttle | Wait the stated time. Then retest once. |
429 + remaining: 0
|
quota | Change the window. Retrying is pointless. |
| 429 with no headers | unknown | Check the body. Then try a second key. |
| 502/503 on first call only | booting | Add a warm-up call after deploy. |
| 200 with error body | ok-status-error-body | Parse the payload in your checker. |
High time_namelookup
|
DNS | Fix resolution before touching the app. |
Can you run this on a free box? Yes, with caveats
This harness needs a Python interpreter and outbound HTTP. Nothing else. No image build, no volume, no worker pool.
It is also a good first task for a free hosting tier, because a failing harness is still informative. Availability of MonkeyCode's free model access and free server option is operator-supplied; confirm current limits on the product pages yourself before you plan around them.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Where the free model access fits my own workflow: generating the next probe. I ask a model to propose three plausible causes for a bucket, then I write the one test that separates them. The model suggests. The script decides.
Limitations, stated plainly
- Sequential requests do not measure capacity. This is a diagnosis tool, not a load test.
- Bucket names are heuristics. Your platform may reuse a code differently.
- Header checking assumes the proxy forwards headers. Many don't.
- One region, one key, one path. Conclusions don't generalize.
- It proves a cause is possible. It does not prove it's the only one.
Who should not use this
Skip it if you already have tracing with per-span timing. Skip it if your failures are data corruption or logic bugs — 429s won't help you there. Skip it if you need concurrency limits measured; use a real load generator with a fixed request profile instead.
The one-line takeaway
A status code tells you the server refused. It never tells you why. Bucket first, then fix.
What's the last failure you diagnosed by assumption rather than by header? If you want to try this harness somewhere disposable, run it on a free box and paste your summary line in the comments. The bucket counts are usually the interesting part.
Top comments (0)