Back off and retry a 429

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
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.

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).

Before you start

bash
export PHOSRA_BASE="https://phosra-api-sandbox-production.up.railway.app"

The unauthenticated discovery reads — the Trust List, /.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.

  1. 1
    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.

    bash
    curl -s -D - -o /dev/null "$PHOSRA_BASE/api/v1/platforms" | grep -i x-ratelimit

    Live headers on a normal 200:

    http
    x-ratelimit-limit: 100
    x-ratelimit-remaining: 92
    x-ratelimit-reset: 1783336980
    HeaderMeaning
    X-RateLimit-LimitRequests allowed per window (100 on the sandbox).
    X-RateLimit-RemainingRequests left in the current window. Watch this — when it hits 0, the next request is a 429.
    X-RateLimit-ResetUnix epoch seconds when the window resets and Remaining returns to Limit.

  2. 2
    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:

    bash
    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

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

    http
    HTTP/2 429
    retry-after: 60
    x-ratelimit-limit: 100
    x-ratelimit-remaining: 0
    x-ratelimit-reset: 1783337040
     
    Too Many Requests

    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.)

    Phosra sends both timing headers on a 429:

    HeaderValueUse
    Retry-After60Seconds to wait — the simplest signal, present only on the 429.
    X-RateLimit-Reset1783337040Absolute 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.

  3. 3
    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."

    bash
    # 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"

    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.

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.

StatusRetry?Strategy
429YesWait until X-RateLimit-Reset (or Retry-After), then retry once.
500YesExponential backoff: 1s, 2s, 4s, 8s, max ~4 attempts.
502 / 503YesRetry once after 3–5s — sandbox services cold-start.
401OnceRe-sign or refresh the token, then retry exactly once.
409 replayNoReuse the identical payload with the same idempotency key, or mint a fresh key.
400 / 403 / 404 / 422NoDeterministic — fix the request; a retry returns the same error.

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 for the polling loop.

The whole flow at a glance

#StepCallLive result
1Read the budgetGET /api/v1/platformslimit 100 · remaining 92 · reset 1783336980
2Exhaust the window130× GET /api/v1/platforms429 at request 101, retry-after 60
3Wait + retry oncefetchWithRetry(...)200 after the reset

Next steps