> ## Documentation Index
> Fetch the complete documentation index at: https://dev.tryno.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Erros e limites

> Os dois envelopes de erro da API da Tryno, os status que você precisa tratar, o teto de requisições por chave e código pronto de retentativa com backoff.

## Dois envelopes

A Tryno tem duas superfícies ([veja a diferença](/introduction)) e elas
respondem erros em formatos diferentes. Isto não é um acidente que vai ser
"corrigido" — é a fronteira entre o contrato público e a API interna.

<CardGroup cols={2}>
  <Card title="Platform API v1" icon="key-round">
    Envelope **tipado**:

    ```json theme={"system"}
    {
      "error": {
        "type": "forbidden",
        "message": "This key is missing the 'orders:read' scope."
      }
    }
    ```
  </Card>

  <Card title="API da aplicação" icon="app-window">
    Envelope **plano**, só com um código:

    ```json theme={"system"}
    { "error": "no_scopes" }
    ```
  </Card>
</CardGroup>

<Warning>
  Ramifique sempre no **`type`** (ou no código), nunca no `message`. A mensagem é
  escrita para humanos, está em inglês e pode mudar sem aviso; o `type` é o
  contrato.
</Warning>

## Status em `/api/v1`

| Status | `error.type`   | Quando                                                          | O que fazer                                                                            |
| ------ | -------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `401`  | `unauthorized` | Cabeçalho ausente ou malformado, chave desconhecida ou revogada | Verifique o header `Authorization: Bearer …`. **Não** tente de novo com a mesma chave. |
| `403`  | `forbidden`    | Chave válida, sem o escopo daquele endpoint                     | Crie uma chave com o escopo certo. Repetir não resolve.                                |
| `404`  | `not_found`    | Recurso inexistente **ou** fora da tribo daquela chave          | Confirme o identificador e a tribo.                                                    |
| `429`  | `rate_limited` | Teto de requisições estourado                                   | Espere o `Retry-After` e tente de novo.                                                |

Estes quatro são os únicos `error.type` que `/v1` emite hoje. Ainda assim,
escreva o seu cliente com um ramo `default` — um `type` novo pode aparecer, e
tratar desconhecido como "erro não recuperável" é mais seguro do que estourar.

O `401` é deliberadamente indistinguível entre "chave inexistente" e "chave
revogada": informar a diferença ajudaria apenas quem está sondando chaves.

O `404` também cobre o caso "existe, mas em outra tribo" — uma chave nunca
confirma a existência de dados de outra tribo. Hoje ele só aparece em
`GET /v1/members/{username}`; as listas devolvem `data: []`, e **lista vazia não
é erro**.

<Note>
  **A ordem da checagem importa.** A autenticação roda antes do limite de taxa, e
  o limite de taxa antes da checagem de escopo. Então uma chave inválida sempre
  recebe `401` (nunca `429`), e uma chave válida sem escopo recebe `429` antes de
  `403` se estiver estourando o teto.
</Note>

## Códigos da API da aplicação

Os endpoints de administração (criar chave, registrar webhook) usam o envelope
plano:

| Código         | Status | Quando                                                       |
| -------------- | ------ | ------------------------------------------------------------ |
| `unauthorized` | `401`  | Sem sessão válida                                            |
| `forbidden`    | `403`  | Sessão válida, mas sem papel de administrador na tribo       |
| `not_found`    | `404`  | Tribo ou recurso inexistente                                 |
| `invalid_name` | `400`  | Nome de chave vazio                                          |
| `no_scopes`    | `400`  | Nenhum escopo válido selecionado                             |
| `invalid_url`  | `400`  | URL de webhook rejeitada (veja [Webhooks](/guides/webhooks)) |
| `no_events`    | `400`  | Nenhum evento válido selecionado                             |
| `empty`        | `400`  | `PATCH` sem nenhum campo reconhecido                         |

<Warning>
  Repare que `no_scopes` e `no_events` também aparecem quando você **enviou**
  valores, mas todos eram inválidos: escopos e eventos desconhecidos são
  filtrados em silêncio antes da validação. Um `400 no_events` costuma ser um
  typo no nome do evento, não uma lista vazia.
