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

# Back off and retry a 429

> Read the live rate-limit headers on every response, detect a real 429, and retry correctly by waiting until the window resets — a copy-paste retry wrapper in cURL, TypeScript, and Python, verified against a genuine 429 from the open sandbox.

Every busy integration eventually hits a rate limit. Handling it well is the difference between a
brief pause and a cascade of failed requests. This recipe reads the **live rate-limit headers**
Phosra puts on every `/api/v1/*` response, drives the sandbox into a **real `429`**, and wraps your
calls in a retry that waits exactly as long as the server tells it to — no guessing, no thundering
herd.

```mermaid theme={null}
flowchart LR
  A["GET /api/v1/platforms<br/>read X-RateLimit-*"] --> B{"remaining > 0?"}
  B -->|yes| C["send request"]
  B -->|no| D["sleep until<br/>X-RateLimit-Reset"]
  C --> E{"status 429?"}
  E -->|no| F["done"]
  E -->|yes| D
  D --> C
```

Every request and response below is **verbatim live output**, captured against
`https://phosra-api-sandbox-production.up.railway.app`. No API key, nothing to install — the
sandbox rate-limits anonymous callers on the same headers production uses.

<Info>
  **Sandbox-first.** These calls run against the open sandbox with no credential. The headers and
  `429` shape are identical in production: swap the base URL for `https://prodapi.phosra.com` and add
  a `phosra_live_…` key. Only the `X-RateLimit-Limit` value differs (the sandbox window is `100`).
</Info>

## Before you start

```bash theme={null}
export PHOSRA_BASE="https://phosra-api-sandbox-production.up.railway.app"
```

<Note>
  The unauthenticated discovery reads — the [Trust List](/guides/test-in-sandbox),
  `/.well-known/ocss/*`, editions — are **unmetered** and carry no rate-limit headers. Only the
  `/api/v1/*` surface is counted. This recipe uses `/api/v1/platforms` because it needs no body.
</Note>

