A rate limit you can’t see is just a trap with a timer. So every maviapi response carries the numbers a polite client needs to never hit one: how much you’ve used, how much is left, and when it resets. Here’s how to read them and build a client that backs off on its own.
The guessing game
Without limit headers, throttling is folklore. You sprinkle in a sleep that felt about right, watch for 429s in the logs, and tune by superstition. It works until traffic shifts, and then you’re either leaving throughput on the table or hammering the limit and getting rejected. Either way you’re guessing, and the server knows the real answer it just isn’t telling you.
We decided to just tell you.
The headers we send
Every successful response includes the current state of your window:
x-ratelimit-limit: 60 # requests allowed per window
x-ratelimit-remaining: 57 # requests left in this window
x-ratelimit-reset: 1718960400 # unix time when the window resets
x-maviapi-version: 4When you do run out, the rejection is just as informative. A 429 comes back with a retry-after telling you, in seconds, exactly how long to wait before trying again. No guessing, no exponential ramp into a wall.
HTTP/2 429
retry-after: 8
x-ratelimit-remaining: 0Reading them in practice
The headers are plain strings on the response, so reading them is unremarkable. The point is to actually look before you fire the next request, not after you’ve already been rejected:
const res = await fetch(url, { headers: auth });
const remaining = Number(res.headers.get("x-ratelimit-remaining"));
const reset = Number(res.headers.get("x-ratelimit-reset"));
if (remaining <= 1) {
const waitMs = Math.max(0, reset * 1000 - Date.now());
console.log(`Window almost empty, pausing ${Math.ceil(waitMs / 1000)}s`);
await sleep(waitMs);
}A backoff that respects them
Tie it together and you get a request helper that never has to be rescued. When the server says wait, it waits the amount the server asked for, then carries on. No magic constants, no tuning:
async function politeFetch(url, init, tries = 4) {
for (let attempt = 0; attempt < tries; attempt++) {
const res = await fetch(url, init);
if (res.status !== 429) return res;
// The server told us exactly how long to wait. Believe it.
const retryAfter = Number(res.headers.get("retry-after")) || 1;
await sleep(retryAfter * 1000);
}
throw new Error("Rate limited after several retries");
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));Four lines of real logic. Because the wait time comes from the response, this is correct whether your plan allows sixty requests a minute or sixty thousand. You’re not modeling our limits in your code; you’re reading them.
Limits, credits, and 429s
One thing worth keeping straight: rate limits and credits are different meters. A rate limit protects the service from bursts in the moment. A credit is your plan’s monthly budget, and one successful request to a data endpoint spends one of them.
- A 429 means “too fast, slow down,” and a credit isn’t spent. Wait the
retry-afterand you’re fine. - Running out of credits is a billing state, not a timing one. No amount of backing off fixes it; that’s an upgrade or the start of the next period.
A request that fails validation or errors out doesn’t cost you a credit. You’re billed for served data, not for attempts, so a flaky source site never quietly eats your allowance.
Build the polite client once, drop it in front of every call, and rate limits stop being something you think about. The headers were there the whole time; all your code has to do is read them.
Started maviapi after one too many 2am scraper fires. Writes about the product, the bets behind it, and the parts of running an API company nobody warns you about.
More from Musab