</Warning>

## Limites de uso

A Platform API permite **120 requisições por minuto por chave**.

```http Resposta ao estourar theme={"system"}
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
```

```json theme={"system"}
{
  "error": {
    "type": "rate_limited",
    "message": "Too many requests for this API key. Try again shortly."
  }
}
```

<Warning>
  **`Retry-After` é o único cabeçalho de limite que a Tryno envia**, e ele só
  aparece na resposta `429`, sempre com o valor `60`. Não existem
  `X-RateLimit-Limit`, `X-RateLimit-Remaining` nem `X-RateLimit-Reset`: você
  **não** consegue saber quanto do orçamento já gastou antes de estourar. Se o
  seu cliente depende de ler o saldo restante, ele precisa contar do próprio
  lado.
</Warning>

O limite é **por chave, não por IP**: uma chave vazada não pode ser usada em
paralelo de vários lugares sem bater no teto. É também por isso que vale ter uma
chave por integração — uma automação em laço não derruba as outras.

### Como a janela funciona

É uma **janela fixa** de 60 segundos, não deslizante: o contador zera de uma vez
quando a janela expira. Consequência prática: 120 chamadas no segundo 59 e mais
120 no segundo 61 passam as duas — 240 chamadas em dois segundos. E o inverso
também vale: se você gastou o orçamento no início da janela, espera até 60s pela
liberação, mesmo que o `Retry-After` sugira exatamente isso.

<Note>
  O contador é mantido **por instância do worker** e é *best-effort*: sob tráfego
  distribuído por vários pontos de presença, o teto efetivo pode ficar acima de
  120/min. Trate 120/min como a **garantia mínima** que você tem, não como um
  número em que dá para encostar de propósito. Há também proteção no WAF acima
  disso, que não é configurável por chave.
</Note>

<Tip>
  **Como não chegar perto do teto:** use `pageSize=100` em vez de páginas
  pequenas (8× menos requisições para varrer a mesma lista), cacheie o que muda
  pouco, e prefira [webhooks](/guides/webhooks) a varreduras periódicas. A maior
  fonte de `429` é código que relê a lista inteira só para descobrir o que mudou.
</Tip>

## Retentativa com backoff exponencial e jitter

Este é o cliente mínimo correto: repete `429` e `5xx`, respeita o `Retry-After`
quando ele existe, aplica **jitter** para não sincronizar retentativas
concorrentes, e **falha rápido** em `401`/`403`/`404` — que repetir nunca
conserta.

