Handling Norbelys API rate limits: backoff that doesn't make things worse
A tight retry loop against a 429 doesn't just fail — it adds load to a shared multi-tenant database exactly when it's already under pressure. How to back off correctly.
By Gabriel Lara, Developer Relations, Norbelys
Founder-reviewed ·How we research and correct articles
Rate limiting on the Norbelys API isn’t there to make your
integration harder to write. It’s there because every organization’s
requests land on the same shared Postgres database, isolated by row-level
security rather than by separate infrastructure per tenant — so one
integration retrying aggressively doesn’t just hurt itself, it adds real
load to a resource every other tenant is also depending on at that exact
moment. How your client responds to a 429 is part of being a good citizen
on a multi-tenant platform, not just an implementation detail.
What a naive retry loop actually does
The instinctive first draft of retry logic looks like this:
// Don't do this.
async function badRetry(url, apiKey) {
while (true) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (res.status !== 429) return res;
// immediately try again
}
}
This is close to the worst possible response to a rate limit. Three things go wrong at once:
- It doesn’t reduce load — it maximizes it. A
429means “you’re over the limit right now.” Retrying instantly means your client is now firing requests as fast as the network round-trip allows, all rejected, all still consuming connection and processing overhead on the way to being rejected. - Every failed attempt still costs something. Even a rejected request touches the edge, authenticates the token, and evaluates the limit. A tight loop of rejected requests is real, wasted work on infrastructure every tenant shares — not a free no-op.
- It never recovers, because it never changes. If the reason you’re rate-limited is sustained (a large bulk import or a busy sync job), retrying at the same instant, same rate, forever just keeps hitting the same wall.
What to do instead: exponential backoff with jitter
The standard, well-understood fix is exponential backoff: each retry waits longer than the last, growing geometrically, with a capped maximum and a small amount of randomness (jitter) added so that many clients retrying after the same failure don’t all retry in lockstep and cause a new spike together.
async function requestWithBackoff(url, apiKey, {
maxRetries = 5,
baseDelayMs = 500,
maxDelayMs = 30_000,
} = {}) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (res.status !== 429) return res;
if (attempt === maxRetries) {
throw new Error("Rate limited after max retries");
}
// Prefer a server-provided signal (e.g. Retry-After) if present;
// otherwise fall back to exponential backoff with jitter.
const retryAfterHeader = res.headers.get("Retry-After");
const serverDelayMs = retryAfterHeader
? Number(retryAfterHeader) * 1000
: null;
const exponentialDelayMs = Math.min(
baseDelayMs * 2 ** attempt,
maxDelayMs
);
const jitterMs = Math.random() * exponentialDelayMs * 0.3;
const delayMs = serverDelayMs ?? exponentialDelayMs + jitterMs;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
Walk through what this buys you over the naive loop:
- Attempt 1 fails → wait roughly 500ms–650ms, try again.
- Attempt 2 fails → wait roughly 1s–1.3s.
- Attempt 3 fails → wait roughly 2s–2.6s, and so on, doubling each time up to the 30-second cap.
- Give up after
maxRetriesrather than looping forever — a caller further up the stack needs to know this request ultimately didn’t succeed, not have it silently retried into eternity.
Always prefer a server-provided signal over your own guess
If a response carries a Retry-After-style header or a hint in the error
body about when to retry, use it instead of computing your own delay. The
server knows its own load and limits better than a client’s local
exponential formula ever can — a client-side backoff schedule is a
reasonable fallback for when no such signal is present, not a replacement
for one that is. Always check the response you actually got before falling
back to a fixed formula.
Backing off protects your own integration too
The multi-tenant argument is the deeper reason, but there’s a purely selfish one as well: a client that backs off gracefully degrades instead of cascading into a worse failure. A tight retry loop against a rate limit tends to pin a thread, a connection pool slot, or a worker process in a busy-wait state — resources your own application needs for everything else it’s doing at the same time. Backoff with a capped retry count turns “my integration is now stuck” into “this one operation failed cleanly and can be logged, alerted on, or retried later on a schedule.”
This matters most for exactly the workloads most likely to hit a limit in
the first place — a large bulk import
job or a script paginating through
every page of a large people list. Both are naturally high-request-volume
operations; both are exactly where a naive retry loop does the most damage
if it fires under load.
See the developer portal for the current rate limit
figures per plan and endpoint class, and
typed error handling for how to
build one central place in your client that handles rate_limited
alongside validation and auth errors instead of three separate code paths.
Rate limits and backoff FAQ
Should I add jitter even for a single-client integration?
Yes — jitter isn't only about avoiding synchronized retries across many clients. It also spreads out your own retries when a single process issues several concurrent requests that fail together, preventing them from all retrying at the exact same moment.
Is it safe to retry a POST request after a 429?
Yes, as long as it carries an Idempotency-Key header — the API guarantees a retried request with the same key won't create a duplicate. Always set one on state-changing POST calls that might be retried, including after a rate limit.
What's a reasonable maxRetries value?
Somewhere around 3–5 is typical for interactive code paths where a user is waiting; background jobs like an import poller can afford more retries and a longer cap, since nothing is blocking on the immediate response.
Does hitting a rate limit mean something is wrong with my integration?
Not necessarily — a burst of legitimate activity (a large sync, a bulk operation) can hit a limit under normal use. What matters is how the client responds: graceful backoff is expected behavior, not a sign of a bug.