> For the complete documentation index, see [llms.txt](https://docs.durohub.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.durohub.com/rate-limits/handling-429s.md).

# Handling 429s

A refusal tells you which budget ran out and exactly how long to wait.

{% hint style="warning" %}
**Rate limits are rolling out environment by environment.** You may already see `RateLimit` and `RateLimit-Policy` headers on responses; refusals begin separately in each environment. Build the handling on this page now, and nothing changes for your integration when enforcement arrives.
{% endhint %}

## Check your headers

Every charged response reports the budget it drew on. Add `-i` to any request to see them:

```bash
curl -si 'https://api.durohub.com/graphql' \
  -H 'x-api-key: YOUR_API_KEY' \
  -H 'x-organization: @your-org' \
  -H 'Content-Type: application/json' \
  -d '{"query": "query { libraries { id name slug } }"}' \
  | grep -i '^ratelimit'
```

On a Standard plan, with a full bucket:

```http
ratelimit-policy: "default-read";q=600;w=60
ratelimit: "default-read";r=599;t=1
```

`libraries` is one `default-read` operation. You had 600 tokens per 60 seconds, one is spent, and the bucket will be full again within a second. See [Every response tells you where you stand](/rate-limits/rate-limits.md#every-response-tells-you-where-you-stand) for each field.

## The refusal

When an operation's budget is empty, the API answers `429 Too Many Requests` before running anything:

```http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 2
RateLimit-Policy: "bulk-write";q=20;w=60
RateLimit: "bulk-write";r=0;t=59
```

```json
{
  "errors": [
    {
      "message": "Rate limit reached for bulk-write.",
      "extensions": {
        "code": "RATE_LIMITED",
        "budget": "bulk-write",
        "retryAfter": 2
      }
    }
  ]
}
```

| Field                           | Meaning                                                                                         |
| ------------------------------- | ----------------------------------------------------------------------------------------------- |
| `extensions.code`               | Always `RATE_LIMITED`. Branch on this, not on `message`                                         |
| `extensions.budget`             | The budget that ran out, as named in the [Budget Reference](/rate-limits/budgets.md)            |
| `extensions.retryAfter`         | Seconds until this request would be accepted: a whole number, at least 1                        |
| `Retry-After` header            | The same value as `extensions.retryAfter`                                                       |
| `RateLimit-Policy`, `RateLimit` | The refused budget's size, what is left, and `t`, the seconds until it is completely full again |

Wait for `retryAfter`, not `t`. `t` is how long until the whole bucket refills; `retryAfter` is how long until there is room for *this* request, which is usually much sooner. If more than one budget refused, `budget` and `retryAfter` name the one with the longest wait.

Nothing in a refused request ran, so it is always safe to send again unchanged, mutations included.

{% hint style="info" %}
`RATE_LIMITED` is the GraphQL API's refusal. The account APIs under `/auth/*` use a different code, `AUTH_RATE_LIMITED`, in their own flat error body. See [Sign-in is limited differently](/rate-limits/rate-limits.md#sign-in-is-limited-differently).
{% endhint %}

## Retry with Retry-After

This helper sends a GraphQL request and, on a `429`, waits the time the API asks for plus a little random jitter, then tries again, up to a fixed number of attempts. Jitter keeps several workers that were refused together from all retrying in the same instant.

```javascript
const DURO_API = 'https://api.durohub.com/graphql';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Budgets that charge only requests without a valid credential.
// Waiting will not fix these.
const CREDENTIAL_BUDGETS = new Set(['invalid-credential', 'ip-backstop']);

async function duroRequest(query, variables = {}, options = {}) {
  const { maxAttempts = 4, maxWaitSeconds = 120 } = options;

  for (let attempt = 1; ; attempt++) {
    const response = await fetch(DURO_API, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': process.env.DURO_API_KEY,
        'x-organization': process.env.DURO_ORGANIZATION, // e.g. "@acme-corp"
      },
      body: JSON.stringify({ query, variables }),
    });

    if (response.status !== 429) {
      return response.json();
    }

    const body = await response.json();
    const extensions = body.errors?.[0]?.extensions ?? {};
    const budget = extensions.budget ?? 'unknown';
    const retryAfter =
      Number(response.headers.get('Retry-After')) || extensions.retryAfter || 1;

    if (CREDENTIAL_BUDGETS.has(budget)) {
      throw new Error(
        `Refused on ${budget}: the API key or session is not valid. Replace it rather than retrying.`
      );
    }
    if (attempt >= maxAttempts || retryAfter > maxWaitSeconds) {
      throw new Error(
        `Rate limited on ${budget}; retry after ${retryAfter}s (gave up after ${attempt} attempts).`
      );
    }

    const jitterMs = Math.random() * 1000;
    console.warn(
      `Rate limited on ${budget}, attempt ${attempt}/${maxAttempts}; waiting ${retryAfter}s`
    );
    await sleep(retryAfter * 1000 + jitterMs);
  }
}
```

Use it like any other request:

```javascript
const result = await duroRequest(
  `query Part($cpn: String!) {
     component { findByCpn(cpn: $cpn) { id name revisionValue } }
   }`,
  { cpn: '100-00001-00' }
);
```

`maxWaitSeconds` matters for the hourly budgets (`import-start`, `export-start`, `ai`, `invite`, `api-keys`): a refusal there can ask you to wait many minutes. A long-running job may be happy to sleep through it; an interactive tool should stop and tell the user when to try again.

### When waiting won't help

Two budgets mean something other than "slow down":

* **`invalid-credential`** — the API key or bearer token on the request is expired, revoked or not valid. Requests carrying it are counted per IP address until the client signs in again or switches to a valid key.
* **`ip-backstop`** — the request carried no valid credential, and its IP address has sent a great many such requests.

Fix the credential; retrying the same request will keep failing. The helper above stops immediately on both.

## Pace yourself with the headers

An integration that watches `RateLimit` can slow down before it is ever refused. This version spaces requests out to the bucket's refill rate once fewer than a tenth of the tokens are left:

```javascript
function parseRateLimit(response) {
  const state = response.headers.get('RateLimit');
  const policy = response.headers.get('RateLimit-Policy');
  if (!state || !policy) return null; // headers are advisory; carry on without them

  const remaining = Number(/;r=(\d+)/.exec(state)?.[1]);
  const limit = Number(/;q=(\d+)/.exec(policy)?.[1]);
  const windowSeconds = Number(/;w=(\d+)/.exec(policy)?.[1]);
  if (!limit || !windowSeconds) return null;

  return { remaining, limit, windowSeconds };
}

async function paceAfter(response) {
  const rate = parseRateLimit(response);
  if (!rate || rate.remaining >= rate.limit / 10) return;

  // Running low: wait one refill interval (one token's worth) before the next call.
  const refillMs = (rate.windowSeconds * 1000) / rate.limit;
  await sleep(refillMs);
}
```

Call `paceAfter(response)` after each request in a loop. On a Standard `default-read` budget (`q=600;w=60`) the refill interval is 100 ms, so a sync that runs low settles at ten operations a second instead of hitting the wall. Keep the retry helper as well: pacing makes a `429` rare, not impossible.

The headers describe whichever budget the request touched that is closest to running out, so pacing on them protects the budget that matters.

## Use less of your budget

The limits are sized so that an integration doing these things has room to spare on any plan.

**Use the bulk operations.** Each call is charged once, whatever its size. One `component.bulkUpdate` updating fifty components costs one `bulk-write` token; fifty `component.update` calls cost fifty `default-write` tokens.

```graphql
mutation UpdateParts($inputs: [UpdateComponentInput!]!) {
  component {
    bulkUpdate(inputs: $inputs) {
      success
      components { id name }
      errors { index code message }
    }
  }
}
```

The same goes for change orders: add every item in one `changeOrders.addItems` call (up to 1,000 items) rather than one call per item.

**Subscribe instead of polling.** [Webhooks](/advanced-topics/webhooks.md) tell you when a component or change order is created, updated or deleted. Polling for the same news spends `default-read` or `search` tokens on every check, most of them to learn that nothing changed.

**Poll jobs gently.** Imports and exports report progress through `componentImport.jobStatus` and `exportJob.findOne`, which share the `job-status` budget (60 a minute on Standard). Checking every few seconds is plenty.

**Cache what rarely changes.** Libraries, categories, statuses and configuration change far less often than components. Read them once per run, not once per item.

**Give each integration its own identity.** An API key's budget belongs to the user who created it, for the library it is scoped to. Two keys created by the same user for the same library share one budget, so a nightly sync and a dashboard on such keys slow each other down. Integrations that should never compete for a budget belong under different users or in different libraries. A key never shares organization budgets with its owner's web session, so your sync won't slow you down in the app.

**Paginate instead of re-searching.** `component.findAll` and the other searches draw on `search`, a smaller budget than `default-read`. Walk the pages of one search with `pagination: { first, after }` rather than starting new searches, and look up a known part directly with `component.findByCpn` or `component.findOne`, which use `default-read`.

## Next steps

* [How Rate Limiting Works](/rate-limits/rate-limits.md): scopes, token buckets and headers
* [Budget Reference](/rate-limits/budgets.md): every budget, its operations, and its limits on each plan
* [Error Handling](/advanced-topics/error-handling.md): every other error the API returns


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.durohub.com/rate-limits/handling-429s.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
