A 429 rate_limit_exceeded means wait for the Retry-After header and try again; a 429 quota_exhausted means the monthly allowance is used up and resets at the next UTC month. A small cache keeps repeat requests off your quota. Set its expiry to match how often the data changes.
Do it with the API
- Set up your key Get a free API key and keep it in the
NARWHAL_API_KEYenvironment variable. The Python script reads that variable and sends the key asAuthorization: Bearer YOUR_KEYtohttps://api.narwhalapi.com. - Choose an expiry for each data type Cache holiday calendars for days or weeks, economic releases until the next scheduled release, and yield curves for a day. Daily FX and Free-plan commodity prices can stay cached until the next UTC day; use those boundaries when choosing the time to live, or TTL.
- Use the cache and retry helper The cache key combines the path with sorted parameters, so requests with different parameters have separate entries. The script reads a gold price twice with the same path and TTL; the second read uses the cached response while that entry remains within its TTL.Python, a cache plus Retry-After handling
import os, random, time import requests API = "https://api.narwhalapi.com" session = requests.Session() session.headers["Authorization"] = f"Bearer {os.environ['NARWHAL_API_KEY']}" _cache: dict[str, tuple[float, dict]] = {} def get(path: str, ttl: int = 300, **params): """GET with a small in-memory cache, Retry-After on short-window 429s and backoff on 5xx.""" key = path + repr(sorted(params.items())) hit = _cache.get(key) if hit and time.time() - hit[0] < ttl: return hit[1] for attempt in range(4): r = session.get(API + path, params=params, timeout=10) if r.status_code == 429: body = r.json() if body.get("code") == "quota_exhausted": raise RuntimeError("Monthly quota used up; it resets on the 1st (UTC).") time.sleep(float(r.headers.get("Retry-After", 2))) # short-window limit: wait, then retry continue if r.status_code in (500, 503): time.sleep(2 ** attempt + random.random()) # bounded exponential backoff with jitter continue r.raise_for_status() _cache[key] = (time.time(), r.json()) return _cache[key][1] raise RuntimeError(f"Gave up on {path}") print(get("/v1/commodities/spot/XAU/USD", ttl=60)["price"]) print(get("/v1/commodities/spot/XAU/USD", ttl=60)["price"], "(from cache)") - Read the response headers Successful data responses include
X-Request-ID,X-RateLimit-Limit,X-RateLimit-Remainingand the monthlyX-RateLimit-Reset. They also includeX-RateLimit-Short-Limit,X-RateLimit-Short-RemainingandX-RateLimit-Short-Resetfor the short window; read those separately from the monthly headers.
Free allows 5,000 successful requests per UTC month and 30 requests per minute, Basic allows 100,000 per month and 120 per minute, and Pro allows 1,000,000 per month and 500 per minute.
The helper stores a response only after a successful request. An unexpired entry returns before the HTTP call, while an expired entry reaches the request loop again. Its cache is in memory, so use the shared-cache option when your web service needs that arrangement.
The default TTL is 300 seconds; the gold-price calls override it with ttl=60. Each request has a 10-second timeout, and the loop allows up to 4 attempts. For a short-window limit, the helper reads Retry-After as a delay in seconds and waits before another attempt. If that header is absent, it uses 2 seconds. The helper raises an error with the requested path when its attempts are used up. Other unsuccessful statuses reach raise_for_status() without adding a response to the cache.
Handle retries without code
In Zapier, Make or n8n, add a short delay step or enable the platform's retry on error. These platforms already show the HTTP status. For a 429, read the problem body's code before choosing whether to delay and retry or wait for the monthly reset.
Schedule requests at a sensible interval for the data you use. The same cache timing applies to an automation: holiday calendars can wait days or weeks between reads, while an economic release can stay cached until its next scheduled release. Space out a burst of requests under the per-minute limit.
Which option to use when
| Situation | Best option | Why |
|---|---|---|
| A script runs once a day | No cache needed | The script already spaces its reads a day apart |
| A web service repeats requests | An in-memory or shared cache | A TTL per data type keeps repeat reads off quota |
| Many requests arrive in a burst | Space the requests out | The per-minute limit still applies |
A TTL determines how long a response can be reused. Request spacing controls when calls reach the API. A web service can need both: caching covers repeated reads of the same path and parameters, and spacing addresses the calls that still reach the API. An hourly cache still leaves requests for different paths or parameters as separate calls. Include those calls when deciding whether a burst needs spacing, even when other reads use cached responses.
Does a 429 cost monthly quota?
No, neither 429 response consumes monthly quota. Error bodies use application/problem+json and include status, code, detail and request_id. The code identifies the reason for the response even though both errors have the same HTTP status.
When the monthly allowance is exhausted, wait for the next UTC month or upgrade through plans and limits. The helper raises an error for quota_exhausted instead of continuing its retry loop, because a short delay does not reset the monthly allowance.
Can clients sharing an address be throttled together?
Yes, clients sharing an address can be throttled together: a separate abuse bucket allows 120 requests per minute with a burst of 20 for one network identity. Account limits and this shared bucket both matter when requests arrive in a burst.
Which other errors should I retry?
Retry idempotent requests after 500 or 503 with bounded exponential backoff and jitter. The helper increases the delay between attempts and adds a random component; after its bounded request loop, it raises an error if no response has succeeded.
Do not retry 400, or retry 401 with the same key. A 404 series_not_found may become available after a release, so keep that case separate from the server errors handled by the backoff loop.
Worked example: a 5,000 request month
A 5,000 request month is about 166 requests a day. Start with a dashboard that reads 6 values every 15 minutes. That refresh schedule makes 96 reads of each value per day, so multiply 6 by 96 to get 576 daily requests.
Then cache each value for an hour. Dashboard refreshes within that hour reuse the stored response, which cuts API requests to 144 a day. A slower refresh is another option when the dashboard would otherwise exceed its allowance.
| Calculation | Daily requests |
|---|---|
| 5,000 request monthly allowance, expressed as a daily average | About 166 |
| 6 values × 96 reads with a 15-minute refresh | 576 |
| The same 6 values, each cached for an hour | 144 |
Published .