<CodeGroup>
  ```js Node.js / TypeScript theme={"system"}
  const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
  const MAX_RETRIES = 5;
  const BASE_DELAY_MS = 1_000;
  const MAX_DELAY_MS = 60_000;

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

  /** Backoff exponencial com "full jitter": aleatório em [0, teto]. */
  function backoffMs(attempt) {
    const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
    return Math.random() * ceiling;
  }

  export async function trynoFetch(path, { apiKey, ...init } = {}) {
    let lastError;

    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      let res;
      try {
        res = await fetch(`https://tryno.io/api/v1${path}`, {
          ...init,
          headers: { ...init.headers, Authorization: `Bearer ${apiKey}` },
        });
      } catch (err) {
        // Falha de rede — também é retentável.
        lastError = err;
        if (attempt === MAX_RETRIES) throw err;
        await sleep(backoffMs(attempt));
        continue;
      }

      if (res.ok) return res.json();

      const body = await res.json().catch(() => ({}));
      const type = body?.error?.type;

      // Erros de configuração: repetir não conserta. Falhe alto.
      if (!RETRYABLE_STATUS.has(res.status)) {
        throw new Error(`Tryno ${res.status} ${type ?? "unknown"}: ${body?.error?.message ?? ""}`);
      }

      if (attempt === MAX_RETRIES) {
        throw new Error(`Tryno ${res.status} ${type ?? "unknown"} após ${MAX_RETRIES} retentativas`);
      }

      // Retry-After vem em SEGUNDOS e só aparece no 429 (sempre "60").
      const retryAfter = Number(res.headers.get("retry-after"));
      const wait = Number.isFinite(retryAfter) && retryAfter > 0
        // Respeite o servidor, mas some jitter: senão todos os seus workers
        // voltam exatamente no mesmo milissegundo e estouram o teto de novo.
        ? retryAfter * 1000 + Math.random() * 1_000
        : backoffMs(attempt);

      await sleep(wait);
    }

    throw lastError ?? new Error("Tryno: retentativas esgotadas");
  }
  ```

  ```python Python theme={"system"}
  import os
  import random
  import time

  import requests

  RETRYABLE_STATUS = {429, 500, 502, 503, 504}
  MAX_RETRIES = 5
  BASE_DELAY_S = 1.0
  MAX_DELAY_S = 60.0

  BASE_URL = "https://tryno.io/api/v1"


  def _backoff_s(attempt: int) -> float:
      """Backoff exponencial com full jitter: aleatório em [0, teto]."""
      ceiling = min(MAX_DELAY_S, BASE_DELAY_S * (2**attempt))
      return random.uniform(0, ceiling)


  class TrynoError(RuntimeError):
      def __init__(self, status: int, type_: str | None, message: str = ""):
          super().__init__(f"Tryno {status} {type_ or 'unknown'}: {message}")
          self.status = status
          self.type = type_


  def tryno_get(path: str, params: dict | None = None, api_key: str | None = None):
      key = api_key or os.environ["TRYNO_API_KEY"]
      session = requests.Session()

      for attempt in range(MAX_RETRIES + 1):
          try:
              res = session.get(
                  f"{BASE_URL}{path}",
                  params=params,
                  headers={"Authorization": f"Bearer {key}"},
                  timeout=10,
              )
          except requests.RequestException:
              if attempt == MAX_RETRIES:
                  raise
              time.sleep(_backoff_s(attempt))
              continue

          if res.ok:
              return res.json()

          try:
              err = res.json().get("error", {})
          except ValueError:
              err = {}

          # 401 / 403 / 404: configuração errada. Falhe alto, não repita.
          if res.status_code not in RETRYABLE_STATUS or attempt == MAX_RETRIES:
              raise TrynoError(res.status_code, err.get("type"), err.get("message", ""))

          # Retry-After chega em SEGUNDOS e só no 429. Some jitter mesmo assim.
          retry_after = res.headers.get("Retry-After")
          if retry_after and retry_after.isdigit():
              wait = int(retry_after) + random.uniform(0, 1)
          else:
              wait = _backoff_s(attempt)

          time.sleep(wait)

      raise TrynoError(0, "exhausted")
  ```
</CodeGroup>

<Warning>
  **Some jitter mesmo quando respeitar o `Retry-After`.** Se dez processos seus
  levarem `429` no mesmo segundo e todos esperarem exatamente 60s, os dez voltam
  juntos e estouram o teto de novo — a mesma manada, um minuto depois. Um
  aleatório de até 1s entre eles resolve.
</Warning>

## Como tratar, resumido

| Situação        | Estratégia                                                               |
| --------------- | ------------------------------------------------------------------------ |
| `401` / `403`   | Erro de configuração. Falhe alto, alerte um humano, **não repita**.      |
| `404`           | Dado ausente. Trate como estado, não como falha.                         |
| `429`           | Espere o `Retry-After` (segundos) + jitter.                              |
| `5xx`           | Backoff exponencial com jitter e teto de tentativas.                     |
| Timeout de rede | Retentável — mas garanta que o `GET` é idempotente (em `/v1`, sempre é). |

<Note>
  Toda a `/v1` é **somente leitura**, então qualquer requisição é segura de
  repetir: não há efeito colateral duplicado possível. Isso é o que torna a
  retentativa agressiva aceitável aqui — o cuidado com duplicidade fica do lado
  de webhooks, onde o [guia de webhooks](/guides/webhooks) detalha a
  idempotência.
</Note>

Se o seu caso é reagir a mudanças, [webhooks](/guides/webhooks) evitam a maior
fonte de `429`: varrer listas em laço só para descobrir o que mudou.
