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.
This commit is contained in:
Julian Pawlowski 2026-05-30 14:46:13 +00:00
parent a4a1243a36
commit 8da3083fb2
2 changed files with 31 additions and 6 deletions

View file

@ -51,6 +51,19 @@ class TibberPricesApiClient:
self._max_retries = 5 self._max_retries = 5
self._retry_delay = 2 # Base retry delay in seconds 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 # Timeout configuration - more granular control
self._connect_timeout = 10 # Connection timeout in seconds self._connect_timeout = 10 # Connection timeout in seconds
self._request_timeout = 25 # Total request 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.""" """Handle retry logic for API-specific errors."""
error_msg = str(error) error_msg = str(error)
# Non-retryable: Invalid queries, bad requests, empty data # Non-retryable: Invalid queries and bad requests are permanent client errors.
# 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:
if "Invalid GraphQL query" in error_msg or "Bad request" in error_msg or "Empty data received" 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 return False, 0
# Rate limits - only retry if server explicitly says so # Rate limits - only retry if server explicitly says so

View file

@ -219,9 +219,13 @@ def _check_price_info_empty(data: dict) -> bool:
and len(subscription["priceInfo"]["today"]) > 0 and len(subscription["priceInfo"]["today"]) > 0
) )
# Only require today's data - yesterday is optional # Consider the response valid if EITHER today's intervals OR the priceInfoRange
# If subscription exists but no today data, that's a structural problem # edges are present. Some markets/accounts (e.g. NL hourly accounts) return an
is_empty = not has_today # 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( _LOGGER_DETAILS.debug(
"Price info check - priceInfoRange: %s, today: %s, is_empty: %s", "Price info check - priceInfoRange: %s, today: %s, is_empty: %s",