From 8da3083fb2422db652ed6a38229d15ddd2da76a3 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sat, 30 May 2026 14:46:13 +0000 Subject: [PATCH] fix(api): accept priceInfoRange-only responses and retry transient empty data Some markets/accounts (e.g. NL hourly) return an empty priceInfo(QUARTER_HOURLY).today while priceInfoRange still delivers the full set of intervals. _check_price_info_empty required today's data and wrongly classified such complete responses as empty, which blocked setup. It now treats a response as valid when today OR yesterday data is present, since priceInfoRange is the authoritative source the interval pool uses. Additionally, "Empty data received" is now retried a few times with exponential backoff, smoothing brief Tibber outages that transiently return empty data within a single update cycle. Long outages are still absorbed by the IntervalPool cache fallback, so the in-request retry count stays small. Reported in #141. Impact: Setup and updates no longer fail with "Empty data received" for NL hourly accounts (and similar), and short Tibber blips are smoothed over. --- custom_components/tibber_prices/api/client.py | 27 ++++++++++++++++--- .../tibber_prices/api/helpers.py | 10 ++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/custom_components/tibber_prices/api/client.py b/custom_components/tibber_prices/api/client.py index 768a41c..48f2002 100644 --- a/custom_components/tibber_prices/api/client.py +++ b/custom_components/tibber_prices/api/client.py @@ -51,6 +51,19 @@ class TibberPricesApiClient: self._max_retries = 5 self._retry_delay = 2 # Base retry delay in seconds + # Empty-data responses are usually permanent (no data for the requested + # range), but during a Tibber outage the API can return empty data + # transiently before recovering. Retry a small number of times with backoff + # so a brief blip is smoothed out within a single update cycle. + # + # NOTE: This is NOT the mechanism that bridges multi-hour outages. Long + # outages are absorbed by the IntervalPool cache fallback (see + # interval_pool/manager.py): as long as cached intervals cover the current + # interval, the integration keeps serving cached prices and retries on the + # NEXT update cycle. That bridge is unbounded in time, so this in-request + # retry count must stay small to avoid blocking the event loop. + self._max_empty_data_retries = 2 + # Timeout configuration - more granular control self._connect_timeout = 10 # Connection timeout in seconds self._request_timeout = 25 # Total request timeout in seconds @@ -834,9 +847,17 @@ class TibberPricesApiClient: """Handle retry logic for API-specific errors.""" error_msg = str(error) - # Non-retryable: Invalid queries, bad requests, empty data - # Empty data means API has no data for the requested range - retrying won't help - if "Invalid GraphQL query" in error_msg or "Bad request" in error_msg or "Empty data received" in error_msg: + # Non-retryable: Invalid queries and bad requests are permanent client errors. + if "Invalid GraphQL query" in error_msg or "Bad request" in error_msg: + return False, 0 + + # Empty data is usually permanent (API has no data for the requested range), + # but can be returned transiently during a Tibber outage. Retry a limited + # number of times with exponential backoff before giving up. + if "Empty data received" in error_msg: + if retry < self._max_empty_data_retries: + delay = min(self._retry_delay * (2**retry), 30) + return True, delay return False, 0 # Rate limits - only retry if server explicitly says so diff --git a/custom_components/tibber_prices/api/helpers.py b/custom_components/tibber_prices/api/helpers.py index 685dd1b..3288efd 100644 --- a/custom_components/tibber_prices/api/helpers.py +++ b/custom_components/tibber_prices/api/helpers.py @@ -219,9 +219,13 @@ def _check_price_info_empty(data: dict) -> bool: and len(subscription["priceInfo"]["today"]) > 0 ) - # Only require today's data - yesterday is optional - # If subscription exists but no today data, that's a structural problem - is_empty = not has_today + # Consider the response valid if EITHER today's intervals OR the priceInfoRange + # edges are present. Some markets/accounts (e.g. NL hourly accounts) return an + # empty priceInfo(QUARTER_HOURLY).today while priceInfoRange still delivers the + # full set of intervals. Requiring today's data alone would wrongly classify + # such a complete response as "empty" and block setup. priceInfoRange is the + # authoritative source the interval pool uses, so its presence means we have data. + is_empty = not has_today and not has_yesterday _LOGGER_DETAILS.debug( "Price info check - priceInfoRange: %s, today: %s, is_empty: %s",