# Rate limiting

Calls to our GraphQL API are rate limited to provide equitable access to the API for everyone and to prevent abuse. We are going to be evolving these limits as we gather more information, and encourage your feedback.

We use the [leaky bucket](https://en.wikipedia.org/wiki/Leaky_bucket) algorithm for our rate limiters, which means that your tokens are refilled with a constant rate of `LIMIT_AMOUNT / LIMIT_PERIOD`.

If you temporarily require higher limits, you can request them by contacting [PlentyLabs support](mailto:support@plentylabs.com), where we'll review them on a case by case basis.

## Avoiding hitting limits

These are best practices for using our APIs that will, in most cases, avoid hitting any rate limits.

### Avoid polling

One thing that we especially discourage is polling the API in a tight loop to fetch updates. A generation needs only a few polls: wait 5 seconds before the first `asset(id)` call, then 10, 15 and 20 seconds between calls, and stop at `COMPLETED` or `FAILED`.

### Avoid fetching unneeded data

Avoid fetching data you don't need by using our [filtering](https://plentylabs.com/developers/filtering) functionality. This way you can drill down on specific records only and avoid pagination in some cases.

Keep in mind that by default our [pagination](https://plentylabs.com/developers/pagination) returns up to 50 records. When querying for children this can quickly multiply the requested complexity. Consider specifying the amount of records you want returned.

### Order data

In certain cases where you do need to fetch all data, we suggest sorting it by the updated timestamp instead of when it was created or opened. This way you can get the most recently changed data first, and avoid paginating through the entire dataset.

### Write custom, specific queries

If you're fetching lots of different entities or dependencies, or have specific data needs, it's always recommended to write your own GraphQL queries that select only the fields you use, rather than copying a published operation whole, and to use filters to narrow down the data as much as possible.

## API request limits

We limit the amount of requests you make to our GraphQL API. To make it easier to keep track and avoid going over the limits, there are 3 HTTP response headers we send back on each request.

| HTTP header | Description |
| --- | --- |
| `X-RateLimit-Requests-Limit` | The maximum number of API requests you're permitted to make per hour. |
| `X-RateLimit-Requests-Remaining` | The number of API requests remaining in your bucket. |
| `X-RateLimit-Requests-Reset` | The time at which your bucket is full again, in UTC epoch seconds. |

When authenticated using an API key you can make up to **2,500 requests per hour**. Requests are associated with the key's workspace, which means all requests to the same workspace share the same quota even when using different API keys. The PlentyLabs app's own requests have a separate budget per signed-in person.

When making unauthenticated requests, you are limited to **600 requests per hour**. These requests are associated with the originating IP address instead of a workspace. Reading a board through a share link with `sharedBoard` does not count.

| Authentication | Limit | Per | Period |
| --- | --- | --- | --- |
| API key | 2,500 | Workspace | 1 hour |
| MCP (OAuth) | 2,500 | User | 1 hour |
| PlentyLabs app | 10,000 | User | 1 hour |
| Unauthenticated | 600 | IP address | 1 hour |

### Query- and mutation-specific request limits

Some queries and mutations have individual request rate limits that are lower than the global request limit. When one of these limits is hit, the API will send the same response as described in [Handling rate limit errors](#handling-rate-limit-errors). The window for each endpoint can be different. The error's `extensions` carry the endpoint's `limit` and `retryAfterSeconds`, and we will also send these extra headers:

| HTTP header | Description |
| --- | --- |
| `X-RateLimit-Endpoint-Requests-Limit` | The maximum number of API requests you're permitted to make to this endpoint in a rate limit window. |
| `X-RateLimit-Endpoint-Requests-Remaining` | The number of API requests remaining in the current rate limit window. |
| `X-RateLimit-Endpoint-Requests-Reset` | The time at which the current rate limit window resets, in UTC epoch seconds. |
| `X-RateLimit-Endpoint-Name` | The name of the endpoint that was rate limited. |

### Complexity limits

In order to protect our system from queries that are too complex and resource intensive, we calculate the complexity of each query, based on the amount of requested data.

To make it easier to keep track and avoid going over the limits, there are 4 HTTP response headers we send back on each request.

| HTTP header | Description |
| --- | --- |
| `X-Complexity` | The complexity of the query. |
| `X-RateLimit-Complexity-Limit` | The maximum number of API complexity points you're permitted to request per hour. |
| `X-RateLimit-Complexity-Remaining` | The number of points of API request complexity remaining in your bucket. |
| `X-RateLimit-Complexity-Reset` | The time at which your bucket is full again, in UTC epoch seconds. |

Requests authenticated using an API key can request up to **3,000,000 points per hour**. Requests are associated with the key's workspace, which means all requests to the same workspace share the same quota even when using different API keys.

Unauthenticated requests are limited to **100,000 points per hour**. These requests are associated with the originating IP address instead of a workspace.

| Authentication | Limit | Per | Period |
| --- | --- | --- | --- |
| API key | 3,000,000 | Workspace | 1 hour |
| MCP (OAuth) | 2,000,000 | User | 1 hour |
| PlentyLabs app | 10,000,000 | User | 1 hour |
| Unauthenticated | 100,000 | IP address | 1 hour |

#### Maximum complexity

We also enforce a maximum complexity of a single query at any time to **10,000 points**. Your query will always get rejected if it exceeds that, with the code `QUERY_TOO_COMPLEX`, as will an operation more than 10 levels deep or selecting more than 500 fields.

```json
{
  "errors": [
    {
      "message": "Query complexity is 12480 points, exceeding the maximum of 10000 for one operation.",
      "extensions": { "code": "QUERY_TOO_COMPLEX", "maxComplexity": 10000, "complexity": 12480 }
    }
  ]
}
```

#### Understanding query complexity

In order to protect our systems from too complex and resource intensive queries, we calculate the complexity of each query. Each field is 0.1 point, each object is 1 point and any connection multiplies everything selected inside it based on the given pagination argument, or the default 50. The score is then rounded up to the nearest integer.

As an example, let's fetch the workspace and request only two fields. The calculation is `1 + 0.1 + 0.1 = 1.2`, which equals a **complexity of 2** when rounded up.

```graphql
query WhoAmI {
  apiCredentialWorkspace {
    id
    name
  }
}
```

Let's now fetch our boards' id, title and when they were created. This has a **complexity of 116**. Here's why:

| Query | Complexity |
| --- | --- |
| `boards` | 1 point |
| `edges` and `node` (assuming 50, the default pagination) | 100 points (50 × 2) |
| `id`, `title`, `createdAt` | 15 points (50 × 3 × 0.1) |

You can use pagination parameters to specify a different limit than the default 50 to let the complexity calculator know how much data you're trying to fetch. This query with an explicit limit of the first 10 boards then has a **complexity of 24**.

```graphql
query MyBoards($workspaceId: ID!) {
  boards(workspaceId: $workspaceId, first: 10) {
    edges {
      node {
        id
        title
        createdAt
      }
    }
  }
}
```

## Handling rate limit errors

Once you actually exceed rate limits, the API will start returning rate limit error responses. The response HTTP status code will be 429 with a `Retry-After` header, and you can catch these by inspecting the `errors` in the response body containing the `RATE_LIMITED` error code.

```json
{
  "errors": [
    {
      "message": "Request budget exceeded: 2500 requests per hour. Try again in 2 seconds.",
      "extensions": {
        "code": "RATE_LIMITED",
        "budget": "requests",
        "limit": 2500,
        "retryAfterSeconds": 2
      }
    }
  ]
}
```

An operation-specific limit reports the same way. A mutation can also report it as data: if you select the payload's `errors` and leave out its data fields, you get a `RateLimitedError` with `retryAfterSeconds` and HTTP 200, because the body is a valid result.

```graphql
mutation GenerateVideo($workspaceId: ID!, $input: GenerateVideoInput!) {
  generateVideo(workspaceId: $workspaceId, input: $input) {
    success
    errors {
      message
      ... on RateLimitedError {
        retryAfterSeconds
      }
    }
  }
}
```
