ブログに戻る

Currency API Caching & Error Handling

V
Vlado Grigirov
April 05, 2026
Caching Error Handling Best Practices Production API Integration Redis

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 minutes

Pros: 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 rates

Pros: 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 CaseRecommended TTL
Display-only conversion30-60 minutes
E-commerce pricing15-30 minutes
Financial reporting5-15 minutes
Trading applications1-5 minutes
Most business applications work perfectly with 30-minute caching. Exchange rates for major pairs rarely move more than 0.1% in that window. For more on choosing the right update frequency, see our real-time exchange rates comparison.

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 cache

After 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:

  1. API response time (p50, p95, p99) — alerts at >500ms p95
  2. Error rate — alerts at >5% of requests failing
  3. Cache hit ratio — should be >95% in steady state
  4. Rate limit remaining — alerts at <20% remaining
  5. 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}')
        raise

Health 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 checks

Multi-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.

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 →

この記事を共有する