Currency API Caching & Error Handling Best Practices
Every application that depends on external exchange rate data will eventually face an API timeout, a rate limit, or an unexpected error. The difference between a production-ready integration and a fragile one is how you handle these failures. This guide covers battle-tested patterns for caching, retry logic, fallbacks, and monitoring that keep your currency features running even when things go wrong. For integration basics, see our platform-specific guides for JavaScript, Python, PHP, and Node.js.
Why Caching Exchange Rates Matters
Exchange rate APIs charge per request. Without caching, a page showing prices in 5 currencies makes 5 API calls per page load. Multiply by 10,000 daily visitors, and you're burning 50,000 API calls per day — exceeding most free tiers within hours.
But caching isn't just about cost. It's about speed and reliability:
- Latency: A cached rate lookup takes under 1ms. An API call takes 30-200ms.
- Availability: Your cache works when the API is down.
- Consistency: Without caching, two users loading the same page might see different prices if rates change between requests.
Choosing Your Cache Layer
In-Memory Cache (Application Level)
The simplest approach. Store rates in a variable that persists for the lifetime of the process.
import time
from threading import Lock
class RateCache:
def __init__(self, ttl_seconds=1800):
self._cache = {}
self._lock = Lock()
self._ttl = ttl_seconds
def get(self, base_currency):
with self._lock:
entry = self._cache.get(base_currency)
if entry and (time.time() - entry['timestamp']) < self._ttl:
return entry['data']
return None
def set(self, base_currency, data):
with self._lock:
self._cache[base_currency] = {
'data': data,
'timestamp': time.time()
}
rate_cache = RateCache(ttl_seconds=1800) # 30 minutesPros: Zero dependencies, zero latency, dead simple. Cons: Lost on process restart, not shared between workers or servers.
Best for: Single-server apps, scripts, CLI tools.
Redis Cache (Shared)
For multi-server deployments, Redis is the standard choice. It's shared across all application instances and survives restarts.
import redis
import json
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def get_rates_cached(base='USD', ttl=1800):
cache_key = f'exchange_rates:{base}'
# Try cache first
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Fetch from API
rates = fetch_from_api(base)
# Store with TTL
redis_client.setex(cache_key, ttl, json.dumps(rates))
return ratesPros: Shared across servers, persistent, fast (sub-millisecond reads). Cons: Requires Redis infrastructure.
Best for: Web applications, microservices, anything with multiple processes.
CDN Edge Cache
For public-facing currency widgets or converter pages, cache the API response at the CDN edge. This puts rates within 10ms of every user globally.
# Nginx configuration
location /api/rates {
proxy_pass http://your-backend;
proxy_cache_valid 200 30m;
proxy_cache_use_stale error timeout updating;
add_header X-Cache-Status $upstream_cache_status;
}The proxy_cache_use_stale directive is critical — it serves stale content when the backend is down rather than returning an error.
Best for: Public APIs, widgets, high-traffic pages.
Cache Invalidation Strategies
Time-Based TTL (Simplest)
Set a TTL that matches your freshness requirements:
| Use Case | Recommended TTL |
|---|---|
| Display-only conversion | 30-60 minutes |
| E-commerce pricing | 15-30 minutes |
| Financial reporting | 5-15 minutes |
| Trading applications | 1-5 minutes |
Stale-While-Revalidate
Serve the cached version immediately, then refresh in the background:
async function getRatesWithSWR(base) {
const cached = cache.get(base);
if (cached && !cached.isExpired) {
return cached.data; // Fresh cache hit
}
if (cached && cached.isExpired) {
// Return stale data immediately
refreshInBackground(base); // Fire-and-forget refresh
return cached.data;
}
// No cache at all - must wait for API
return await fetchAndCache(base);
}
async function refreshInBackground(base) {
try {
const fresh = await fetchFromAPI(base);
cache.set(base, fresh);
} catch (e) {
// Background refresh failed - stale data continues serving
console.warn(`Background refresh failed for ${base}:`, e.message);
}
}This pattern ensures users never wait for an API call after the first request. It's the best balance of freshness and speed.
Event-Driven Invalidation
If your application receives webhook notifications or subscribes to rate updates, invalidate the cache when new rates arrive rather than relying on TTL alone. This gives you both freshness and efficiency. See our REST vs WebSocket comparison for understanding push-based rate delivery.
Error Handling Patterns
Retry with Exponential Backoff
When an API call fails, don't retry immediately in a tight loop. Use exponential backoff with jitter to avoid thundering-herd problems:
import time
import random
def fetch_with_retry(base, max_retries=3):
for attempt in range(max_retries):
try:
response = requests.get(
f'https://api.finexly.com/v1/latest',
params={'api_key': API_KEY, 'base': base},
timeout=10
)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
if attempt == max_retries - 1:
raise # Final attempt failed
# Exponential backoff: 1s, 2s, 4s + random jitter
delay = (2 ** attempt) + random.uniform(0, 1)
time.sleep(delay)Key details: Always set a timeout on HTTP requests (10 seconds is a good default). Without a timeout, a hanging connection can block your entire application.
Circuit Breaker Pattern
If the API is consistently failing, stop trying for a while instead of wasting resources on doomed requests:
class CircuitBreaker:
def __init__(self, failure_threshold=5, reset_timeout=60):
self.failures = 0
self.threshold = failure_threshold
self.reset_timeout = reset_timeout
self.last_failure_time = 0
self.state = 'closed' # closed = normal, open = blocking
def call(self, func, *args, **kwargs):
if self.state == 'open':
if time.time() - self.last_failure_time > self.reset_timeout:
self.state = 'half-open' # Allow one test request
else:
raise CircuitOpenError('Circuit breaker is open')
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
raise
def _on_success(self):
self.failures = 0
self.state = 'closed'
def _on_failure(self):
self.failures += 1
self.last_failure_time = time.time()
if self.failures >= self.threshold:
self.state = 'open'
# Usage
rate_breaker = CircuitBreaker(failure_threshold=5, reset_timeout=60)
try:
rates = rate_breaker.call(fetch_rates, 'USD')
except CircuitOpenError:
rates = get_cached_rates('USD') # Fall back to cacheAfter 5 consecutive failures, the circuit opens and all subsequent calls immediately return cached data for 60 seconds instead of hitting the failing API.
Graceful Degradation
When all else fails — no API, no cache, circuit open — your app should still function:
HARDCODED_FALLBACK_RATES = {
'EUR': 0.92, 'GBP': 0.79, 'JPY': 149.50,
'CAD': 1.36, 'AUD': 1.53, 'CHF': 0.88
}
def get_rates_with_fallback(base='USD'):
# Layer 1: Fresh API data
try:
return fetch_with_retry(base)
except Exception:
pass
# Layer 2: Redis cache (possibly stale)
cached = redis_client.get(f'exchange_rates:{base}')
if cached:
return json.loads(cached)
# Layer 3: Disk-persisted rates
disk_rates = load_from_disk(base)
if disk_rates:
return disk_rates
# Layer 4: Hardcoded fallback (last resort)
return {
'base': base,
'rates': HARDCODED_FALLBACK_RATES,
'is_fallback': True
}The is_fallback flag tells your UI to show a warning like "Rates may not reflect current market values" so users know the data might be stale.
Rate Limit Management
Tracking Your Usage
Before you hit a rate limit, know where you stand:
class RateLimitTracker:
def __init__(self, redis_client, limit_per_month=1000):
self.redis = redis_client
self.limit = limit_per_month
def can_make_request(self):
key = f'api_usage:{time.strftime("%Y-%m")}'
current = int(self.redis.get(key) or 0)
return current < self.limit
def record_request(self):
key = f'api_usage:{time.strftime("%Y-%m")}'
pipe = self.redis.pipeline()
pipe.incr(key)
pipe.expire(key, 86400 * 35) # Expire after 35 days
pipe.execute()
def remaining(self):
key = f'api_usage:{time.strftime("%Y-%m")}'
current = int(self.redis.get(key) or 0)
return max(0, self.limit - current)Reducing API Calls
Batch requests: Fetch all currencies in one call instead of one call per pair. Finexly returns 170+ rates in a single response.
Share across users: One cached result serves all users. A 30-minute cache for USD base rates means a maximum of 48 API calls per day, regardless of traffic.
Prefetch popular pairs: At startup, fetch rates for your most common base currencies (USD, EUR, GBP) so the first user request is always a cache hit.
When your usage approaches the free tier limit, see Finexly's pricing plans for production-grade limits.
Monitoring and Alerting
What to Track
Set up monitoring for these metrics:
- API response time (p50, p95, p99) — alerts at >500ms p95
- Error rate — alerts at >5% of requests failing
- Cache hit ratio — should be >95% in steady state
- Rate limit remaining — alerts at <20% remaining
- Data freshness — the age of the oldest cached rate actively being served
import logging
logger = logging.getLogger('exchange_rates')
def fetch_rates_instrumented(base):
start = time.time()
try:
data = fetch_from_api(base)
duration = time.time() - start
logger.info(f'API call: base={base} duration={duration:.3f}s status=success')
return data
except Exception as e:
duration = time.time() - start
logger.error(f'API call: base={base} duration={duration:.3f}s status=error error={e}')
raiseHealth Check Endpoint
Expose a health check that verifies your currency data pipeline is working:
def currency_health_check():
checks = {}
# Check cache freshness
cached = redis_client.get('exchange_rates:USD')
if cached:
data = json.loads(cached)
age = time.time() - data.get('timestamp', 0)
checks['cache_age_seconds'] = round(age)
checks['cache_healthy'] = age < 7200 # 2 hours
# Check API reachability
try:
response = requests.get(
'https://api.finexly.com/v1/latest',
params={'api_key': API_KEY, 'base': 'USD'},
timeout=5
)
checks['api_reachable'] = response.ok
except Exception:
checks['api_reachable'] = False
checks['overall_healthy'] = (
checks.get('cache_healthy', False) or checks.get('api_reachable', False)
)
return checksMulti-Currency Caching Architecture
For applications like e-commerce platforms or accounting systems that need rates for many base currencies:
def warm_cache(popular_bases=['USD', 'EUR', 'GBP']):
"""Pre-fetch rates for popular base currencies."""
for base in popular_bases:
try:
rates = fetch_from_api(base)
cache_rates(base, rates)
time.sleep(0.5) # Be polite to the API
except Exception as e:
logger.warning(f'Cache warming failed for {base}: {e}')
# Run on startup and every 25 minutes
import schedule
schedule.every(25).minutes.do(warm_cache)This proactive approach means the cache is always fresh, and user requests never trigger API calls directly.
Testing Your Error Handling
Don't wait for production failures to discover your error handling doesn't work. Test it explicitly:
import unittest
from unittest.mock import patch
class TestRateFetching(unittest.TestCase):
@patch('requests.get')
def test_returns_cached_on_api_failure(self, mock_get):
# Seed the cache
cache_rates('USD', {'rates': {'EUR': 0.92}})
# Make API fail
mock_get.side_effect = requests.Timeout()
# Should return cached data, not raise
result = get_rates_with_fallback('USD')
self.assertEqual(result['rates']['EUR'], 0.92)
@patch('requests.get')
def test_retries_on_server_error(self, mock_get):
mock_get.side_effect = [
requests.exceptions.HTTPError(response=Mock(status_code=500)),
Mock(ok=True, json=lambda: {'rates': {'EUR': 0.92}})
]
result = fetch_with_retry('USD', max_retries=3)
self.assertEqual(mock_get.call_count, 2)
def test_circuit_breaker_opens_after_threshold(self):
breaker = CircuitBreaker(failure_threshold=3)
for _ in range(3):
with self.assertRaises(Exception):
breaker.call(lambda: 1/0)
with self.assertRaises(CircuitOpenError):
breaker.call(lambda: "should not execute")Summary Checklist
Before shipping your currency integration to production, verify:
- Cache TTL matches your freshness requirements
- Stale cache serves as fallback when the API is down
- Retry logic uses exponential backoff with jitter
- HTTP requests have explicit timeouts
- Rate limit tracking prevents quota exhaustion
- Monitoring covers response time, error rate, and cache hit ratio
- Error handling has been tested with simulated failures
- Users see a warning when viewing potentially stale rates
Get started with Finexly's free tier — 1,000 requests per month is enough for most applications when combined with proper caching. For higher limits, check our pricing plans.
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →