Handle FX API usage limit fallback

Closes #211
This commit is contained in:
Edward Betts 2026-06-20 17:35:12 +01:00
parent d620ffd389
commit a369c44ae9
2 changed files with 149 additions and 77 deletions

View file

@ -9,6 +9,9 @@ from decimal import Decimal
import flask import flask
import httpx import httpx
DEFAULT_FX_CACHE_TTL_HOURS = 24
DEFAULT_FX_FAILURE_RETRY_HOURS = 24
async def get_gbpusd(config: flask.config.Config) -> Decimal: async def get_gbpusd(config: flask.config.Config) -> Decimal:
"""Get the current value for GBPUSD, with caching.""" """Get the current value for GBPUSD, with caching."""
@ -54,18 +57,51 @@ def read_cached_rates(
with open(filename) as file: with open(filename) as file:
data = json.load(file, parse_float=Decimal) data = json.load(file, parse_float=Decimal)
quotes = data.get("quotes")
if not isinstance(quotes, dict):
return {}
return { return {
cur: Decimal(data["quotes"][f"GBP{cur}"]) cur: Decimal(quotes[f"GBP{cur}"]) for cur in currencies if f"GBP{cur}" in quotes
for cur in currencies
if f"GBP{cur}" in data["quotes"]
} }
def _fx_cache_datetime(filename: str) -> datetime:
"""Extract the cache timestamp from an FX cache filename."""
return datetime.strptime(filename[:16], "%Y-%m-%d_%H:%M")
def _has_required_quotes(filename: str, currencies: list[str]) -> bool:
"""Return true if the cache file contains the requested GBP quotes."""
try:
return len(read_cached_rates(filename, currencies)) == len(currencies)
except (OSError, json.JSONDecodeError):
return False
def _latest_file(
fx_dir: str, filenames: list[str], currencies: list[str], *, valid_only: bool
) -> str | None:
"""Return the newest matching FX cache filename."""
matching_files = [
filename
for filename in filenames
if not valid_only
or _has_required_quotes(os.path.join(fx_dir, filename), currencies)
]
return max(matching_files) if matching_files else None
def get_rates(config: flask.config.Config) -> dict[str, Decimal]: def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
"""Get current values of exchange rates for a list of currencies against GBP.""" """Get current values of exchange rates for a list of currencies against GBP."""
currencies = config["CURRENCIES"] currencies = config["CURRENCIES"]
access_key = config["EXCHANGERATE_ACCESS_KEY"] access_key = config["EXCHANGERATE_ACCESS_KEY"]
data_dir = config["DATA_DIR"] data_dir = config["DATA_DIR"]
cache_ttl_hours = int(config.get("FX_CACHE_TTL_HOURS", DEFAULT_FX_CACHE_TTL_HOURS))
failure_retry_hours = int(
config.get("FX_FAILURE_RETRY_HOURS", DEFAULT_FX_FAILURE_RETRY_HOURS)
)
now = datetime.now() now = datetime.now()
now_str = now.strftime("%Y-%m-%d_%H:%M") now_str = now.strftime("%Y-%m-%d_%H:%M")
@ -75,20 +111,26 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
currency_string = ",".join(sorted(currencies)) currency_string = ",".join(sorted(currencies))
file_suffix = f"{currency_string}_to_GBP.json" file_suffix = f"{currency_string}_to_GBP.json"
existing_data = os.listdir(fx_dir) existing_data = os.listdir(fx_dir)
existing_files = [f for f in existing_data if f.endswith(".json")] existing_files = [f for f in existing_data if f.endswith(file_suffix)]
full_path: str | None = None latest_attempt = _latest_file(fx_dir, existing_files, currencies, valid_only=False)
latest_valid = _latest_file(fx_dir, existing_files, currencies, valid_only=True)
latest_valid_path = (
os.path.join(fx_dir, latest_valid) if latest_valid is not None else None
)
if existing_files: if latest_valid is not None:
recent_filename = max(existing_files) recent = _fx_cache_datetime(latest_valid)
recent = datetime.strptime(recent_filename[:16], "%Y-%m-%d_%H:%M")
delta = now - recent delta = now - recent
full_path = os.path.join(fx_dir, recent_filename) if delta < timedelta(hours=cache_ttl_hours) or config["OFFLINE_MODE"]:
if recent_filename.endswith(file_suffix) and ( return read_cached_rates(latest_valid_path, currencies)
delta < timedelta(hours=12) or config["OFFLINE_MODE"]
): if latest_attempt is not None:
return read_cached_rates(full_path, currencies) recent_attempt = _fx_cache_datetime(latest_attempt)
attempt_delta = now - recent_attempt
if attempt_delta < timedelta(hours=failure_retry_hours):
return read_cached_rates(latest_valid_path, currencies)
url = "http://api.exchangerate.host/live" url = "http://api.exchangerate.host/live"
params = {"currencies": currency_string, "source": "GBP", "access_key": access_key} params = {"currencies": currency_string, "source": "GBP", "access_key": access_key}
@ -98,12 +140,17 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
with httpx.Client() as client: with httpx.Client() as client:
response = client.get(url, params=params, timeout=10) response = client.get(url, params=params, timeout=10)
except (httpx.ConnectError, httpx.ReadTimeout): except (httpx.ConnectError, httpx.ReadTimeout):
return read_cached_rates(full_path, currencies) return read_cached_rates(latest_valid_path, currencies)
try: try:
data = json.loads(response.text, parse_float=Decimal) data = json.loads(response.text, parse_float=Decimal)
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
return read_cached_rates(full_path, currencies) return read_cached_rates(latest_valid_path, currencies)
if not data.get("success", True) or not isinstance(data.get("quotes"), dict):
with open(os.path.join(fx_dir, filename), "w") as file:
file.write(response.text)
return read_cached_rates(latest_valid_path, currencies)
with open(os.path.join(fx_dir, filename), "w") as file: with open(os.path.join(fx_dir, filename), "w") as file:
file.write(response.text) file.write(response.text)

View file

@ -3,12 +3,11 @@
import json import json
import os import os
import tempfile import tempfile
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from unittest.mock import Mock, patch from unittest.mock import patch
import pytest from agenda.fx import get_rates, read_cached_rates
from agenda.fx import read_cached_rates
class TestReadCachedRates: class TestReadCachedRates:
@ -27,23 +26,23 @@ class TestReadCachedRates:
"GBPUSD": 1.25, "GBPUSD": 1.25,
"GBPEUR": 1.15, "GBPEUR": 1.15,
"GBPJPY": 150.0, "GBPJPY": 150.0,
"GBPCAD": 1.70 # Not requested "GBPCAD": 1.70, # Not requested
} }
} }
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f) json.dump(data, f)
filepath = f.name filepath = f.name
try: try:
result = read_cached_rates(filepath, currencies) result = read_cached_rates(filepath, currencies)
assert len(result) == 3 assert len(result) == 3
assert result["USD"] == Decimal("1.25") assert result["USD"] == Decimal("1.25")
assert result["EUR"] == Decimal("1.15") assert result["EUR"] == Decimal("1.15")
assert result["JPY"] == Decimal("150.0") assert result["JPY"] == Decimal("150.0")
assert "CAD" not in result # Not requested assert "CAD" not in result # Not requested
finally: finally:
os.unlink(filepath) os.unlink(filepath)
@ -53,23 +52,23 @@ class TestReadCachedRates:
data = { data = {
"quotes": { "quotes": {
"GBPUSD": 1.25, "GBPUSD": 1.25,
"GBPEUR": 1.15 "GBPEUR": 1.15,
# GBPCHF missing # GBPCHF missing
} }
} }
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f) json.dump(data, f)
filepath = f.name filepath = f.name
try: try:
result = read_cached_rates(filepath, currencies) result = read_cached_rates(filepath, currencies)
assert len(result) == 2 assert len(result) == 2
assert result["USD"] == Decimal("1.25") assert result["USD"] == Decimal("1.25")
assert result["EUR"] == Decimal("1.15") assert result["EUR"] == Decimal("1.15")
assert "CHF" not in result # Missing from data assert "CHF" not in result # Missing from data
finally: finally:
os.unlink(filepath) os.unlink(filepath)
@ -78,18 +77,18 @@ class TestReadCachedRates:
data = { data = {
"quotes": { "quotes": {
"GBPUSD": 1.25, "GBPUSD": 1.25,
"GBPEUR": 1.15 "GBPEUR": 1.15,
} }
} }
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f) json.dump(data, f)
filepath = f.name filepath = f.name
try: try:
result = read_cached_rates(filepath, []) result = read_cached_rates(filepath, [])
assert result == {} assert result == {}
finally: finally:
os.unlink(filepath) os.unlink(filepath)
@ -97,58 +96,35 @@ class TestReadCachedRates:
"""Test with data missing quotes key.""" """Test with data missing quotes key."""
currencies = ["USD", "EUR"] currencies = ["USD", "EUR"]
data = {"other_key": "value"} # No quotes key data = {"other_key": "value"} # No quotes key
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f) json.dump(data, f)
filepath = f.name filepath = f.name
try:
with pytest.raises(KeyError):
read_cached_rates(filepath, currencies)
finally:
os.unlink(filepath)
def test_read_cached_rates_file_not_found(self) -> None:
"""Test error handling when file doesn't exist."""
with pytest.raises(FileNotFoundError):
read_cached_rates("/nonexistent/file.json", ["USD"])
def test_read_cached_rates_invalid_json(self) -> None:
"""Test error handling with invalid JSON."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
f.write("invalid json content")
filepath = f.name
try: try:
with pytest.raises(json.JSONDecodeError): assert read_cached_rates(filepath, currencies) == {}
read_cached_rates(filepath, ["USD"])
finally: finally:
os.unlink(filepath) os.unlink(filepath)
def test_read_cached_rates_decimal_precision(self) -> None: def test_read_cached_rates_decimal_precision(self) -> None:
"""Test that rates are returned as Decimal with proper precision.""" """Test that rates are returned as Decimal with proper precision."""
currencies = ["USD"] currencies = ["USD"]
data = { data = {"quotes": {"GBPUSD": 1.234567890123456789}} # High precision
"quotes": {
"GBPUSD": 1.234567890123456789 # High precision with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
}
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(data, f) json.dump(data, f)
filepath = f.name filepath = f.name
try: try:
result = read_cached_rates(filepath, currencies) result = read_cached_rates(filepath, currencies)
assert isinstance(result["USD"], Decimal) assert isinstance(result["USD"], Decimal)
# Should preserve reasonable precision from the JSON # Should preserve reasonable precision from the JSON
# Python's JSON precision may be limited to float precision # Python's JSON precision may be limited to float precision
expected = Decimal("1.234567890123456789") expected = Decimal("1.234567890123456789")
assert abs(result["USD"] - expected) < Decimal("0.0000000000000001") assert abs(result["USD"] - expected) < Decimal("0.0000000000000001")
finally: finally:
os.unlink(filepath) os.unlink(filepath)
@ -162,21 +138,70 @@ class TestReadCachedRates:
"GBPJPY": 150.0, "GBPJPY": 150.0,
"GBPCHF": 1.12, "GBPCHF": 1.12,
"GBPCAD": 1.70, "GBPCAD": 1.70,
"GBPAUD": 1.85 "GBPAUD": 1.85,
} }
} }
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f) json.dump(data, f)
filepath = f.name filepath = f.name
try: try:
result = read_cached_rates(filepath, currencies) result = read_cached_rates(filepath, currencies)
assert len(result) == 6 assert len(result) == 6
for currency in currencies: for currency in currencies:
assert currency in result assert currency in result
assert isinstance(result[currency], Decimal) assert isinstance(result[currency], Decimal)
finally: finally:
os.unlink(filepath) os.unlink(filepath)
def test_get_rates_uses_older_valid_cache_when_latest_attempt_is_usage_limit() -> None:
"""A usage-limit API response should not replace usable cached rates."""
with tempfile.TemporaryDirectory() as temp_dir:
fx_dir = os.path.join(temp_dir, "fx")
os.mkdir(fx_dir)
suffix = "EUR,USD_to_GBP.json"
valid_file = os.path.join(fx_dir, f"2026-06-19_23:35_{suffix}")
usage_limit_file = os.path.join(fx_dir, f"2026-06-20_11:35_{suffix}")
with open(valid_file, "w") as f:
json.dump(
{"success": True, "quotes": {"GBPEUR": 1.15, "GBPUSD": 1.25}},
f,
)
with open(usage_limit_file, "w") as f:
json.dump(
{
"success": False,
"error": {
"code": 104,
"type": "usage_limit_reached",
"info": "Your monthly usage limit has been reached.",
},
},
f,
)
config = {
"CURRENCIES": ["USD", "EUR"],
"DATA_DIR": temp_dir,
"EXCHANGERATE_ACCESS_KEY": "key",
"OFFLINE_MODE": False,
"FX_CACHE_TTL_HOURS": 1,
"FX_FAILURE_RETRY_HOURS": 24,
}
with patch("agenda.fx.datetime") as mock_datetime, patch(
"agenda.fx.httpx.Client"
) as mock_client:
mock_datetime.now.return_value = datetime(2026, 6, 20, 12, 35)
mock_datetime.strptime.side_effect = datetime.strptime
result = get_rates(config)
assert result == {"USD": Decimal("1.25"), "EUR": Decimal("1.15")}
mock_client.assert_not_called()