<Steps>
  <Step title="Read your rate-limit budget off any response">
    Every `/api/v1/*` response carries three headers. Read them on the response you already made — you
    do **not** need a separate "check my quota" call.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -s -D - -o /dev/null "$PHOSRA_BASE/api/v1/platforms" | grep -i x-ratelimit
      ```

      ```typescript TypeScript theme={null}
      const BASE = "https://phosra-api-sandbox-production.up.railway.app";

      const res = await fetch(`${BASE}/api/v1/platforms`);
      console.log({
        limit: res.headers.get("x-ratelimit-limit"),
        remaining: res.headers.get("x-ratelimit-remaining"),
        reset: res.headers.get("x-ratelimit-reset"),
      });
      ```

      ```python Python theme={null}
      import requests

      BASE = "https://phosra-api-sandbox-production.up.railway.app"

      res = requests.get(f"{BASE}/api/v1/platforms", timeout=30)
      print({
          "limit": res.headers["X-RateLimit-Limit"],
          "remaining": res.headers["X-RateLimit-Remaining"],
          "reset": res.headers["X-RateLimit-Reset"],
      })
      ```
    </CodeGroup>

    Live headers on a normal `200`:

    ```http theme={null}
    x-ratelimit-limit: 100
    x-ratelimit-remaining: 92
    x-ratelimit-reset: 1783336980
    ```

    | Header                  | Meaning                                                                                              |
    | ----------------------- | ---------------------------------------------------------------------------------------------------- |
    | `X-RateLimit-Limit`     | Requests allowed per window (`100` on the sandbox).                                                  |
    | `X-RateLimit-Remaining` | Requests left in the current window. **Watch this** — when it hits `0`, the next request is a `429`. |
    | `X-RateLimit-Reset`     | **Unix epoch seconds** when the window resets and `Remaining` returns to `Limit`.                    |
  </Step>

  <Step title="Drive a real 429">
    Exhaust the window and the very next request is rejected. Loop past the limit and you will see the
    counter fall to `0`, then a `429`. This is the exact loop that produced the response below —
    request **101** was the first to be limited:

    <CodeGroup>
      ```bash cURL theme={null}
      for i in $(seq 1 130); do
        code=$(curl -s -o /dev/null -w "%{http_code}" "$PHOSRA_BASE/api/v1/platforms")
        if [ "$code" = "429" ]; then
          echo "429 at request $i"
          curl -s -D - -o /dev/null "$PHOSRA_BASE/api/v1/platforms" \
            | grep -iE 'http/|retry-after|x-ratelimit'
          break
        fi
      done
      ```

      ```typescript TypeScript theme={null}
      for (let i = 1; i <= 130; i++) {
        const r = await fetch(`${BASE}/api/v1/platforms`);
        if (r.status === 429) {
          console.log(`429 at request ${i}`, {
            retryAfter: r.headers.get("retry-after"),
            reset: r.headers.get("x-ratelimit-reset"),
            remaining: r.headers.get("x-ratelimit-remaining"),
          });
          break;
        }
      }
      ```

      ```python Python theme={null}
      for i in range(1, 131):
          r = requests.get(f"{BASE}/api/v1/platforms", timeout=30)
          if r.status_code == 429:
              print(f"429 at request {i}", {
                  "retry_after": r.headers.get("Retry-After"),
                  "reset": r.headers.get("X-RateLimit-Reset"),
                  "remaining": r.headers.get("X-RateLimit-Remaining"),
              })
              break
      ```
    </CodeGroup>

    The live `429` — status line, both timing headers, and the body:

    ```http theme={null}
    HTTP/2 429
    retry-after: 60
    x-ratelimit-limit: 100
    x-ratelimit-remaining: 0
    x-ratelimit-reset: 1783337040

    Too Many Requests
    ```

    <Warning>
      The `429` body is the **plain-text** string `Too Many Requests`, not JSON. Do not `JSON.parse` a
      `429` — branch on the status code and read the headers. (Every other error class — `400`, `401`,
      `403`, `404`, `409`, `422` — returns a JSON body; `429` and `5xx` are the exceptions.)
    </Warning>

    Phosra sends **both** timing headers on a `429`:

    | Header              | Value        | Use                                                                                                                |
    | ------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------ |
    | `Retry-After`       | `60`         | Seconds to wait — the simplest signal, present only on the `429`.                                                  |
    | `X-RateLimit-Reset` | `1783337040` | Absolute Unix time the window resets — present on every response, so you can wait *before* you ever hit the limit. |

    Prefer `X-RateLimit-Reset` (it lets you pace proactively); fall back to `Retry-After` when it is
    absent.
  </Step>

  <Step title="Wrap every call in a reset-aware retry">
    Put it together: on a `429`, sleep until the reset the server named, then retry **once**. This is the
    whole contract — a `429` is never a failure you surface to the user, it is a "wait and try again."

    <CodeGroup>
      ```bash cURL theme={null}
      # fetch_with_retry URL — retries a single 429 after waiting for the reset.
      fetch_with_retry() {
        local url="$1"
        local body headers code reset now
        headers=$(curl -s -D - -o /tmp/body "$url" -w "%{http_code}")
        code=$(printf '%s' "$headers" | tail -c 3)
        if [ "$code" = "429" ]; then
          reset=$(printf '%s' "$headers" | grep -i '^x-ratelimit-reset:' | tr -d '\r' | awk '{print $2}')
          now=$(date +%s)
          sleep $(( reset > now ? reset - now : 1 ))
          curl -s "$url" -o /tmp/body            # retry once
        fi
        cat /tmp/body
      }

      fetch_with_retry "$PHOSRA_BASE/api/v1/platforms"
      ```

      ```typescript TypeScript theme={null}
      async function fetchWithRetry(url: string, init?: RequestInit): Promise<Response> {
        let res = await fetch(url, init);
        if (res.status === 429) {
          const reset = Number(res.headers.get("x-ratelimit-reset")); // epoch seconds
          const retryAfter = Number(res.headers.get("retry-after"));  // fallback
          const waitMs = Number.isFinite(reset)
            ? Math.max(0, reset * 1000 - Date.now())
            : (retryAfter || 1) * 1000;
          await new Promise((r) => setTimeout(r, waitMs));
          res = await fetch(url, init); // retry once — the window has reset
        }
        return res;
      }

      const res = await fetchWithRetry(`${BASE}/api/v1/platforms`);
      // res.status === 200
      ```

      ```python Python theme={null}
      import time

      def fetch_with_retry(url: str, **kwargs) -> requests.Response:
          res = requests.get(url, timeout=30, **kwargs)
          if res.status_code == 429:
              reset = res.headers.get("X-RateLimit-Reset")
              if reset:
                  wait = max(0, int(reset) - int(time.time()))
              else:
                  wait = int(res.headers.get("Retry-After", "1"))
              time.sleep(wait)
              res = requests.get(url, timeout=30, **kwargs)  # retry once
          return res

      res = fetch_with_retry(f"{BASE}/api/v1/platforms")
      # res.status_code == 200
      ```
    </CodeGroup>

    <Note>
      **Retry once, not forever.** After one wait-and-retry the window has reset, so a second `429` means
      you are sending faster than the limit sustains — throttle your own concurrency instead of looping.
      The right ceiling is a bounded retry (1–2 attempts), never an unbounded `while`.
    </Note>
  </Step>
</Steps>

## When to retry — the full table

A `429` is retryable; most errors are not. Retrying a deterministic `4xx` just returns the same
error and burns your budget.

| Status                        | Retry? | Strategy                                                                        |
| ----------------------------- | ------ | ------------------------------------------------------------------------------- |
| `429`                         | Yes    | Wait until `X-RateLimit-Reset` (or `Retry-After`), then retry **once**.         |
| `500`                         | Yes    | Exponential backoff: `1s, 2s, 4s, 8s`, max \~4 attempts.                        |
| `502` / `503`                 | Yes    | Retry once after 3–5s — sandbox services cold-start.                            |
| `401`                         | Once   | Re-sign or refresh the token, then retry exactly once.                          |
| `409 replay`                  | No     | Reuse the identical payload with the same idempotency key, or mint a fresh key. |
| `400` / `403` / `404` / `422` | No     | Deterministic — fix the request; a retry returns the same error.                |

<Info>
  Async jobs (a `202` or a `running` enforcement job) are **polled, not retried** — poll the
  job endpoint named in the response rather than re-POSTing the trigger. See
  [Change a rule and enforce it](/guides/recipes/policy-change-and-enforce) for the polling loop.
</Info>

## The whole flow at a glance

| # | Step               | Call                         | Live result                                   |
| - | ------------------ | ---------------------------- | --------------------------------------------- |
| 1 | Read the budget    | `GET /api/v1/platforms`      | `limit 100 · remaining 92 · reset 1783336980` |
| 2 | Exhaust the window | 130× `GET /api/v1/platforms` | `429` at request 101, `retry-after 60`        |
| 3 | Wait + retry once  | `fetchWithRetry(...)`        | `200` after the reset                         |

## Next steps

<CardGroup cols={2}>
  <Card title="Error reference" icon="triangle-exclamation" href="/errors">
    Every status, its `class`, and whether it is retryable — the full contract behind this table.
  </Card>

  <Card title="Rotate a compromised key" icon="key" href="/guides/recipes/rotate-compromised-key">
    A `401` after a leak is a "rotate, then retry once" — the credential-rotation companion to this recipe.
  </Card>

  <Card title="Change a rule and enforce it" icon="bolt" href="/guides/recipes/policy-change-and-enforce">
    How async enforcement jobs are polled — the case where you never retry the trigger.
  </Card>

  <Card title="Recover a disconnected platform" icon="plug-circle-exclamation" href="/guides/recipes/recover-disconnected-platform">
    When retries won't help because the platform link itself is gone.
  </Card>
</CardGroup>
