Skip to content

Commit 8254f6e

Browse files
Merge pull request #5167 from springfall2008/fix/kraken-rest-400-graphql-fallback-5166
fix(kraken): fall back to GraphQL when REST rates return HTTP 400
2 parents 87ecd9c + ed8db21 commit 8254f6e

2 files changed

Lines changed: 214 additions & 21 deletions

File tree

‎apps/predbat/kraken.py‎

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,11 @@ async def handle_oauth_401(self):
9494
# Viewer query to discover all account numbers under the authenticated user
9595
KRAKEN_VIEWER_QUERY = """{ viewer { accounts { number } } }"""
9696

97-
# GraphQL applicableRates query — fallback when REST product endpoint returns 404
98-
# (product code removed/replaced while customer is still on the tariff, or TOU tariff
99-
# with no /standard-unit-rates/ REST endpoint e.g. E-TOU-* tariffs on E.ON Next).
97+
# GraphQL applicableRates query — fallback when the REST product endpoint cannot serve the
98+
# tariff: 404/410 (product code removed/replaced while customer is still on the tariff, a private
99+
# product such as an EDF SEG export tariff, or a TOU tariff absent from the REST API such as
100+
# E-TOU-* on E.ON Next) or 400 (the product is in the REST API but has no /standard-unit-rates/
101+
# resource, as day/night-structured EDF tariffs answer — GH#5166).
100102
# Returns value (pence/kWh inc VAT), validFrom, validTo for the requested window.
101103
# applicableRates is a Relay-style connection (ApplicableRateConnectionTypeConnection),
102104
# so the rate fields live under edges { node { ... } }, not directly on the field. The
@@ -206,6 +208,17 @@ async def handle_oauth_401(self):
206208
# Auth error codes that trigger token refresh + retry
207209
KRAKEN_AUTH_ERROR_CODES = ("KT-CT-1139", "KT-CT-1111", "KT-CT-1143")
208210

