From 411c34d1fb56e7ddaa4e3969e4fc75845c9ecad9 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Fri, 25 Sep 2026 15:30:08 +0000 Subject: [PATCH 1/2] fix(fcm): encode durations without floating-point rounding errors encode_ttl and encode_milliseconds computed the nanoseconds from the float returned by timedelta.total_seconds(), so some durations were sent with rounding errors: an AndroidConfig.ttl of 86400.9 seconds (or timedelta(days=1, microseconds=900000)) was encoded as "86400.899999999s", and 1.123457 as "1.123456999s". The Duration string is now built from the exact days/seconds/microseconds of the timedelta. --- firebase_admin/_messaging_encoder.py | 24 ++++++++++++------------ tests/test_messaging.py | 3 +++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index b7c69107..9d02a951 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -16,7 +16,6 @@ import datetime import json -import math import numbers import re import warnings @@ -275,14 +274,9 @@ def encode_ttl(cls, ttl): if not isinstance(ttl, datetime.timedelta): raise ValueError('AndroidConfig.ttl must be a duration in seconds or an instance of ' 'datetime.timedelta.') - total_seconds = ttl.total_seconds() - if total_seconds < 0: + if ttl < datetime.timedelta(0): raise ValueError('AndroidConfig.ttl must not be negative.') - seconds = int(math.floor(total_seconds)) - nanos = int((total_seconds - seconds) * 1e9) - if nanos: - return f'{seconds}.{str(nanos).zfill(9)}s' - return f'{seconds}s' + return cls.encode_duration(ttl) @classmethod def encode_milliseconds(cls, label, msec): @@ -294,11 +288,17 @@ def encode_milliseconds(cls, label, msec): if not isinstance(msec, datetime.timedelta): raise ValueError( f'{label} must be a duration in milliseconds or an instance of datetime.timedelta.') - total_seconds = msec.total_seconds() - if total_seconds < 0: + if msec < datetime.timedelta(0): raise ValueError(f'{label} must not be negative.') - seconds = int(math.floor(total_seconds)) - nanos = int((total_seconds - seconds) * 1e9) + return cls.encode_duration(msec) + + @classmethod + def encode_duration(cls, duration): + """Encodes a non-negative ``datetime.timedelta`` into a protobuf Duration string.""" + # Use the exact integer fields of the timedelta instead of total_seconds(), which + # is a float and can turn e.g. 86400.9 seconds into 86400.899999999s. + seconds = duration.days * 86400 + duration.seconds + nanos = duration.microseconds * 1000 if nanos: return f'{seconds}.{str(nanos).zfill(9)}s' return f'{seconds}s' diff --git a/tests/test_messaging.py b/tests/test_messaging.py index ae2e4a4d..e5783227 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -466,6 +466,9 @@ def test_android_config(self): (123, '123s'), (123.45, '123.450000000s'), (datetime.timedelta(days=1, seconds=100), '86500s'), + (1.123457, '1.123457000s'), + (86400.9, '86400.900000000s'), + (datetime.timedelta(days=1, microseconds=900000), '86400.900000000s'), ]) def test_android_ttl(self, ttl): msg = messaging.Message( From 536ab590b3eab89937eee63390cce8f68e96cceb Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Fri, 25 Sep 2026 16:08:58 +0000 Subject: [PATCH 2/2] Format the nanoseconds with an integer format spec --- firebase_admin/_messaging_encoder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 9d02a951..9aa38536 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -300,7 +300,7 @@ def encode_duration(cls, duration): seconds = duration.days * 86400 + duration.seconds nanos = duration.microseconds * 1000 if nanos: - return f'{seconds}.{str(nanos).zfill(9)}s' + return f'{seconds}.{nanos:09d}s' return f'{seconds}s' @classmethod