DEV Community

Jordan Huang
Jordan Huang

Posted on

A 429 Is Not Always a Rate Limit: Four Free-Tier Failure Myths and a Classifier You Can Run

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:

  1. Retry-After present? The server is telling you when. Obey it.
  2. x-ratelimit-remaining: 0? You hit a quota window, not a burst limit. Sleeping one second won't help.
  3. Different key, same failure? The limit is per-account or per-IP, not per-request.
  4. 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"
Enter fullscreen mode Exit fullscreen mode

Read it like this:

  • time_namelookup high → DNS, not your app.
  • time_connect high → routing or a cold host.
  • time_starttransfer high, 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())
Enter fullscreen mode Exit fullscreen mode

Run it like this:

python3 classify.py "$YOUR_ENDPOINT" 5 2
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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)