PRC-001
Fetch and cache a coin price without hammering the API
One request per interval for the whole site, an atomic cache write, and a stale copy served rather than nothing when the feed is down.
Every free price API rate limits, and a faucet that fetches on page load will be blocked within an hour. One fetch per interval, shared by every request, is all that is needed — coin prices do not move meaningfully in sixty seconds.
The part people skip is what happens when the feed fails. Returning zero or null on error makes payouts either free or impossible, depending on which way the arithmetic falls. Keeping the last good value with its timestamp lets the caller decide, which is the whole point of the stale-price guard.
Write the cache atomically. Two requests writing the same file at once produces truncated JSON, and the parse failure looks exactly like a feed outage.
function coin_price(string $coinId, string $vs = 'usd', int $ttl = 120): array
{
$file = sys_get_temp_dir() . '/price_' . preg_replace('~[^a-z0-9]~', '', $coinId) . '_' . $vs . '.json';
if (is_file($file)) {
$cached = json_decode((string) file_get_contents($file), true);
if (is_array($cached) && (time() - (int) $cached['at']) < $ttl) {
return ['price' => (float) $cached['price'], 'age' => time() - (int) $cached['at'], 'stale' => false];
}
}
$url = 'https://api.coingecko.com/api/v3/simple/price?ids=' . rawurlencode($coinId)
. '&vs_currencies=' . rawurlencode($vs);
$body = fetch_guarded($url, 8); // SSRF-guarded fetch
$data = $body === null ? null : json_decode($body, true);
$price = (float) ($data[$coinId][$vs] ?? 0);
if ($price > 0) {
// Atomic: write to a temp file, then rename. A half-written cache file
// fails to parse and looks identical to the feed being down.
$tmp = $file . '.' . getmypid() . '.tmp';
file_put_contents($tmp, json_encode(['price' => $price, 'at' => time()]));
rename($tmp, $file);
return ['price' => $price, 'age' => 0, 'stale' => false];
}
// Feed failed. Serve the last good value and say how old it is.
if (!empty($cached['price'])) {
return ['price' => (float) $cached['price'], 'age' => time() - (int) $cached['at'], 'stale' => true];
}
return ['price' => 0.0, 'age' => PHP_INT_MAX, 'stale' => true];
}
Using it
Return the age, not just the price. Every caller that spends money needs to decide for itself how old is too old.
Cache in a file rather than the database. A price lookup should not touch MySQL, and the filesystem survives a database restart.
Warm the cache from cron so a visitor never waits on the outbound request.
What bites people
Never return zero as a price. Zero passes a naive check and produces division by nothing or a free payout.
rename() is atomic on the same filesystem only. Keep the temp file beside the target, not in a different directory.
Coin ids are the API's, not ticker symbols. litecoin, not LTC.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.