@@ -1193,6 +1193,90 @@ def test_fetch_rates_rest_410_falls_back_to_graphql_for_import():
11931193 api .async_fetch_rates_graphql .assert_called_once_with ("1900000000456" , account_id = "A-AA8A473C" )
11941194
11951195
1196+ def test_fetch_rates_rest_400_day_night_tariff_falls_back_to_graphql ():
1197+ """async_fetch_rates() falls back to GraphQL when REST returns 400 on a day/night tariff.
1198+
1199+ GH#5166: EDF day/night-structured tariffs (e.g. E-FLAT2R-EDF_STANDARD_VARIABLE-D) have no
1200+ /standard-unit-rates/ resource — the API answers HTTP 400 "This tariff has day and night
1201+ rates, not standard." That is as permanent as a 404, so it must reach the GraphQL fallback
1202+ instead of being logged as a failure on every cycle. Auth cannot change a 400, so unlike the
1203+ 404 path there must be no authenticated retry of the same URL.
1204+ """
1205+ api = make_kraken_api (provider = "edf" , account_id = "A-9C006563" )
1206+ api .current_tariff = {"tariff_code" : "E-FLAT2R-EDF_STANDARD_VARIABLE-D" , "product_code" : "EDF_STANDARD_VARIABLE" }
1207+ api .import_mpan = "1900000000456"
1208+ api .failures_total = 0
1209+
1210+ graphql_rates = [{"value_inc_vat" : 27.5 , "value_exc_vat" : round (27.5 / 1.05 , 4 ), "valid_from" : "2026-09-19T00:00:00Z" , "valid_to" : "2026-09-21T00:00:00Z" }]
1211+ api .async_fetch_rates_graphql = AsyncMock (return_value = graphql_rates )
1212+
1213+ mock_session = AsyncMock ()
1214+ mock_session .get = MagicMock (side_effect = lambda url , ** kwargs : _mk_get_ctx (400 ))
1215+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
1216+ mock_session .__aexit__ = AsyncMock (return_value = None )
1217+
1218+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
1219+ result = asyncio .run (api .async_fetch_rates ())
1220+
1221+ assert result is graphql_rates
1222+ api .async_fetch_rates_graphql .assert_called_once_with ("1900000000456" , account_id = "A-9C006563" )
1223+ assert mock_session .get .call_count == 1 , f"400 must not trigger an authenticated retry, got { mock_session .get .call_count } REST requests"
1224+ assert api .failures_total == 0 , "A recovered 400 must not count as a failure"
1225+
1226+
1227+ def test_fetch_rates_rest_400_falls_back_to_graphql_for_export ():
1228+ """A day/night-structured EXPORT tariff's 400 falls back on the export MPAN and account.
1229+
1230+ The 400 gate shares the fallback wiring with the 404 path, which selects export_mpan and
1231+ export_account_id for an export tariff — but only the 404 side of that wiring was covered,
1232+ so a 400 mis-routed to the import MPAN/account would have passed the suite.
1233+ """
1234+ api = make_kraken_api (provider = "edf" , account_id = "A-9C006563" , export_account_id = "A-EXPORT456" )
1235+ api .current_tariff = {"tariff_code" : "E-1R-IMP-01-J" , "product_code" : "IMP-01" }
1236+ api .import_mpan = "1900000000456"
1237+ export_tariff = {"tariff_code" : "E-FLAT2R-EDF_EXPORT_VARIABLE-D" , "product_code" : "EDF_EXPORT_VARIABLE" }
1238+ api .export_tariff = export_tariff
1239+ api .export_mpan = "2000000000789"
1240+ api .failures_total = 0
1241+
1242+ graphql_rates = [{"value_inc_vat" : 15.0 , "value_exc_vat" : round (15.0 / 1.05 , 4 ), "valid_from" : "2026-09-19T00:00:00Z" , "valid_to" : "2026-09-21T00:00:00Z" }]
1243+ api .async_fetch_rates_graphql = AsyncMock (return_value = graphql_rates )
1244+
1245+ mock_session = AsyncMock ()
1246+ mock_session .get = MagicMock (side_effect = lambda url , ** kwargs : _mk_get_ctx (400 ))
1247+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
1248+ mock_session .__aexit__ = AsyncMock (return_value = None )
1249+
1250+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
1251+ result = asyncio .run (api .async_fetch_rates (tariff = export_tariff ))
1252+
1253+ assert result is graphql_rates
1254+ api .async_fetch_rates_graphql .assert_called_once_with ("2000000000789" , account_id = "A-EXPORT456" )
1255+ assert mock_session .get .call_count == 1 , f"400 must not trigger an authenticated retry, got { mock_session .get .call_count } REST requests"
1256+ assert api .failures_total == 0 , "A recovered 400 must not count as a failure"
1257+
1258+
1259+ def test_fetch_rates_rest_400_no_fallback_without_import_mpan ():
1260+ """async_fetch_rates() returns None and counts a failure when REST 400s with no MPAN to fall back on."""
1261+ api = make_kraken_api (provider = "edf" )
1262+ api .current_tariff = {"tariff_code" : "E-FLAT2R-EDF_STANDARD_VARIABLE-D" , "product_code" : "EDF_STANDARD_VARIABLE" }
1263+ api .import_mpan = None # Not yet discovered
1264+ api .failures_total = 0
1265+ api .async_fetch_rates_graphql = AsyncMock ()
1266+
1267+ mock_session = AsyncMock ()
1268+ mock_session .get = MagicMock (side_effect = lambda url , ** kwargs : _mk_get_ctx (400 ))
1269+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
1270+ mock_session .__aexit__ = AsyncMock (return_value = None )
1271+
1272+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
1273+ result = asyncio .run (api .async_fetch_rates ())
1274+
1275+ assert result is None
1276+ api .async_fetch_rates_graphql .assert_not_called ()
1277+ assert api .failures_total == 1
1278+
1279+
11961280def test_fetch_rates_transient_error_does_not_fall_back_to_graphql ():
11971281 """async_fetch_rates() returns None (no GraphQL fallback) for transient errors like 500/429."""
11981282 for status_code in (429 , 500 , 503 ):
@@ -1397,6 +1481,79 @@ def test_fetch_rates_404_graphql_fallback_empty_counts_one_failure():
13971481 assert api .failures_total == 1
13981482
13991483
1484+ def test_fetch_rates_404_then_network_error_on_retry_still_falls_back_to_graphql ():
1485+ """A network error during the authenticated retry must not lose the GraphQL fallback.
1486+
1487+ _fetch_rates_rest returns (None, None) on a network error, which would otherwise erase the
1488+ 404 the public attempt just proved and drop the cycle into the generic-failure path — losing
1489+ a private product's rates to a transient blip even though GraphQL would have served them.
1490+ """
1491+ import aiohttp
1492+
1493+ api = make_kraken_api ()
1494+ export_tariff = {"tariff_code" : "E-1R-EDF_EXPORT_SEG_12M_HH-B" , "product_code" : "EDF_EXPORT_SEG_12M" }
1495+ api .export_tariff = export_tariff
1496+ api .export_mpan = "2000000000789"
1497+ api .failures_total = 0
1498+
1499+ graphql_rates = [{"value_inc_vat" : 15.0 , "value_exc_vat" : round (15.0 / 1.05 , 4 ), "valid_from" : "2026-09-19T00:00:00Z" , "valid_to" : "2026-09-21T00:00:00Z" }]
1500+ api .async_fetch_rates_graphql = AsyncMock (return_value = graphql_rates )
1501+
1502+ call_count = [0 ]
1503+
1504+ def get_side_effect (url , ** kwargs ):
1505+ """Public attempt 404s; the authenticated retry fails with a connection error."""
1506+ call_count [0 ] += 1
1507+ if call_count [0 ] == 1 :
1508+ return _mk_get_ctx (404 )
1509+ raise aiohttp .ClientConnectionError ("connection reset" )
1510+
1511+ mock_session = AsyncMock ()
1512+ mock_session .get = MagicMock (side_effect = get_side_effect )
1513+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
1514+ mock_session .__aexit__ = AsyncMock (return_value = None )
1515+
1516+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
1517+ result = asyncio .run (api .async_fetch_rates (tariff = export_tariff ))
1518+
1519+ assert result is graphql_rates
1520+ assert call_count [0 ] == 2 , "expected an unauthenticated attempt then an authenticated retry"
1521+ api .async_fetch_rates_graphql .assert_called_once_with ("2000000000789" , account_id = "A-TEST123" )
1522+ assert api .failures_total == 0 , "rates recovered via GraphQL must not count as a failure"
1523+
1524+
1525+ def test_fetch_rates_graphql_fallback_failure_counts_one_failure_not_two ():
1526+ """A GraphQL fallback that fails internally is counted once, not once per counting layer.
1527+
1528+ async_graphql_query increments failures_total on every one of its own failure paths (auth
1529+ unavailable, no token, non-200, GraphQL errors, network), so async_fetch_rates must not add
1530+ a second count for the same cycle: for a day/night tariff whose GraphQL is down that would
1531+ score 2 every 5 minutes and mislead anything watching the metric for component health.
1532+ """
1533+ api = make_kraken_api (provider = "edf" , account_id = "A-9C006563" )
1534+ api .current_tariff = {"tariff_code" : "E-FLAT2R-EDF_STANDARD_VARIABLE-D" , "product_code" : "EDF_STANDARD_VARIABLE" }
1535+ api .import_mpan = "1900000000456"
1536+ api .failures_total = 0
1537+
1538+ def graphql_failure (mpan , account_id = None ):
1539+ """Stand in for a fallback that failed inside async_graphql_query, which counts its own."""
1540+ api .failures_total += 1
1541+ return None
1542+
1543+ api .async_fetch_rates_graphql = AsyncMock (side_effect = graphql_failure )
1544+
1545+ mock_session = AsyncMock ()
1546+ mock_session .get = MagicMock (side_effect = lambda url , ** kwargs : _mk_get_ctx (400 ))
1547+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
1548+ mock_session .__aexit__ = AsyncMock (return_value = None )
1549+
1550+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
1551+ result = asyncio .run (api .async_fetch_rates ())
1552+
1553+ assert result is None
1554+ assert api .failures_total == 1 , f"one failed cycle must count once, got { api .failures_total } "
1555+
1556+
14001557def test_connection_nodes_extracts_edges ():
14011558 """_connection_nodes returns node dicts from a Relay connection and tolerates other shapes."""
14021559 from kraken import KrakenAPI
0 commit comments