Handling 429s and quota correctly against a metered forecasting API
Pierre N. · Founder, VecTime Cloud · 6 min read
The short version429 means slow down: sleep for Retry-After seconds and go again. 402 means stop: the monthly quota is gone and no amount of waiting inside this month will bring it back. Read RateLimit-Remaining from every response and pace yourself before the 429, not after it.
Two limits, two status codes
The rate limit is per API key, per minute, and it is a token bucket: you may spike up to the plan's burst size, then settle at the sustained rate. Exceeding it returns 429 rate_limit_exceeded and a Retry-After header. The request was not billed and the condition clears by itself within seconds.The quota is per account, per UTC calendar month, and it is a counter. Exhausting it returns 402 quota_exceeded. Nothing about that changes until the month rolls over or the plan does, so a retry is at best wasted and at worst a tight loop that hides the real problem from whoever is on call.| Plan | Requests / minute | Burst | Monthly quota |
|---|---|---|---|
| Free | 10 | 5 | 300 |
| Starter | 60 | 30 | 50,000 |
| Pro | 300 | 120 | 500,000 |
| Enterprise | 2000 | 500 | unmetered |
How the token bucket refills
Each key has a bucket that holds up to burst tokens and refills continuously at rpm ÷ 60 tokens per second. A request takes one token; a request that finds the bucket empty is the 429. On Starter that is a bucket of 30 refilling at one token a second: you can fire 30 requests instantly, and then one a second forever. The bucket never holds more than the burst, so a quiet hour does not bank you a bigger spike later.The practical consequence is that a steady client almost never sees a 429. A batch job that sends one request, waits for the response — which takes over a second on CPU — and sends the next is running well under one request a second, and the bucket stays full. The 429s come from concurrency: twenty workers that all start at the top of the hour.The headers on every response
Both limits are reported on every response, including successful ones, so a client can see a 429 coming and a dashboard can show quota burn without a separate endpoint.httpHTTP/1.1 200 OKRateLimit-Limit: 60 # requests per minute on this planRateLimit-Remaining: 41 # tokens left in the bucket right nowRateLimit-Reset: 19 # seconds until the bucket is full againX-Quota-Limit: 50000 # monthly call allowanceX-Quota-Used: 12043 # calls consumed this UTC monthHTTP/1.1 429 Too Many RequestsRetry-After: 2 # only on 429 — wait this many seconds
A retry loop that behaves
Three properties matter: it honours the server's hint when there is one, it backs off geometrically with jitter when there is not, and it refuses to retry anything that a retry cannot fix.pythonimport time, requestsRETRYABLE = {429, 502, 503}def forecast(session, payload, attempts=6):for attempt in range(attempts):r = session.post(f"{API}/v1/forecast", json=payload, timeout=120)if r.status_code not in RETRYABLE:r.raise_for_status() # 401/402/403/422 are not retryable — see belowreturn r.json()# 429 carries the server's own hint. 502/503 do not; back off geometrically,# capped, with jitter so a fleet of workers does not retry in lockstep.hinted = r.headers.get("Retry-After")wait = float(hinted) if hinted else min(30, 0.5 * 2 ** attempt)time.sleep(wait + random.uniform(0, 0.25 * wait))raise RuntimeError("gave up after repeated retryable failures")
python# Read the bucket from the last response and pace before the next request# instead of after a 429. Remaining tokens tell you how much burst is left.remaining = int(r.headers["RateLimit-Remaining"])limit = int(r.headers["RateLimit-Limit"])if remaining == 0:# Bucket is empty: one token arrives every 60/limit seconds.time.sleep(60 / limit)
Discover limits, do not hard-code them
Every ceiling — series per request, context window, horizon — is on GET /v1/models, which is unmetered and reflects the plan at the moment of the call. Plan changes apply to the very next request, so a constant copied from the pricing page is wrong from the minute you upgrade until the next deploy. The batching post builds its chunk size from this call for exactly that reason.pythonmodel = requests.get(f"{API}/v1/models", headers=H).json()["models"][0]# {"max_series_per_request": 32, "max_context": 2048, "max_horizon": 128, ...}# Unmetered, and it reads your plan at call time — so it is right the minute# after an upgrade, which a constant in your config is not.
When retrying is the wrong move
The loop above retries three codes. Everything else is a message to a human, and the honest thing for a client to do is surface it.| Status | Code | Retry? | Why |
|---|---|---|---|
| 429 | rate_limit_exceeded | Yes — after Retry-After | The token bucket is empty. It refills on its own. |
| 503 | model_unavailable | Yes — seconds | Cold start; the weights are still loading. |
| 502 | inference_failed | Yes — with backoff | The backend failed. The call is not billed. |
| 402 | quota_exceeded | No | The month's allowance is spent. Upgrade or wait for the UTC month. |
| 422 | horizon_limit_exceeded, context_limit_exceeded, series_limit_exceeded | No | The request is larger than the plan allows. `details` carries the ceiling. |
| 422 | invalid_request | No | Malformed payload. Retrying sends the same malformed payload. |
| 401 | missing / invalid / revoked_api_key | No | The credential is wrong. Fix the key, not the loop. |
Run it on your own series.
If you want to try this without hosting a model, VecTime Cloud's free tier is 300 forecasts a month and needs no card.