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:
parent
22976868ce
commit
6b253f2d04
2 changed files with 106 additions and 5 deletions
18
agenda/fx.py
18
agenda/fx.py
|
|
@ -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):
|
||||||
|
|
|
||||||
93
tests/test_fx_currency_changes.py
Normal file
93
tests/test_fx_currency_changes.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue