# Rate limits and retries

Rate limits are counted in weight units, so heavier requests cost more. Going over the limit returns 429. Overload or temporary unavailability returns 503. Both responses tell you when to retry.

## Request weight

| Endpoint | Weight |
|---|---|
| `GET /v1/me` | 1 |
| `GET /v1/workspaces` | 1 |
| `GET /v1/campaigns` | 2 |
| `GET /v1/campaigns/{id}` | 1 |
| `GET /v1/leads` | 2 |
| `GET /v1/leads/{id}` | 1 |
| `GET /v1/leads/{id}/messages` | 3 |
| `GET /v1/events` | 2, 3 with `include_text=true` |

## Limits by plan

Limits apply both to each key and to the whole organization, across all of its keys combined.

| Window | Pro | Premium | Enterprise |
|---|---|---|---|
| Per key, per second | 5 | 10 | 10 |
| Per key, per minute | 300 | 600 | 600 |
| Per organization, per second | 10 | 25 | 25 |
| Per organization, per minute | 600 | 1500 | 1500 |
| Concurrent requests per key | 5 | 5 | 5 |
| Concurrent requests per organization | 10 | 10 | 10 |

For example, on the Pro plan one key can make 2 lead list requests per second (weight 2, limit 5) and up to 150 such requests per minute. [`GET /v1/me`](https://docs.melioralab.io/en/reference/api-context/) returns the key's current limits in the `limits` field.

The API also limits requests from a single IP address: 20 per second on average, with short bursts up to 40. If one address receives 20 responses with status 401 within 5 minutes, the address is blocked for 15 minutes, and all its requests get 429 with `Retry-After`. Check your key before you retry requests that failed with 401.

Sometimes limits are temporarily reduced, for example during maintenance on our side. When that happens, `limits.mode` returns `emergency` and the limit fields show lower values. Code that reads the response headers adjusts on its own.

## Headers

Successful responses and 429 responses for key and organization limits include the limit state. A 429 response for the IP address limit doesn't include these headers, only `Retry-After`.

```http
RateLimit-Policy: "key_second";q=5;w=1, "key_minute";q=300;w=60, "org_second";q=10;w=1, "org_minute";q=600;w=60
RateLimit: "key_second";r=3;t=1, "key_minute";r=291;t=60, "org_second";r=8;t=1, "org_minute";r=583;t=60
RateLimit-Limit: 5
RateLimit-Remaining: 3
RateLimit-Reset: 1
```

| Header | Value |
|---|---|
| `RateLimit-Policy` | The windows: name, limit `q` in weight units and window length `w` in seconds. |
| `RateLimit` | For each window, the remaining amount `r` and the seconds until reset `t`. |
| `RateLimit-Limit` | The limit of the window closest to running out. |
| `RateLimit-Remaining` | The amount remaining in that window. |
| `RateLimit-Reset` | Seconds until that window resets. |

## 429 response

If a request's weight exceeds the remaining amount, or you exceed the number of concurrent requests, you get 429 `rate_limit_exceeded` with a `Retry-After` header in seconds. The request was not processed. Wait `Retry-After` seconds and add random jitter so that several processes don't retry at the same moment.

## 503 response

503 means the request wasn't processed right now. The code tells you why:

| Code | Reason | `Retry-After` |
|---|---|---|
| `server_busy` | The service is overloaded | 1 second |
| `service_unavailable` | The data couldn't be read for now | 5 seconds |
| `integration_initializing` | A new key is still being prepared | 5 seconds |

Retry with a growing delay: start with `Retry-After`, then double it up to 60 seconds. If you keep getting 503 for more than a few minutes, contact [support](https://docs.melioralab.io/en/support/) and include the `Request-Id`.

## Retries are safe

All v1 routes only read data, so you can retry any request without side effects. An error doesn't advance the cursor: retrying with the same cursor returns the same page.

```js
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function meliora(path, params = {}, maxAttempts = 6) {
  const url = new URL(`https://api.melioralab.io/v1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(url, {
        headers: { Authorization: `Bearer ${process.env.MELIORA_API_KEY}` },
        signal: AbortSignal.timeout(15_000),
      });
    } catch (networkError) {
      if (attempt >= maxAttempts) throw networkError;
      await sleep(Math.min(60, 2 ** attempt) * 1000);
      continue;
    }
    if (res.ok) return res.json();
    const problem = await res.json().catch(() => ({}));
    if ((res.status === 429 || res.status === 503) && attempt < maxAttempts) {
      const base = Number(res.headers.get('Retry-After')) || 5;
      const wait = Math.min(60, base * (res.status === 503 ? 2 ** (attempt - 1) : 1));
      await sleep((wait + Math.random() * Math.max(1, wait * 0.25)) * 1000);
      continue;
    }
    const error = new Error(`Meliora API ${res.status} ${problem.code ?? ''}`);
    Object.assign(error, { status: res.status, code: problem.code, requestId: res.headers.get('Request-Id') });
    throw error;
  }
}
```
```python
import os
import random
import time

import requests

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['MELIORA_API_KEY']}"

class MelioraError(Exception):
    def __init__(self, status, code, request_id):
        super().__init__(f"Meliora API {status} {code}")
        self.status, self.code, self.request_id = status, code, request_id

def meliora(path, params=None, max_attempts=6):
    for attempt in range(1, max_attempts + 1):
        try:
            resp = session.get(f"https://api.melioralab.io/v1{path}", params=params, timeout=15)
        except requests.RequestException:
            if attempt == max_attempts:
                raise
            time.sleep(min(60, 2 ** attempt))
            continue
        if resp.ok:
            return resp.json()
        if resp.status_code in (429, 503) and attempt < max_attempts:
            base = int(resp.headers.get("Retry-After", "5"))
            wait = min(60, base * (2 ** (attempt - 1) if resp.status_code == 503 else 1))
            time.sleep(wait + random.uniform(0, max(1, wait * 0.25)))
            continue
        try:
            code = resp.json().get("code")
        except ValueError:
            code = None
        raise MelioraError(resp.status_code, code, resp.headers.get("Request-Id"))
    raise RuntimeError("unreachable")
```

## Staying within limits

- Poll the [event feed](https://docs.melioralab.io/en/events-polling/) once a minute instead of walking all leads on a schedule.
- Use `limit=100` for lists and up to 200 for the feed: fewer requests for the same amount of data.
- Filter on our side: `campaign_id`, `status` and `username` cost less than exporting the whole list.
- Don't run more than 5 concurrent requests per key. Extra requests get 429.
- Request message text only where you need it: events with `include_text=true` and conversations have a weight of 3.
