Caching and Rate Limiting NREL API Requests in Python
The scenario: a nightly resource refresh starts failing at 03:40 with 429 Too Many Requests, and
the cause turns out to be an analyst exploring the same API interactively that afternoon on the same
key. The quota is per key, the pipeline had no idea the budget was already spent, and every retry
made it worse. This page builds the client that shares a budget and stops asking for things it
already has, and it extends
open energy data portals.
Root-cause analysis
Three properties of energy-portal APIs make naive clients fail predictably.
- Quotas are per key, not per process. NREL enforces an hourly and a daily cap against the API key, so any limiter scoped to one process is blind to every other consumer of that key — including a notebook and a colleague.
- The same request is made repeatedly. Resource data for a fixed point and year does not change between runs, yet a pipeline without a cache re-fetches it on every execution, spending quota on bytes it already has on disk.
- Retries amplify. A 429 answered by an immediate retry consumes another unit of quota and arrives sooner than the window resets, so a naive retry loop turns a brief throttle into a sustained one.
Pre-flight validation
Before any request, know what the budget is and how much of it is left. NREL returns the remaining allowance in response headers, so the client can carry an accurate picture rather than a guess.
from dataclasses import dataclass
@dataclass
class QuotaState:
limit_hour: int
remaining_hour: int
limit_day: int
remaining_day: int
@classmethod
def from_headers(cls, headers) -> "QuotaState":
"""NREL publishes the remaining allowance on every response."""
return cls(
limit_hour=int(headers.get("X-RateLimit-Limit", 0) or 0),
remaining_hour=int(headers.get("X-RateLimit-Remaining", 0) or 0),
limit_day=int(headers.get("X-RateLimit-Limit-Day", 0) or 0),
remaining_day=int(headers.get("X-RateLimit-Remaining-Day", 0) or 0),
)
def can_spend(self, n: int, *, reserve: int = 50) -> bool:
"""Keep a reserve so an interactive user is never locked out by a batch job."""
return self.remaining_hour - n >= reserve and self.remaining_day - n >= reserve
The reserve is the part worth arguing for: a batch job that spends the last request of the hour leaves an analyst unable to check a single site, and the resulting workaround is usually a second key that nobody tracks.
Fix implementation
The client below is content-addressed, so an identical request is answered from disk, and rate limited through a shared token bucket, so every process on the key draws from one budget.
import hashlib
import json
import os
import time
from pathlib import Path
import requests
class SharedTokenBucket:
"""A token bucket held in a file, so several processes share one budget."""
def __init__(self, path: str, *, rate_per_sec: float, capacity: int):
self.path = Path(path)
self.rate = rate_per_sec
self.capacity = capacity
self.path.parent.mkdir(parents=True, exist_ok=True)
def acquire(self, *, timeout_s: float = 120.0) -> None:
deadline = time.monotonic() + timeout_s
while True:
state = self._read()
now = time.time()
tokens = min(
self.capacity,
state["tokens"] + (now - state["updated"]) * self.rate,
)
if tokens >= 1.0:
self._write({"tokens": tokens - 1.0, "updated": now})
return
if time.monotonic() > deadline:
raise TimeoutError("rate limiter: no token available within the timeout")
time.sleep(max(0.05, (1.0 - tokens) / self.rate))
def _read(self) -> dict:
try:
return json.loads(self.path.read_text())
except (OSError, json.JSONDecodeError):
return {"tokens": float(self.capacity), "updated": time.time()}
def _write(self, state: dict) -> None:
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(state))
os.replace(tmp, self.path) # atomic, so a crash cannot corrupt the budget
def cached_get(
url: str,
params: dict,
*,
cache_dir: str,
bucket: SharedTokenBucket,
session: requests.Session | None = None,
ttl_s: float | None = None,
) -> tuple[dict, dict]:
"""Content-addressed GET: identical parameters are answered from disk."""
key_material = json.dumps({"url": url, "params": _without_key(params)}, sort_keys=True)
key = hashlib.sha256(key_material.encode()).hexdigest()[:32]
blob = Path(cache_dir) / key[:2] / f"{key}.json"
if blob.exists() and (ttl_s is None or time.time() - blob.stat().st_mtime < ttl_s):
return json.loads(blob.read_text()), {"cache": "hit", "key": key}
bucket.acquire()
sess = session or requests.Session()
response = sess.get(url, params=params, timeout=60)
response.raise_for_status()
payload = response.json()
blob.parent.mkdir(parents=True, exist_ok=True)
blob.write_text(json.dumps(payload))
quota = QuotaState.from_headers(response.headers)
return payload, {"cache": "miss", "key": key, "quota": quota.__dict__}
def _without_key(params: dict) -> dict:
"""The API key must never enter the cache key — it is a credential, not a parameter."""
return {k: v for k, v in params.items() if k.lower() not in {"api_key", "apikey"}}
Excluding the API key from the cache key matters twice over: it keeps a credential out of a filename, and it means two keys share one cache instead of duplicating every payload.
Fallback routing and performance tuning
- Give each job its own key. Quotas are per key, so a dedicated pipeline key means an interactive session cannot exhaust the nightly budget, and the logs identify which consumer hit the limit.
- Cache the raw payload, not the parsed frame. The parse is cheap and the bytes are the evidence; a raw cache also survives a change to the parser.
- Set a TTL by data type, not globally. A historical NSRDB year never changes and deserves no TTL at all; a queue endpoint that updates monthly deserves a month.
- Use conditional requests where the endpoint supports them. An
If-None-Matchthat returns 304 costs a request against the quota but almost no bytes, which matters when the payload is large and the change is rare. - Back off on the header, not on a guess. When
X-RateLimit-Remainingreaches zero, sleep until the window resets rather than retrying with exponential backoff into a wall.
Downstream validation
def assert_quota_accounting(stats: dict, *, min_hit_rate: float = 0.6) -> None:
"""A pipeline that re-fetches what it already has is spending quota on nothing."""
total = stats["hits"] + stats["misses"]
assert total > 0, "no requests recorded — the accounting is not wired up"
hit_rate = stats["hits"] / total
assert hit_rate >= min_hit_rate, (
f"cache hit rate {hit_rate:.0%} below {min_hit_rate:.0%} — "
"check the cache key for a volatile parameter such as a timestamp"
)
assert stats["throttled"] == 0 or stats["throttled"] / total < 0.02, (
f"{stats['throttled']} throttled responses — the limiter is set above the real quota"
)
What to cache, and for how long
Not every response deserves the same treatment, and a single global TTL is the usual reason a cache is either stale or useless.
Immutable by construction. A historical NSRDB year for a fixed point will never change: the source reprocesses whole archives on a multi-year cadence and publishes them under a new version. These deserve no TTL at all, only a version in the cache key, and they are the bulk of a resource pipeline’s traffic.
Slow-moving. Dataset catalogues, station metadata and model coefficients change a few times a year. A TTL measured in weeks is right, and the cost of being a week stale is nil.
Revised on a schedule. Monthly series that are back-revised — most EIA data — should carry a TTL just shorter than the publication cadence, so the pipeline picks up a revision on the first run after it lands rather than a month later.
Genuinely live. Real-time or day-ahead endpoints should not be cached beyond minutes, and arguably should not be cached at all; a stale price or a stale outage is worse than a slow one.
Tagging each endpoint with its class at the client boundary — rather than deciding per call site — keeps the policy in one place and makes the cache’s behaviour explainable. The accounting then splits cleanly: hits against immutable data are pure saving, and a low hit rate on live endpoints is expected rather than a defect.
Frequently asked questions
Why does the cache hit rate fall to almost zero after a code change?
Almost always because a volatile value entered the cache key — a timestamp, a request identifier, a float formatted with full precision. The fix is to normalise the parameters before hashing: round coordinates to the precision the API actually honours, sort the keys, and exclude anything that identifies the caller rather than the request.
Should the cache live on disk or in object storage?
Disk for a single machine, object storage for a fleet — and the same content-addressed key works for both. The property that matters is that the key is derived from the request, so two workers computing the same key find the same object without coordinating.
Is a token bucket better than a simple sleep between requests?
Yes, because it allows bursts up to the capacity while holding the average rate, which matches how quotas are actually enforced. A fixed sleep either wastes the burst allowance or exceeds the sustained rate, and it cannot be shared between processes.
What happens if two workers write the limiter file at once?
The atomic replace means neither sees a corrupt file, and the worst case is that one worker’s token accounting is briefly stale — it spends a token the other also spent. With a reserve in place that is harmless. If the fleet is large enough for that to matter, the same interface backs onto Redis with a Lua script and no other change.
Should the pipeline stop when the quota is nearly gone?
It should stop cleanly rather than fail messily. Reaching the reserve is a legitimate outcome: write what has been fetched, record which partitions are outstanding, and exit with a status that the scheduler can retry after the window resets. Burning through the reserve and then failing mid-write is worse in every respect.
How do I know how much quota a run will need?
Count the cache misses in a dry run. Because the cache key is deterministic, a pass that only checks for the presence of each key gives an exact miss count without spending a single request — which is enough to decide whether tonight’s run fits in the remaining budget.
Related
- Open Energy Data Portals — the parent workflow and its portal comparison
- Downloading EIA & OpenEI Datasets with Python Requests — the response taxonomy this client sits behind
- Validating NREL Solar Datasets with Python — validating what the cache returns
- Spatial Pipeline Orchestration & Deployment — the shared concurrency limits this cooperates with