← All posts

Handling 429s and quota correctly against a metered forecasting API

Pierre N. · Founder, VecTime Cloud · 6 min read
A metered API says no in two different ways, and a client that treats them the same will either hammer a bucket that is about to refill or patiently retry a quota that will not reset for three weeks. The difference is one status code. This post is the retry loop we would want every integration to have, the headers it reads, and the table of what is worth retrying.
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.
PlanRequests / minuteBurstMonthly quota
Free105300
Starter603050,000
Pro300120500,000
Enterprise2000500unmetered

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.
http
HTTP/1.1 200 OK
RateLimit-Limit: 60 # requests per minute on this plan
RateLimit-Remaining: 41 # tokens left in the bucket right now
RateLimit-Reset: 19 # seconds until the bucket is full again
X-Quota-Limit: 50000 # monthly call allowance
X-Quota-Used: 12043 # calls consumed this UTC month
HTTP/1.1 429 Too Many Requests
Retry-After: 2 # only on 429 — wait this many seconds
RateLimit-Reset is seconds until the bucket is full, not until the next token arrives. The next token is 60 ÷ limit seconds away whenever the bucket is not already full.

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.
python
import time, requests
RETRYABLE = {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 below
return 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")
Better than retrying after a 429 is not sending the request that would earn one. The remaining-token count is on the previous response; a client that reads it can pace itself.
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.
python
model = 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.
StatusCodeRetry?Why
429rate_limit_exceededYes — after Retry-AfterThe token bucket is empty. It refills on its own.
503model_unavailableYes — secondsCold start; the weights are still loading.
502inference_failedYes — with backoffThe backend failed. The call is not billed.
402quota_exceededNoThe month's allowance is spent. Upgrade or wait for the UTC month.
422horizon_limit_exceeded, context_limit_exceeded, series_limit_exceededNoThe request is larger than the plan allows. `details` carries the ceiling.
422invalid_requestNoMalformed payload. Retrying sends the same malformed payload.
401missing / invalid / revoked_api_keyNoThe credential is wrong. Fix the key, not the loop.
One more case that is not an error at all: a request that succeeds but leaves X-Quota-Used close to X-Quota-Limit is the moment to alert, not the 402 that follows. Ninety percent is a reasonable line; the dashboard draws the same one.
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.
Pierre N.Founder, VecTime CloudBuilds VecTime Cloud — the Go API and its metering, the Python inference service, and the benchmarks published here, including the runs that go against us.