DEV Community

Jordan Huang
Jordan Huang

Posted on

The 429 You Can't See: Reading Rate-Limit Headers on Free Model Servers

You got a 429. Or a timeout. Was it your code? Or their quota?

Free model servers throttle quietly. Sometimes they never send a 429 at all. They just slow down. I spent weeks probing free endpoints. The most useful signal wasn't the response body. It was the headers.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I use MonkeyCode's free model access and free server option in this workflow. The method works on any free endpoint.

Why Headers Beat Guessing

Most rate limiters leave fingerprints. RateLimit-Remaining tells you how many calls are left. RateLimit-Reset tells you when the window clears. Retry-After tells you how long to wait.

Some servers send nothing. That's a signal too.

The problem? Header formats are inconsistent. Some use X-RateLimit-*. Some use RateLimit-*. Some rename everything.

You need a probe. Not a guess.

The Probe

Here's the script I run. It sends one request every two seconds. It logs status, latency, and every rate-limit header it finds.

import csv
import time
import requests
from datetime import datetime, timezone

URL = "https://your-free-endpoint.example/v1/chat/completions"
TOKEN = "your-token"
INTERVAL_SECONDS = 2
MAX_CALLS = 60

headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

payload = {
    "model": "your-model",
    "messages": [{"role": "user", "content": "Reply with the word ok."}],
    "max_tokens": 10,
}

RATE_KEYS = [
    "ratelimit-limit", "ratelimit-remaining", "ratelimit-reset",
    "x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset",
    "retry-after", "x-ratelimit-retry-after",
]

def rate_headers(resp):
    return {k: resp.headers.get(k) for k in RATE_KEYS if resp.headers.get(k)}

with open("probe.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["ts", "status", "latency_ms",
                     "limit", "remaining", "reset", "retry_after"])

    for i in range(MAX_CALLS):
        start = time.perf_counter()
        try:
            resp = requests.post(URL, headers=headers, json=payload, timeout=30)
            latency = int((time.perf_counter() - start) * 1000)
            h = rate_headers(resp)
            writer.writerow([
                datetime.now(timezone.utc).isoformat(),
                resp.status_code, latency,
                h.get("ratelimit-limit", ""),
                h.get("ratelimit-remaining", ""),
                h.get("ratelimit-reset", ""),
                h.get("retry-after", ""),
            ])
            print(i, resp.status_code, latency, h)
        except requests.exceptions.Timeout:
            writer.writerow([datetime.now(timezone.utc).isoformat(),
                             "timeout", "", "", "", "", ""])
            print(i, "timeout")
        except requests.exceptions.ConnectionError:
            writer.writerow([datetime.now(timezone.utc).isoformat(),
                             "conn_error", "", "", "", "", ""])
            print(i, "conn_error")
        time.sleep(INTERVAL_SECONDS)
Enter fullscreen mode Exit fullscreen mode

That's it. Sixty calls. Two minutes. One CSV.

How to Read the CSV

Open it. Sort by timestamp. Look for patterns.

column -s, -t < probe.csv | head -25
Enter fullscreen mode Exit fullscreen mode

Then bucket every row:

import csv
from collections import Counter

rows = list(csv.DictReader(open("probe.csv")))
print(Counter(r["status"] for r in rows))
Enter fullscreen mode Exit fullscreen mode

Now ask three questions.

Does remaining hit zero before a 429? If yes, the limiter is honest. Back off until reset.

Does retry-after match reality? Sleep exactly that long. If the next call still fails, the header is a lie.

Does latency climb while status stays 200? That's queueing, not quota. Your fix is concurrency, not waiting.

The Decision Table

Header pattern What it means What I do
remaining drops to 0 before any 429 Honest fixed window Wait until reset, then resume
429 + retry-after Recoverable throttle Sleep retry-after seconds
429 with no headers Opaque throttle Back off 60s, log it, alert
200 but latency climbs Server queueing Cut concurrency, not rate
reset changes between calls Rolling window Re-read headers every call
Headers vanish under load Limiter hides itself Treat every call as possibly throttled

Keep this table next to your probe. It turns noise into decisions.

Vary the Probe

One fixed interval is not enough. Change four things.

  • Interval. Run at 1s, 5s, and 30s. Fast intervals expose the limiter. Slow intervals expose reset behavior.
  • Payload size. Send 50 tokens. Then send 2000. Some servers throttle on tokens, not calls.
  • Concurrency. Run two probes in parallel. Watch for queueing.
  • Time of day. Free servers share capacity. Evening load changes everything.

Each variation is one CSV. Keep them in separate files. Compare the header columns, not just the status codes.

Where Free Servers Perform Well

Free endpoints are honest more often than you'd think. Many send RateLimit-* headers on every response. That's rare on paid tiers.

A predictable reset window is gold. You can schedule batch jobs around it. You can pre-warm before the window clears.

The probe reveals this in minutes. No support ticket needed.

Where Free Servers Break

Free servers break in three places.

First, header drift. The limit header says 60. The server throttles at 40. The header was decorative.

Second, silent queueing. Status stays 200. Latency triples. Your timeout fires and you blame the network. The headers tell the truth.

Third, reset lies. The header says reset in 30 seconds. You wait. The next 429 arrives anyway. The server reset its own clock.

None of these show up in a single test. That's why you log every call.

When I see a broken pattern, I follow three steps. First, reproduce with a single call. Second, check the raw headers with curl -i. Third, compare against the CSV.

curl -i -X POST "$URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"model":"your-model","messages":[{"role":"user","content":"hi"}]}'
Enter fullscreen mode Exit fullscreen mode

Limitations

This probe maps one endpoint, one model, one time of day. It doesn't map the whole system.

Header formats aren't standardized. Your endpoint may use none of the keys I listed. Check the raw response first.

A free server has no SLA. This probe tells you what the server does today. It doesn't promise tomorrow.

Who Should Skip This

Skip this if you have a paid contract with a real SLA. Skip it if you need hard guarantees. Skip it if one failed call costs money.

This is for the rest of us. The ones building on free tiers. The ones who need to know where the edge is before we fall off it.

Run the probe. Read the headers. Then tell me what your server lied about.

Top comments (0)