Rate limits
Budgets by rate class and tier, the IETF RateLimit headers that state them, what a 429 and a 503 overloaded mean, and how to back off.
FurniOps budgets requests so that one busy integration cannot slow everyone else. Every response says where you stand, so a well-behaved client never needs to guess.
How budgets work
- Your organisation has one budget per mode and rate class: live and test are counted apart, and so are reads, writes and the rest. All your keys share it.
- A budget is a token bucket: a burst you can spend at once, refilled at a steady rate. Spending the burst is fine; sustained traffic above the rate is not.
- Every operation in this API needs a key, so every request spends your organisation's budget, never a shared one.
Rate classes
Every operation is in one class, named in the API reference as x-furniops-rate-class:
| Class | What | Counted |
|---|---|---|
read | Reads: GET of objects, lists and the change feed | per request |
write | Changes | per request |
expensive | Work that fetches from a vendor or reprocesses data: runs and replays, source tests, stock checks, completing an upload, confirming mappings and category rules | per request |
export | Starting a bulk export, budgeted per minute | per export started |
stream | Opening GET /v1/stream | per connection; a reconnection counts |
Budgets by tier
Your plan sets your organisation's rate tier. These are the current budgets; read the headers below rather than hard-coding them, as they can change.
| Class | standard | high | max |
|---|---|---|---|
read | 100/s, burst 200 | 500/s, burst 1,000 | 2,000/s, burst 4,000 |
write | 20/s, burst 40 | 100/s, burst 200 | 400/s, burst 800 |
expensive | 2/s, burst 5 | 10/s, burst 20 | 40/s, burst 80 |
export | 1/min, burst 2 | 6/min, burst 10 | 30/min, burst 60 |
stream | 1/s, burst 5 | 5/s, burst 20 | 20/s, burst 50 |
An organisation without a plan yet gets standard. With a burst of 200 and 100 a second, a
standard organisation can send 200 reads at once, then 100 a second for as long as it likes.
The headers
Every budgeted response carries the IETF RateLimit header fields (draft 11):
RateLimit-Policy: "standard.read";q=200;w=2
RateLimit: "standard.read";r=187;t=1| Header | Parameter | Meaning |
|---|---|---|
RateLimit-Policy | the name | The budget, <tier>.<class>. |
q | The burst: how many requests a full budget holds. | |
w | Seconds an empty budget takes to refill completely. | |
RateLimit | r | Requests left right now. |
t | Seconds until the budget is full again. |
Retry-After (in seconds) is sent only when a request is refused. A budget counted per minute
states its window in seconds too: "standard.export";q=2;w=120 is a burst of 2 refilled over two
minutes.
const page = await furniops.products.list();
// RateLimit-Policy: "standard.read";q=200;w=2 (burst q, refill window w seconds)
// RateLimit: "standard.read";r=199;t=1 (r requests left, t seconds until full)
console.log(page.lastResponse.headers.get("ratelimit-policy"));
console.log(page.lastResponse.headers.get("ratelimit"));429 rate_limited
A request over budget is refused before it does anything, with 429
rate_limited, the headers above and Retry-After:
HTTP/2 429
Content-Type: application/problem+json
RateLimit-Policy: "standard.write";q=40;w=2
RateLimit: "standard.write";r=0;t=2
Retry-After: 1
{
"type": "https://docs.furniops.com/errors/rate_limited",
"title": "Too many requests",
"status": 429,
"code": "rate_limited",
"detail": "The standard.write budget is spent. Retry after 1 s.",
"request_id": "req_034XqBaL2mRLTZPXyRbaUl",
"doc_url": "https://docs.furniops.com/errors/rate_limited"
}Wait at least Retry-After seconds, then retry. Nothing was done, so retrying is always safe.
429 quota_exceeded is not a rate limit
A 429 with the code quota_exceeded means a monthly quota of your plan
is spent, and its Retry-After counts down to the end of the month. Branch on code, not only
the status. See Usage and quotas.
503 overloaded
Separately from your budget, a FurniOps server under heavy load sheds the least important
requests first, so the rest keep being served. A shed request answers 503
overloaded with Retry-After of 2, 5 or 10 seconds, longer the heavier
the load, and nothing was done.
| Priority | Requests | Shed |
|---|---|---|
| low | export: starting a bulk export | first |
| medium | expensive and stream | next |
| high | read by organisations on the standard tier | under heavy load |
| critical | every write, and read on the high and max tiers | never |
Writes are never shed, so a change you make is never refused for load. Starting an export is the one exception: it is shed first, because it can wait.
Backing off
The SDK handles all of this: it retries 429 and every 5xx (503 overloaded included) after
Retry-After when it is at most 60 seconds, and otherwise backs off exponentially from 0.5 to 8
seconds with jitter, twice by default. When Retry-After asks for longer than 60 seconds, or the
retries run out, a 429 throws RateLimitError with retryAfterMs, so you can schedule the work
for later:
try {
await furniops.sources.createRun("src_034XqBaL2mRLTZPXyRbaUc", {}, { maxRetries: 5 });
} catch (error) {
// Thrown after the retries, or at once when Retry-After asks for more than 60 s.
if (error instanceof RateLimitError) scheduleLater(error.retryAfterMs ?? 60_000);
else throw error;
}Without the SDK:
- On
429or503, waitRetry-Afterseconds. Without one, wait with exponential backoff and jitter (for example, a random time between half and all of 0.5 s, 1 s, 2 s, …, capped). - Watch
RateLimit'sr. When it nears 0, slow down before you are refused. - Spread scheduled jobs: many workers starting on the minute spend the burst together.
Spending less
- Follow the change feed with
wait=25instead of polling objects. - Use
limit=100on lists, and up to1000on the change feed. - Send
If-None-Match: an unchanged object answers304with no body. - Use webhooks: deliveries to you cost no budget.
- Keep test traffic on test keys: it has its own budget and never eats into live.