211+
# REST statuses meaning "the products API cannot serve this tariff's rates", as opposed to a
212+
# transient outage (429/500/503) — these are the ones worth falling back to GraphQL for.
213+
# 404/410 — the product is not in the REST API at all: a private product (EDF SEG / export
214+
# tariffs) or one retired while the customer is still on it.
215+
# 400 — the tariff exists but has no /standard-unit-rates/ resource. Day/night-structured
216+
# tariffs answer "This tariff has day and night rates, not standard." (GH#5166),
217+
# which is permanent for the tariff, not a request the customer can fix.
218+
# Auth makes no difference to a 400, so the authenticated retry stays on 404/410 only.
219+
KRAKEN_REST_PRODUCT_NOT_FOUND_STATUSES = (404, 410)
220+
KRAKEN_REST_RATES_UNAVAILABLE_STATUSES = (400,) + KRAKEN_REST_PRODUCT_NOT_FOUND_STATUSES
221+
209222
# applicableRates connection pagination — request this many nodes per page, cap total pages
210223
# so a runaway cursor can't loop forever. 100 * 20 = 2000 half-hourly periods (~41 days) is far
211224
# more than the ~4-day planning window ever needs.
@@ -835,7 +848,10 @@ async def async_fetch_rates(self, tariff=None):
835848
REST auth — EDF SEG / export tariffs are private products that 404 unauthenticated.
836849
Only if the authenticated retry also fails do we fall back to the GraphQL applicableRates
837850
query (which is not available on every provider), so export rates are recovered wherever
838-
possible.
851+
possible. A 400 goes straight to that fallback without the authenticated retry: it means
852+
the tariff has no /standard-unit-rates/ resource (day/night-structured tariffs), which
853+
auth cannot change. A network error during the authenticated retry keeps the status the
854+
public attempt returned, so the fallback still runs for a tariff already proven 404/410.
839855
"""
840856
tariff = tariff or self.current_tariff
841857
if not tariff:
@@ -855,22 +871,38 @@ async def async_fetch_rates(self, tariff=None):
855871
results, err = await self._fetch_rates_rest(url, authenticate=False)
856872

857873
# 2) On a permanent "not found", the product is likely private — retry authenticated.
858-
if err in (404, 410):
874+
if err in KRAKEN_REST_PRODUCT_NOT_FOUND_STATUSES:
859875
self.log(f"Kraken: REST rates HTTP {err} for {tariff['tariff_code']}, retrying authenticated")
876+
rest_unavailable_status = err
860877
results, err = await self._fetch_rates_rest(url, authenticate=True)
878+
if results is None and err is None:
879+
# (None, None) is a network error on the retry, which would otherwise erase the
880+
# status the public attempt just proved and drop this cycle into the generic
881+
# failure path. The products API still cannot serve this tariff, so keep that
882+
# status and take the GraphQL fallback rather than losing the rates to a blip.
883+
self.log(f"Warn: Kraken: Authenticated retry for {tariff['tariff_code']} hit a network error, treating as HTTP {rest_unavailable_status}")
884+
err = rest_unavailable_status
861885

862886
if err is not None:
863-
# A 404/410 with a known MPAN is the EXPECTED path for private products — EDF SEG
864-
# export tariffs are never served by the REST products API, so this fires on every
865-
# export fetch. Recover via GraphQL applicableRates and only count a failure if that
866-
# also comes back empty; otherwise the failure counter would climb every cycle
867-
# despite the fetch succeeding. Transient errors (429/500/503) fall through to the
868-
# genuine-failure path below.
869-
if err in (404, 410) and fallback_mpan:
887+
# A "REST cannot serve this tariff" status with a known MPAN is the EXPECTED path for
888+
# two tariff families: EDF SEG export tariffs are private products never served by the
889+
# REST products API (404), and day/night-structured tariffs have no standard-unit-rates
890+
# resource at all (400). Both fire on every fetch. Recover via GraphQL applicableRates
891+
# and only count a failure if that also comes back empty; otherwise the failure counter
892+
# would climb every cycle despite the fetch succeeding. Transient errors (429/500/503)
893+
# fall through to the genuine-failure path below.
894+
if err in KRAKEN_REST_RATES_UNAVAILABLE_STATUSES and fallback_mpan:
870895
kind = "export" if is_export else "import"
871-
self.log(f"Kraken: REST rates HTTP {err} for {tariff['tariff_code']} (private product), using GraphQL applicableRates for {kind} MPAN {fallback_mpan}")
896+
# 404/410 covers both a private product and one retired from the REST API, so the
897+
# label has to fit both — a retired product is not a private one.
898+
reason = "product not in the REST API" if err in KRAKEN_REST_PRODUCT_NOT_FOUND_STATUSES else "tariff has no standard-unit-rates endpoint"
899+
self.log(f"Kraken: REST rates HTTP {err} for {tariff['tariff_code']} ({reason}), using GraphQL applicableRates for {kind} MPAN {fallback_mpan}")
900+
failures_before = self.failures_total
872901
rates = await self.async_fetch_rates_graphql(fallback_mpan, account_id=fallback_account)
873-
if rates is None:
902+
if rates is None and self.failures_total == failures_before:
903+
# async_graphql_query counts its own failures (auth, non-200, GraphQL errors,
904+
# network), so only add a count when the fallback came back empty without
905+
# counting one — otherwise a down GraphQL would score 2 per cycle.
874906
self.failures_total += 1
875907
return rates
876908
self.log(f"Warn: Kraken: Rates HTTP {err} for {url}")
@@ -935,9 +967,11 @@ async def async_fetch_standing_charges_graphql(self, mpan):
935967
async def async_fetch_standing_charges(self, tariff=None):
936968
"""Fetch standing charges from public REST endpoint. No auth needed.
937969
938-
Falls back to GraphQL applicableStandingCharges if the REST endpoint returns a non-200
939-
status (e.g. 404 for TOU tariffs on E.ON Next whose product is not in the REST API)
940-
and self.import_mpan is known.
970+
Falls back to GraphQL applicableStandingCharges only on a permanent "product not found"
971+
status (404/410 — e.g. TOU tariffs on E.ON Next whose product is not in the REST API)
972+
when self.import_mpan is known. Transient statuses (429/500/503) are counted as failures
973+
instead, and unlike the rates endpoint a 400 does not arise here: /standing-charges/ is
974+
register-independent, so day/night-structured tariffs still serve it (GH#5166).
941975
"""
942976
tariff = tariff or self.current_tariff
943977
if not tariff:
@@ -963,10 +997,12 @@ async def async_fetch_standing_charges(self, tariff=None):
963997
return None
964998

965999
if http_error_status is not None:
966-
# Only fall back to GraphQL for permanent "product not found" responses (404/410).
967-
# Transient errors (429, 500, 503, …) should surface as failures, not trigger
968-
# an extra GraphQL request that would mask the outage.
969-
if http_error_status in (404, 410) and self.import_mpan:
1000+
# Only fall back to GraphQL for permanent "product not found" responses (404/410) —
1001+
# the same statuses the rates fallback retries authenticated on, shared via the
1002+
# constant so the gates cannot drift. Transient errors (429, 500, 503, …) should
1003+
# surface as failures, not trigger an extra GraphQL request that would mask the
1004+
# outage. 400 is deliberately absent: it is a rates-endpoint-only answer.
1005+
if http_error_status in KRAKEN_REST_PRODUCT_NOT_FOUND_STATUSES and self.import_mpan:
9701006
self.log(f"Kraken: REST standing charges returned HTTP {http_error_status}, falling back to GraphQL applicableStandingCharges for MPAN {self.import_mpan}")
9711007
return await self.async_fetch_standing_charges_graphql(self.import_mpan)
9721008
return None

‎apps/predbat/tests/test_kraken.py‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
11961280
def 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+
14001557
def 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

Comments
 (0)