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.
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 --> CEvery 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.
- 1Read 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.bashcurl -s -D - -o /dev/null "$PHOSRA_BASE/api/v1/platforms" | grep -i x-ratelimitLive headers on a normal
200:httpx-ratelimit-limit: 100 x-ratelimit-remaining: 92 x-ratelimit-reset: 1783336980Header Meaning X-RateLimit-LimitRequests allowed per window ( 100on the sandbox).X-RateLimit-RemainingRequests left in the current window. Watch this — when it hits 0, the next request is a429.X-RateLimit-ResetUnix epoch seconds when the window resets and Remainingreturns toLimit. - 2Drive 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 a429. This is the exact loop that produced the response below — request 101 was the first to be limited:bashfor 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 doneThe live
429— status line, both timing headers, and the body:httpHTTP/2 429 retry-after: 60 x-ratelimit-limit: 100 x-ratelimit-remaining: 0 x-ratelimit-reset: 1783337040 Too Many RequestsThe
429body is the plain-text stringToo Many Requests, not JSON. Do notJSON.parsea429— branch on the status code and read the headers. (Every other error class —400,401,403,404,409,422— returns a JSON body;429and5xxare the exceptions.)Phosra sends both timing headers on a
429:Header Value Use 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 toRetry-Afterwhen it is absent. - 3Wrap 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 — a429is 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
429means 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 unboundedwhile.
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. |
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
| # | 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
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.