URL-encode Nightscout date filters instead of retrying with a mangled timestamp

arrow's isoformat() ends a timestamp with its UTC offset, e.g.
'2026-07-16T00:00:00+02:00'. Placed raw into a query string, '+' is a
reserved character that servers decode as a space, so Nightscout receives
'2026-07-16T00:00:00 02:00' and answers 'could not parse as a valid ISO-8601
date'. Any user on a positive UTC offset hits this on every date-filtered
query.

The current workaround retries the request with 'T' replaced by a space
(t_to_space) and treats a failure as 'no previous entry'. That gets a
response, but it works around the symptom: the value is still mangled, every
affected query costs two round-trips, and a genuinely empty result is
indistinguishable from a rejected one.

Percent-encoding the value fixes the cause: the offset survives, the first
request succeeds, and the t_to_space fallback and its retry wrapper are no
longer needed and are removed.

Adds tests for time_range(): that a positive offset is encoded rather than
emitted raw, that the encoded value round-trips back to the original instant,
that negative offsets and 'Z' still parse, and the bounds/no-bounds cases.
There was no coverage of time_range() before. The encoding test fails on
master with '+' present in the query and passes with this change.

Running with this in my EU deployment since May.
This commit is contained in:
xannasavin
2026-07-20 20:50:13 -04:00
committed by James Woglom
parent ddeaa79ded
commit 758204d4de
2 changed files with 84 additions and 67 deletions
+16 -67
View File
@@ -19,12 +19,14 @@ DateLike = Union[str, datetime.datetime, arrow.Arrow]
def format_datetime(date: DateLike) -> str: def format_datetime(date: DateLike) -> str:
return arrow.get(date).isoformat() return arrow.get(date).isoformat()
def time_range(field_name: str, start_time: Optional[DateLike], end_time: Optional[DateLike], t_to_space: bool = False) -> str: def time_range(field_name: str, start_time: Optional[DateLike], end_time: Optional[DateLike]) -> str:
def fmt(date: DateLike) -> str: def fmt(date: DateLike) -> str:
ret = format_datetime(date) ret = format_datetime(date)
if t_to_space: # URL-encode so the '+' in offsets like '+02:00' is not decoded
return ret.replace('T', ' ') # to a space by the server, which would mangle the ISO-8601 value.
return ret # Upstream instead retries with 'T' replaced by a space (t_to_space);
# encoding the value fixes the cause, so that fallback is not carried.
return urllib.parse.quote(ret, safe='')
arg = '' arg = ''
if start_time: if start_time:
arg += '&find[%s][$gte]=%s' % (field_name, fmt(start_time)) arg += '&find[%s][$gte]=%s' % (field_name, fmt(start_time))
@@ -70,37 +72,18 @@ class NightscoutApi:
raise ApiException(r.status_code, "Nightscout put %s response: %s" % (r.status_code, r.text)) raise ApiException(r.status_code, "Nightscout put %s response: %s" % (r.status_code, r.text))
def last_uploaded_entry(self, eventType: str, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]: def last_uploaded_entry(self, eventType: str, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]:
def internal(t_to_space: bool) -> Optional[dict]: dateFilter = time_range('created_at', time_start, time_end)
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space) try:
latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + dateFilter + '&ts=' + str(time.time())), headers={ latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify) }, verify=self.verify)
if latest.status_code != 200: if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout last_uploaded_entry %s could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (eventType, time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry %s response: %s" % (latest.status_code, latest.text)) raise ApiException(latest.status_code, "Nightscout last_uploaded_entry %s response: %s" % (latest.status_code, latest.text))
j = latest.json() j = latest.json()
if j and len(j) > 0: if j and len(j) > 0:
return j[0] return j[0]
return None return None
try:
ret = None
try:
ret = internal(False)
except ApiException as e:
#logger.warning("last_uploaded_entry with no t_to_space: %s", e)
ret = None
if ret is None and (time_start or time_end):
try:
ret = internal(True)
except ApiException as e:
#logger.warning("last_uploaded_entry with t_to_space: %s", e)
ret = None
if ret is not None:
logger.warning("last_uploaded_entry with eventType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (eventType, time_start, time_end))
return ret
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors: if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e) logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
@@ -108,30 +91,18 @@ class NightscoutApi:
raise e raise e
def last_uploaded_bg_entry(self, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]: def last_uploaded_bg_entry(self, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]:
def internal(t_to_space: bool) -> Optional[dict]: dateFilter = time_range('dateString', time_start, time_end)
dateFilter = time_range('dateString', time_start, time_end, t_to_space=t_to_space) try:
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={ latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify) }, verify=self.verify)
if latest.status_code != 200: if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout last_uploaded_bg_entry could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry %s response: %s" % (latest.status_code, latest.text)) raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry %s response: %s" % (latest.status_code, latest.text))
j = latest.json() j = latest.json()
if j and len(j) > 0: if j and len(j) > 0:
return j[0] return j[0]
return None return None
try:
ret = internal(False)
if ret is None and (time_start or time_end):
ret = internal(True)
if ret is not None:
logger.warning("last_uploaded_bg_entry with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
return ret
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors: if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e) logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
@@ -139,29 +110,18 @@ class NightscoutApi:
raise e raise e
def last_uploaded_activity(self, activityType: str, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]: def last_uploaded_activity(self, activityType: str, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]:
def internal(t_to_space: bool) -> Optional[dict]: dateFilter = time_range('created_at', time_start, time_end)
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space) try:
latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + dateFilter + '&ts=' + str(time.time())), headers={ latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify) }, verify=self.verify)
if latest.status_code != 200: if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout activity %s could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (activityType, time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout activity %s response: %s" % (latest.status_code, latest.text)) raise ApiException(latest.status_code, "Nightscout activity %s response: %s" % (latest.status_code, latest.text))
j = latest.json() j = latest.json()
if j and len(j) > 0: if j and len(j) > 0:
return j[0] return j[0]
return None return None
try:
ret = internal(False)
if ret is None and (time_start or time_end):
ret = internal(True)
if ret is not None:
logger.warning("last_uploaded_activity with activityType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (activityType, time_start, time_end))
return ret
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors: if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e) logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
@@ -169,29 +129,18 @@ class NightscoutApi:
raise e raise e
def last_uploaded_devicestatus(self, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]: def last_uploaded_devicestatus(self, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]:
def internal(t_to_space: bool) -> Optional[dict]: dateFilter = time_range('created_at', time_start, time_end)
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space) try:
latest = requests.get(urljoin(self.url, 'api/v1/devicestatus?find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={ latest = requests.get(urljoin(self.url, 'api/v1/devicestatus?find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify) }, verify=self.verify)
if latest.status_code != 200: if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout devicestatus could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout devicestatus %s response: %s" % (latest.status_code, latest.text)) raise ApiException(latest.status_code, "Nightscout devicestatus %s response: %s" % (latest.status_code, latest.text))
j = latest.json() j = latest.json()
if j and len(j) > 0: if j and len(j) > 0:
return j[0] return j[0]
return None return None
try:
ret = internal(False)
if ret is None and (time_start or time_end):
ret = internal(True)
if ret is not None:
logger.warning("devicestatus time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
return ret
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors: if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e) logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
@@ -201,7 +150,7 @@ class NightscoutApi:
""" """
Returns general status information about the Nightscout server. Returns general status information about the Nightscout server.
""" """
def api_status(self) -> dict: def api_status(self):
status = requests.get(urljoin(self.url, 'api/v1/status.json'), headers={ status = requests.get(urljoin(self.url, 'api/v1/status.json'), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify) }, verify=self.verify)
@@ -213,7 +162,7 @@ class NightscoutApi:
Returns information on the currently configured Nightscout profile data store Returns information on the currently configured Nightscout profile data store
(contains all profiles in Nightscout under one mongo object). (contains all profiles in Nightscout under one mongo object).
""" """
def current_profile(self, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> dict: def current_profile(self, time_start=None, time_end=None):
r = requests.get(urljoin(self.url, 'api/v1/profile/current?api_secret=' + self.secret), json={}, headers={ r = requests.get(urljoin(self.url, 'api/v1/profile/current?api_secret=' + self.secret), json={}, headers={
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Tests for the Nightscout date-filter query building in time_range()."""
import unittest
import urllib.parse
import arrow
from tconnectsync.nightscout import time_range
class TestTimeRangeEncoding(unittest.TestCase):
"""A positive UTC offset ends an ISO-8601 timestamp with '+02:00'. Placed
raw into a query string, the '+' is a reserved character that servers decode
as a space, so Nightscout receives '2026-07-16T00:00:00 02:00' and rejects
it with "could not parse as a valid ISO-8601 date". Percent-encoding the
value keeps the offset intact."""
def test_positive_offset_is_percent_encoded(self):
start = arrow.get("2026-07-16T00:00:00+02:00")
arg = time_range('created_at', start, None)
self.assertNotIn(
'+', arg,
"A raw '+' in the query string is decoded to a space by the server, "
"mangling the timestamp. Got: %s" % arg,
)
self.assertIn('%2B', arg, "Expected the offset '+' to be encoded as %%2B. Got: %s" % arg)
def test_encoded_value_round_trips_to_the_original_timestamp(self):
"""Decoding the query the way a server would must yield the timestamp
we meant to send."""
start = arrow.get("2026-07-16T00:00:00+02:00")
arg = time_range('created_at', start, None)
value = arg.split('=', 1)[1]
self.assertEqual(
arrow.get(urllib.parse.unquote(value)), start,
"Round-tripping the encoded filter must reproduce the original instant",
)
def test_negative_offset_and_z_suffix_still_parse(self):
"""US pumps report a '-04:00' offset and UTC values end in 'Z'; neither
is ambiguous in a query string, but both must survive encoding."""
for iso in ("2026-07-16T00:00:00-04:00", "2026-07-16T00:00:00Z"):
with self.subTest(iso=iso):
expected = arrow.get(iso)
arg = time_range('created_at', expected, None)
value = arg.split('=', 1)[1]
self.assertEqual(arrow.get(urllib.parse.unquote(value)), expected)
def test_both_bounds_are_emitted(self):
start = arrow.get("2026-07-16T00:00:00+02:00")
end = arrow.get("2026-07-17T00:00:00+02:00")
arg = time_range('created_at', start, end)
self.assertIn('find[created_at][$gte]=', arg)
self.assertIn('find[created_at][$lte]=', arg)
def test_omitted_bounds_produce_no_filter(self):
self.assertEqual(time_range('created_at', None, None), '')
if __name__ == "__main__":
unittest.main()