diff --git a/agenda/fx.py b/agenda/fx.py index 61c4bb7..a19425d 100644 --- a/agenda/fx.py +++ b/agenda/fx.py @@ -9,6 +9,9 @@ from decimal import Decimal import flask import httpx +DEFAULT_FX_CACHE_TTL_HOURS = 24 +DEFAULT_FX_FAILURE_RETRY_HOURS = 24 + async def get_gbpusd(config: flask.config.Config) -> Decimal: """Get the current value for GBPUSD, with caching.""" @@ -54,18 +57,51 @@ def read_cached_rates( with open(filename) as file: data = json.load(file, parse_float=Decimal) + quotes = data.get("quotes") + if not isinstance(quotes, dict): + return {} + return { - cur: Decimal(data["quotes"][f"GBP{cur}"]) - for cur in currencies - if f"GBP{cur}" in data["quotes"] + cur: Decimal(quotes[f"GBP{cur}"]) for cur in currencies if f"GBP{cur}" in 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]: """Get current values of exchange rates for a list of currencies against GBP.""" currencies = config["CURRENCIES"] access_key = config["EXCHANGERATE_ACCESS_KEY"] 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_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)) file_suffix = f"{currency_string}_to_GBP.json" 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: - recent_filename = max(existing_files) - recent = datetime.strptime(recent_filename[:16], "%Y-%m-%d_%H:%M") + if latest_valid is not None: + recent = _fx_cache_datetime(latest_valid) delta = now - recent - full_path = os.path.join(fx_dir, recent_filename) - if recent_filename.endswith(file_suffix) and ( - delta < timedelta(hours=12) or config["OFFLINE_MODE"] - ): - return read_cached_rates(full_path, currencies) + if delta < timedelta(hours=cache_ttl_hours) or config["OFFLINE_MODE"]: + return read_cached_rates(latest_valid_path, currencies) + + if latest_attempt is not None: + 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" 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: response = client.get(url, params=params, timeout=10) except (httpx.ConnectError, httpx.ReadTimeout): - return read_cached_rates(full_path, currencies) + return read_cached_rates(latest_valid_path, currencies) try: data = json.loads(response.text, parse_float=Decimal) 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: file.write(response.text) diff --git a/tests/test_fx.py b/tests/test_fx.py index b291300..83b5dfa 100644 --- a/tests/test_fx.py +++ b/tests/test_fx.py @@ -3,12 +3,11 @@ import json import os import tempfile +from datetime import datetime from decimal import Decimal -from unittest.mock import Mock, patch +from unittest.mock import patch -import pytest - -from agenda.fx import read_cached_rates +from agenda.fx import get_rates, read_cached_rates class TestReadCachedRates: @@ -27,23 +26,23 @@ class TestReadCachedRates: "GBPUSD": 1.25, "GBPEUR": 1.15, "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) filepath = f.name - + try: result = read_cached_rates(filepath, currencies) - + assert len(result) == 3 assert result["USD"] == Decimal("1.25") assert result["EUR"] == Decimal("1.15") assert result["JPY"] == Decimal("150.0") assert "CAD" not in result # Not requested - + finally: os.unlink(filepath) @@ -53,23 +52,23 @@ class TestReadCachedRates: data = { "quotes": { "GBPUSD": 1.25, - "GBPEUR": 1.15 + "GBPEUR": 1.15, # 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) filepath = f.name - + try: result = read_cached_rates(filepath, currencies) - + assert len(result) == 2 assert result["USD"] == Decimal("1.25") assert result["EUR"] == Decimal("1.15") assert "CHF" not in result # Missing from data - + finally: os.unlink(filepath) @@ -78,18 +77,18 @@ class TestReadCachedRates: data = { "quotes": { "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) filepath = f.name - + try: result = read_cached_rates(filepath, []) assert result == {} - + finally: os.unlink(filepath) @@ -97,58 +96,35 @@ class TestReadCachedRates: """Test with data missing quotes key.""" currencies = ["USD", "EUR"] 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) 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: - with pytest.raises(json.JSONDecodeError): - read_cached_rates(filepath, ["USD"]) - + assert read_cached_rates(filepath, currencies) == {} + finally: os.unlink(filepath) def test_read_cached_rates_decimal_precision(self) -> None: """Test that rates are returned as Decimal with proper precision.""" currencies = ["USD"] - data = { - "quotes": { - "GBPUSD": 1.234567890123456789 # High precision - } - } - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + data = {"quotes": {"GBPUSD": 1.234567890123456789}} # High precision + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(data, f) filepath = f.name - + try: result = read_cached_rates(filepath, currencies) - + 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 expected = Decimal("1.234567890123456789") assert abs(result["USD"] - expected) < Decimal("0.0000000000000001") - + finally: os.unlink(filepath) @@ -162,21 +138,70 @@ class TestReadCachedRates: "GBPJPY": 150.0, "GBPCHF": 1.12, "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) filepath = f.name - + try: result = read_cached_rates(filepath, currencies) - + assert len(result) == 6 for currency in currencies: assert currency in result assert isinstance(result[currency], Decimal) - + finally: - os.unlink(filepath) \ No newline at end of file + 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()