Refresh FX rates when configured currencies change

Compare the latest cached currency list before applying cache TTL and match cache filenames exactly. Preserve offline mode and failure retry backoff.

Closes #170
This commit is contained in:
Edward Betts 2026-10-01 13:25:52 +01:00
parent 22976868ce
commit 6b253f2d04
2 changed files with 106 additions and 5 deletions

View file

@ -150,7 +150,7 @@ def get_rates_exchangerate_host(config: flask.config.Config) -> dict[str, Decima
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(file_suffix)] existing_files = [f for f in existing_data if f[17:] == file_suffix]
latest_attempt = _latest_file(fx_dir, existing_files, currencies, valid_only=False) 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 = _latest_file(fx_dir, existing_files, currencies, valid_only=True)
@ -222,9 +222,17 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
valid_cache_files = [ valid_cache_files = [
f f
for f in existing_data for f in existing_data
if f.endswith(legacy_file_suffix) or f.endswith(frankfurter_file_suffix) if f[17:] in (legacy_file_suffix, frankfurter_file_suffix)
] ]
attempt_files = [f for f in existing_data if f.endswith(frankfurter_file_suffix)] attempt_files = [f for f in existing_data if f[17:] == frankfurter_file_suffix]
# Cache filenames already record the requested currencies. Compare against
# the newest request, including failed attempts, so failures retain backoff.
all_rate_files = [f for f in existing_data if f.endswith("_to_GBP.json")]
latest_request = max(all_rate_files) if all_rate_files else None
currencies_changed = latest_request is not None and latest_request[17:] not in (
legacy_file_suffix,
frankfurter_file_suffix,
)
latest_attempt = _latest_file(fx_dir, attempt_files, currencies, valid_only=False) latest_attempt = _latest_file(fx_dir, attempt_files, currencies, valid_only=False)
latest_source_valid = _latest_file( latest_source_valid = _latest_file(
@ -238,7 +246,7 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
if config["OFFLINE_MODE"]: if config["OFFLINE_MODE"]:
return read_cached_rates(latest_valid_path, currencies) return read_cached_rates(latest_valid_path, currencies)
if latest_source_valid is not None: if latest_source_valid is not None and not currencies_changed:
recent = _fx_cache_datetime(latest_source_valid) recent = _fx_cache_datetime(latest_source_valid)
delta = now - recent delta = now - recent
@ -247,7 +255,7 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
os.path.join(fx_dir, latest_source_valid), currencies os.path.join(fx_dir, latest_source_valid), currencies
) )
if latest_attempt is not None: if latest_attempt is not None and not currencies_changed:
recent_attempt = _fx_cache_datetime(latest_attempt) recent_attempt = _fx_cache_datetime(latest_attempt)
attempt_delta = now - recent_attempt attempt_delta = now - recent_attempt
if attempt_delta < timedelta(hours=failure_retry_hours): if attempt_delta < timedelta(hours=failure_retry_hours):

View file

@ -0,0 +1,93 @@
"""Currency configuration changes must bypass the FX cache interval."""
import json
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
import flask
import pytest
from agenda.fx import get_rates
@pytest.mark.parametrize(
("previous", "current", "fetch"),
[
(["USD"], ["USD", "EUR"], True),
(["EUR", "USD"], ["USD"], True),
(["EUR", "USD"], ["USD", "EUR"], False),
],
)
def test_currency_changes(
tmp_path: Path, previous: list[str], current: list[str], fetch: bool
) -> None:
"""Fetch additions/removals even if an older matching cache is still fresh."""
fx_dir = tmp_path / "fx"
fx_dir.mkdir()
def cache(timestamp: str, currencies: list[str]) -> None:
suffix = ",".join(sorted(currencies))
filename = fx_dir / f"{timestamp}_frankfurter_{suffix}_to_GBP.json"
filename.write_text(
json.dumps([{"quote": c, "rate": 1.25} for c in currencies])
)
cache("2026-10-01_10:00", current)
cache("2026-10-01_11:00", previous)
config = flask.config.Config(
"/",
{
"CURRENCIES": current,
"DATA_DIR": str(tmp_path),
"OFFLINE_MODE": False,
"FX_CACHE_TTL_HOURS": 12,
},
)
with (
patch("agenda.fx.datetime") as clock,
patch("agenda.fx.httpx.Client") as client,
):
clock.now.return_value = datetime(2026, 10, 1, 12)
clock.strptime.side_effect = datetime.strptime
get = client.return_value.__enter__.return_value.get
get.return_value.text = json.dumps([{"quote": c, "rate": 1.3} for c in current])
result = get_rates(config)
assert set(result) == set(current)
assert get.call_count == int(fetch)
# The same configuration must use the newly fetched cache.
get_rates(config)
assert get.call_count == int(fetch)
def test_changed_currencies_preserve_failure_backoff(tmp_path: Path) -> None:
"""A failed fetch for the new list should not be retried on every cron run."""
fx_dir = tmp_path / "fx"
fx_dir.mkdir()
(fx_dir / "2026-10-01_11:00_frankfurter_EUR,USD_to_GBP.json").write_text(
json.dumps([{"quote": "EUR", "rate": 1.15}, {"quote": "USD", "rate": 1.25}])
)
config = flask.config.Config(
"/", {"CURRENCIES": ["USD"], "DATA_DIR": str(tmp_path), "OFFLINE_MODE": False}
)
with (
patch("agenda.fx.datetime") as clock,
patch("agenda.fx.httpx.Client") as client,
):
clock.now.return_value = datetime(2026, 10, 1, 12)
clock.strptime.side_effect = datetime.strptime
get = client.return_value.__enter__.return_value.get
get.return_value.text = "[]"
assert get_rates(config) == {}
assert get_rates(config) == {}
assert get.call_count == 1
def test_currency_changes_respect_offline_mode(tmp_path: Path) -> None:
"""Changing configuration must not trigger network requests when offline."""
config = flask.config.Config(
"/", {"CURRENCIES": ["USD"], "DATA_DIR": str(tmp_path), "OFFLINE_MODE": True}
)
with patch("agenda.fx.httpx.Client") as client:
assert get_rates(config) == {}
client.assert_not_called()