Skip to main content
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. 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

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

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.
curl -s -D - -o /dev/null "$PHOSRA_BASE/api/v1/platforms" | grep -i x-ratelimit
Live headers on a normal 200:
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

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:
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/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

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

Error reference

Every status, its class, and whether it is retryable — the full contract behind this table.

Rotate a compromised key

A 401 after a leak is a “rotate, then retry once” — the credential-rotation companion to this recipe.

Change a rule and enforce it

How async enforcement jobs are polled — the case where you never retry the trigger.

Recover a disconnected platform

When retries won’t help because the platform link itself is gone.