@@ -1210,15 +1210,8 @@ def test_fetch_rates_rest_400_day_night_tariff_falls_back_to_graphql():
12101210 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" }]
12111211 api .async_fetch_rates_graphql = AsyncMock (return_value = graphql_rates )
12121212
1213- mock_response = AsyncMock ()
1214- mock_response .status = 400
12151213 mock_session = AsyncMock ()
1216- mock_session .get = MagicMock (
1217- return_value = AsyncMock (
1218- __aenter__ = AsyncMock (return_value = mock_response ),
1219- __aexit__ = AsyncMock (return_value = None ),
1220- )
1221- )
1214+ mock_session .get = MagicMock (side_effect = lambda url , ** kwargs : _mk_get_ctx (400 ))
12221215 mock_session .__aenter__ = AsyncMock (return_value = mock_session )
12231216 mock_session .__aexit__ = AsyncMock (return_value = None )
12241217
@@ -1231,6 +1224,38 @@ def test_fetch_rates_rest_400_day_night_tariff_falls_back_to_graphql():
12311224 assert api .failures_total == 0 , "A recovered 400 must not count as a failure"
12321225
12331226
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+
12341259def test_fetch_rates_rest_400_no_fallback_without_import_mpan ():
12351260 """async_fetch_rates() returns None and counts a failure when REST 400s with no MPAN to fall back on."""
12361261 api = make_kraken_api (provider = "edf" )
@@ -1239,15 +1264,8 @@ def test_fetch_rates_rest_400_no_fallback_without_import_mpan():
12391264 api .failures_total = 0
12401265 api .async_fetch_rates_graphql = AsyncMock ()
12411266
1242- mock_response = AsyncMock ()
1243- mock_response .status = 400
12441267 mock_session = AsyncMock ()
1245- mock_session .get = MagicMock (
1246- return_value = AsyncMock (
1247- __aenter__ = AsyncMock (return_value = mock_response ),
1248- __aexit__ = AsyncMock (return_value = None ),
1249- )
1250- )
1268+ mock_session .get = MagicMock (side_effect = lambda url , ** kwargs : _mk_get_ctx (400 ))
12511269 mock_session .__aenter__ = AsyncMock (return_value = mock_session )
12521270 mock_session .__aexit__ = AsyncMock (return_value = None )
12531271
@@ -1463,6 +1481,79 @@ def test_fetch_rates_404_graphql_fallback_empty_counts_one_failure():
14631481 assert api .failures_total == 1
14641482
14651483
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+
14661557def test_connection_nodes_extracts_edges ():
14671558 """_connection_nodes returns node dicts from a Relay connection and tolerates other shapes."""
14681559 from kraken import KrakenAPI
0 commit comments