From b6f90972f06196ddab88a153ce566a4dce11715e Mon Sep 17 00:00:00 2001 From: Camillo bucciarelli Date: Tue, 21 Oct 2025 17:55:36 +0000 Subject: [PATCH 01/10] fix: improve RFC 3339 timestamp parsing in on_schedule function --- src/firebase_functions/scheduler_fn.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/firebase_functions/scheduler_fn.py b/src/firebase_functions/scheduler_fn.py index 1979f67e..e7735c62 100644 --- a/src/firebase_functions/scheduler_fn.py +++ b/src/firebase_functions/scheduler_fn.py @@ -103,10 +103,16 @@ def on_schedule_wrapped(request: _Request) -> _Response: if schedule_time_str is None: schedule_time = _dt.datetime.utcnow() else: - schedule_time = _dt.datetime.strptime( - schedule_time_str, - "%Y-%m-%dT%H:%M:%S%z", - ) + try: + # Robust RFC 3339 parsing + schedule_time = dateutil_parser.isoparse(schedule_time_str) + except ValueError as e: + print(f"Failed to parse RFC 3339 timestamp: {e}") + schedule_time = _dt.utcnow() + schedule_time = _dt.datetime.strptime( + schedule_time_str, + "%Y-%m-%dT%H:%M:%S%z", + ) event = ScheduledEvent( job_name=request.headers.get("X-CloudScheduler-JobName"), schedule_time=schedule_time, From 22c7638e91d5be54a471e2c5fd0ac527449faf13 Mon Sep 17 00:00:00 2001 From: Camillo bucciarelli Date: Tue, 21 Oct 2025 18:11:42 +0000 Subject: [PATCH 02/10] fix: use dateutil.parser for RFC 3339 timestamp parsing in on_schedule function --- src/firebase_functions/scheduler_fn.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/firebase_functions/scheduler_fn.py b/src/firebase_functions/scheduler_fn.py index e7735c62..f83dbeef 100644 --- a/src/firebase_functions/scheduler_fn.py +++ b/src/firebase_functions/scheduler_fn.py @@ -32,6 +32,7 @@ import firebase_functions.options as _options import firebase_functions.private.util as _util from firebase_functions.core import _with_init +import dateutil.parser as dateutil_parser # Re-export Timezone from options module so users can import it directly from scheduler_fn # This provides a more convenient API: from firebase_functions.scheduler_fn import Timezone @@ -109,10 +110,6 @@ def on_schedule_wrapped(request: _Request) -> _Response: except ValueError as e: print(f"Failed to parse RFC 3339 timestamp: {e}") schedule_time = _dt.utcnow() - schedule_time = _dt.datetime.strptime( - schedule_time_str, - "%Y-%m-%dT%H:%M:%S%z", - ) event = ScheduledEvent( job_name=request.headers.get("X-CloudScheduler-JobName"), schedule_time=schedule_time, From 7123e88c8950aff0939bc08c695634d42ca0e119 Mon Sep 17 00:00:00 2001 From: Camillo bucciarelli Date: Tue, 21 Oct 2025 18:29:15 +0000 Subject: [PATCH 03/10] fix: improve RFC 3339 timestamp parsing in on_schedule function --- src/firebase_functions/scheduler_fn.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/firebase_functions/scheduler_fn.py b/src/firebase_functions/scheduler_fn.py index f83dbeef..2c740581 100644 --- a/src/firebase_functions/scheduler_fn.py +++ b/src/firebase_functions/scheduler_fn.py @@ -32,7 +32,6 @@ import firebase_functions.options as _options import firebase_functions.private.util as _util from firebase_functions.core import _with_init -import dateutil.parser as dateutil_parser # Re-export Timezone from options module so users can import it directly from scheduler_fn # This provides a more convenient API: from firebase_functions.scheduler_fn import Timezone @@ -105,11 +104,24 @@ def on_schedule_wrapped(request: _Request) -> _Response: schedule_time = _dt.datetime.utcnow() else: try: - # Robust RFC 3339 parsing - schedule_time = dateutil_parser.isoparse(schedule_time_str) - except ValueError as e: - print(f"Failed to parse RFC 3339 timestamp: {e}") - schedule_time = _dt.utcnow() + # Try to parse with the stdlib which supports fractional + # seconds and offsets in Python 3.11+ via fromisoformat. + # Normalize RFC3339 'Z' to '+00:00' for fromisoformat. + iso_str = schedule_time_str + if iso_str.endswith("Z"): + iso_str = iso_str[:-1] + "+00:00" + schedule_time = _dt.datetime.fromisoformat(iso_str) + except Exception: + # Fallback to strict parsing without fractional seconds + try: + schedule_time = _dt.datetime.strptime( + schedule_time_str, + "%Y-%m-%dT%H:%M:%S%z", + ) + except Exception as e: + # If all parsing fails, log and use current UTC time + _logging.exception(e) + schedule_time = _dt.datetime.utcnow() event = ScheduledEvent( job_name=request.headers.get("X-CloudScheduler-JobName"), schedule_time=schedule_time, From 225fb607251f4d5f809ac190a041d4829ad9613c Mon Sep 17 00:00:00 2001 From: Camillo bucciarelli Date: Tue, 21 Oct 2025 21:11:18 +0200 Subject: [PATCH 04/10] Update src/firebase_functions/scheduler_fn.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/firebase_functions/scheduler_fn.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/firebase_functions/scheduler_fn.py b/src/firebase_functions/scheduler_fn.py index 2c740581..38b069e8 100644 --- a/src/firebase_functions/scheduler_fn.py +++ b/src/firebase_functions/scheduler_fn.py @@ -111,14 +111,14 @@ def on_schedule_wrapped(request: _Request) -> _Response: if iso_str.endswith("Z"): iso_str = iso_str[:-1] + "+00:00" schedule_time = _dt.datetime.fromisoformat(iso_str) - except Exception: + except ValueError: # Fallback to strict parsing without fractional seconds try: schedule_time = _dt.datetime.strptime( schedule_time_str, "%Y-%m-%dT%H:%M:%S%z", ) - except Exception as e: + except ValueError as e: # If all parsing fails, log and use current UTC time _logging.exception(e) schedule_time = _dt.datetime.utcnow() From e4040a44ac4d8406fddbc5127b3409ce3cd12c13 Mon Sep 17 00:00:00 2001 From: Camillo bucciarelli Date: Tue, 4 Nov 2025 11:47:25 +0000 Subject: [PATCH 05/10] fix: use timezone-aware datetime for schedule_time in on_schedule function --- src/firebase_functions/scheduler_fn.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/firebase_functions/scheduler_fn.py b/src/firebase_functions/scheduler_fn.py index 38b069e8..02baf516 100644 --- a/src/firebase_functions/scheduler_fn.py +++ b/src/firebase_functions/scheduler_fn.py @@ -15,6 +15,7 @@ import dataclasses as _dataclasses import datetime as _dt +from datetime import timezone as _timezone import functools as _functools import typing as _typing @@ -101,7 +102,7 @@ def on_schedule_wrapped(request: _Request) -> _Response: schedule_time: _dt.datetime schedule_time_str = request.headers.get("X-CloudScheduler-ScheduleTime") if schedule_time_str is None: - schedule_time = _dt.datetime.utcnow() + schedule_time = _dt.datetime.now(_timezone.utc) else: try: # Try to parse with the stdlib which supports fractional @@ -121,7 +122,7 @@ def on_schedule_wrapped(request: _Request) -> _Response: except ValueError as e: # If all parsing fails, log and use current UTC time _logging.exception(e) - schedule_time = _dt.datetime.utcnow() + schedule_time = _dt.datetime.now(_timezone.utc) event = ScheduledEvent( job_name=request.headers.get("X-CloudScheduler-JobName"), schedule_time=schedule_time, From 2d5be31a422f558a198b533bd7686d81cf127172 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 4 Nov 2025 09:56:44 -0800 Subject: [PATCH 06/10] fix lint issues. --- src/firebase_functions/dataconnect_fn.py | 1 - src/firebase_functions/scheduler_fn.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/firebase_functions/dataconnect_fn.py b/src/firebase_functions/dataconnect_fn.py index d11eb9a6..4682245d 100644 --- a/src/firebase_functions/dataconnect_fn.py +++ b/src/firebase_functions/dataconnect_fn.py @@ -17,7 +17,6 @@ # pylint: disable=protected-access import dataclasses as _dataclass -import datetime as _dt import functools as _functools import typing as _typing diff --git a/src/firebase_functions/scheduler_fn.py b/src/firebase_functions/scheduler_fn.py index 02baf516..995228d7 100644 --- a/src/firebase_functions/scheduler_fn.py +++ b/src/firebase_functions/scheduler_fn.py @@ -15,9 +15,9 @@ import dataclasses as _dataclasses import datetime as _dt -from datetime import timezone as _timezone import functools as _functools import typing as _typing +from datetime import timezone as _timezone from flask import ( Request as _Request, From 4ad3c21c1eb3bc03904eecabcd39751273136ee2 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 4 Nov 2025 09:58:35 -0800 Subject: [PATCH 07/10] make formatter happy. --- src/firebase_functions/dataconnect_fn.py | 27 ++++++------- src/firebase_functions/options.py | 24 ++++++------ tests/repro_async_error.py | 48 ++++++++++++++++++++++++ tests/test_dataconnect_fn.py | 2 +- 4 files changed, 73 insertions(+), 28 deletions(-) create mode 100644 tests/repro_async_error.py diff --git a/src/firebase_functions/dataconnect_fn.py b/src/firebase_functions/dataconnect_fn.py index 4682245d..a005c221 100644 --- a/src/firebase_functions/dataconnect_fn.py +++ b/src/firebase_functions/dataconnect_fn.py @@ -208,7 +208,9 @@ def _dataconnect_endpoint_handler( ) -> None: # Currently, only mutationExecuted is supported if event_type != _event_type_mutation_executed: - raise NotImplementedError(f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported.") + raise NotImplementedError( + f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported." + ) event_attributes = raw._get_attributes() event_data: _typing.Any = raw.get_data() @@ -223,15 +225,9 @@ def _dataconnect_endpoint_handler( if service_pattern: params = {**params, **service_pattern.extract_matches(event_service)} if connector_pattern: - params = { - **params, - **connector_pattern.extract_matches(event_connector) - } + params = {**params, **connector_pattern.extract_matches(event_connector)} if operation_pattern: - params = { - **params, - **operation_pattern.extract_matches(event_operation) - } + params = {**params, **operation_pattern.extract_matches(event_operation)} event_auth_type = event_attributes["authtype"] event_auth_id = event_attributes["authid"] @@ -281,12 +277,13 @@ def mutation_executed_handler(event: Event[MutationEventData]): options = DataConnectOptions(**kwargs) def on_mutation_executed_inner_decorator(func: _C1): - service_pattern = _path_pattern.PathPattern( - options.service) if options.service else None - connector_pattern = _path_pattern.PathPattern( - options.connector) if options.connector else None - operation_pattern = _path_pattern.PathPattern( - options.operation) if options.operation else None + service_pattern = _path_pattern.PathPattern(options.service) if options.service else None + connector_pattern = ( + _path_pattern.PathPattern(options.connector) if options.connector else None + ) + operation_pattern = ( + _path_pattern.PathPattern(options.operation) if options.operation else None + ) @_functools.wraps(func) def on_mutation_executed_wrapped(raw: _ce.CloudEvent): diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index c3ba16f0..ee084cbe 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -1188,22 +1188,22 @@ def _endpoint( event_filters_path_patterns: _typing.Any = {} if self.service: - if service_pattern.has_wildcards: - event_filters_path_patterns["service"] = service_pattern.value - else: - event_filters["service"] = service_pattern.value + if service_pattern.has_wildcards: + event_filters_path_patterns["service"] = service_pattern.value + else: + event_filters["service"] = service_pattern.value if self.connector: - if connector_pattern.has_wildcards: - event_filters_path_patterns["connector"] = connector_pattern.value - else: - event_filters["connector"] = connector_pattern.value + if connector_pattern.has_wildcards: + event_filters_path_patterns["connector"] = connector_pattern.value + else: + event_filters["connector"] = connector_pattern.value if self.operation: - if operation_pattern.has_wildcards: - event_filters_path_patterns["operation"] = operation_pattern.value - else: - event_filters["operation"] = operation_pattern.value + if operation_pattern.has_wildcards: + event_filters_path_patterns["operation"] = operation_pattern.value + else: + event_filters["operation"] = operation_pattern.value event_trigger = _manifest.EventTrigger( eventType=kwargs["event_type"], diff --git a/tests/repro_async_error.py b/tests/repro_async_error.py new file mode 100644 index 00000000..6e13ff76 --- /dev/null +++ b/tests/repro_async_error.py @@ -0,0 +1,48 @@ +import json + +import pytest +from flask import Request, Response + +from firebase_functions import https_fn + + +# Mock request object +class MockRequest: + def __init__(self, data=None, headers=None, method="POST"): + self.data = json.dumps(data).encode("utf-8") if data else b"" + self.headers = headers or {"Content-Type": "application/json"} + self.method = method + self.json = data + + +# Async function with error +@https_fn.on_request() +async def async_error_func(req: Request) -> Response: + raise ValueError("Async error") + + +# Sync function with error +@https_fn.on_request() +def sync_error_func(req: Request) -> Response: + raise ValueError("Sync error") + + +@pytest.mark.asyncio +async def test_async_error_handling(): + req = MockRequest() + try: + await async_error_func(req) + except ValueError as e: + assert str(e) == "Async error" + except Exception as e: + pytest.fail(f"Unexpected exception: {type(e).__name__}: {e}") + + +def test_sync_error_handling(): + req = MockRequest() + try: + sync_error_func(req) + except ValueError as e: + assert str(e) == "Sync error" + except Exception as e: + pytest.fail(f"Unexpected exception: {type(e).__name__}: {e}") diff --git a/tests/test_dataconnect_fn.py b/tests/test_dataconnect_fn.py index 6fb55f60..907a3363 100644 --- a/tests/test_dataconnect_fn.py +++ b/tests/test_dataconnect_fn.py @@ -132,7 +132,7 @@ def init(): "connector": "connector-id", "operation": "mutation-name", "authtype": "app_user", - "authid": "auth-id" + "authid": "auth-id", }, data=json.dumps({}), ) From c4e75d264262b2eae0c359d783cf5189aa9eb7cb Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 4 Nov 2025 10:05:29 -0800 Subject: [PATCH 08/10] revert accidentally commmited file. --- tests/repro_async_error.py | 48 -------------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 tests/repro_async_error.py diff --git a/tests/repro_async_error.py b/tests/repro_async_error.py deleted file mode 100644 index 6e13ff76..00000000 --- a/tests/repro_async_error.py +++ /dev/null @@ -1,48 +0,0 @@ -import json - -import pytest -from flask import Request, Response - -from firebase_functions import https_fn - - -# Mock request object -class MockRequest: - def __init__(self, data=None, headers=None, method="POST"): - self.data = json.dumps(data).encode("utf-8") if data else b"" - self.headers = headers or {"Content-Type": "application/json"} - self.method = method - self.json = data - - -# Async function with error -@https_fn.on_request() -async def async_error_func(req: Request) -> Response: - raise ValueError("Async error") - - -# Sync function with error -@https_fn.on_request() -def sync_error_func(req: Request) -> Response: - raise ValueError("Sync error") - - -@pytest.mark.asyncio -async def test_async_error_handling(): - req = MockRequest() - try: - await async_error_func(req) - except ValueError as e: - assert str(e) == "Async error" - except Exception as e: - pytest.fail(f"Unexpected exception: {type(e).__name__}: {e}") - - -def test_sync_error_handling(): - req = MockRequest() - try: - sync_error_func(req) - except ValueError as e: - assert str(e) == "Sync error" - except Exception as e: - pytest.fail(f"Unexpected exception: {type(e).__name__}: {e}") From 87b911605690a6ca7a43d2eda8ff9729c06d3fbb Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 4 Nov 2025 10:12:09 -0800 Subject: [PATCH 09/10] add unit tests. --- tests/test_scheduler_fn.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_scheduler_fn.py b/tests/test_scheduler_fn.py index b3a8c82f..2a9c0e59 100644 --- a/tests/test_scheduler_fn.py +++ b/tests/test_scheduler_fn.py @@ -14,7 +14,7 @@ """Scheduler function tests.""" import unittest -from datetime import datetime +from datetime import datetime, timezone from unittest.mock import Mock from flask import Flask, Request @@ -81,6 +81,30 @@ def test_on_schedule_call(self): ) ) + def test_on_schedule_call_with_z_suffix(self): + """ + Tests to ensure that timestamps with 'Z' suffix are parsed correctly as UTC. + """ + with Flask(__name__).test_request_context("/"): + environ = EnvironBuilder( + headers={ + "X-CloudScheduler-JobName": "example-job", + "X-CloudScheduler-ScheduleTime": "2023-04-13T19:00:00Z", + } + ).get_environ() + mock_request = Request(environ) + example_func = Mock(__name__="example_func") + decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func) + response = decorated_func(mock_request) + + self.assertEqual(response.status_code, 200) + example_func.assert_called_once_with( + scheduler_fn.ScheduledEvent( + job_name="example-job", + schedule_time=datetime(2023, 4, 13, 19, 0, 0, tzinfo=timezone.utc), + ) + ) + def test_on_schedule_call_with_no_headers(self): """ Tests to ensure that if the function is called manually @@ -99,6 +123,7 @@ def test_on_schedule_call_with_no_headers(self): self.assertEqual(example_func.call_count, 1) self.assertIsNone(example_func.call_args[0][0].job_name) self.assertIsNotNone(example_func.call_args[0][0].schedule_time) + self.assertIsNotNone(example_func.call_args[0][0].schedule_time.tzinfo) def test_on_schedule_call_with_exception(self): """ From 900f511ccd66bbf397c6f137efce68f60b708d32 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 4 Nov 2025 10:21:27 -0800 Subject: [PATCH 10/10] add more unit tests per gemini review. --- tests/test_scheduler_fn.py | 86 +++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/tests/test_scheduler_fn.py b/tests/test_scheduler_fn.py index 2a9c0e59..f3ad92f1 100644 --- a/tests/test_scheduler_fn.py +++ b/tests/test_scheduler_fn.py @@ -15,7 +15,7 @@ import unittest from datetime import datetime, timezone -from unittest.mock import Mock +from unittest.mock import Mock, patch from flask import Flask, Request from werkzeug.test import EnvironBuilder @@ -146,6 +146,90 @@ def test_on_schedule_call_with_exception(self): self.assertEqual(response.status_code, 500) self.assertEqual(response.data, b"Test exception") + def test_on_schedule_call_with_fractional_seconds(self): + """ + Tests to ensure that timestamps with fractional seconds are parsed correctly. + """ + with Flask(__name__).test_request_context("/"): + environ = EnvironBuilder( + headers={ + "X-CloudScheduler-JobName": "example-job", + "X-CloudScheduler-ScheduleTime": "2023-04-13T19:00:00.123456Z", + } + ).get_environ() + mock_request = Request(environ) + example_func = Mock(__name__="example_func") + decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func) + response = decorated_func(mock_request) + + self.assertEqual(response.status_code, 200) + example_func.assert_called_once_with( + scheduler_fn.ScheduledEvent( + job_name="example-job", + schedule_time=datetime(2023, 4, 13, 19, 0, 0, 123456, tzinfo=timezone.utc), + ) + ) + + def test_on_schedule_call_fallback_parsing(self): + """ + Tests fallback parsing for formats that might fail fromisoformat + but pass strptime (e.g., offset without colon). + """ + with Flask(__name__).test_request_context("/"): + environ = EnvironBuilder( + headers={ + "X-CloudScheduler-JobName": "example-job", + # Offset without colon might fail fromisoformat in some versions + # but should pass strptime("%Y-%m-%dT%H:%M:%S%z") + "X-CloudScheduler-ScheduleTime": "2023-04-13T12:00:00-0700", + } + ).get_environ() + mock_request = Request(environ) + example_func = Mock(__name__="example_func") + decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func) + response = decorated_func(mock_request) + + self.assertEqual(response.status_code, 200) + + # Create expected datetime with fixed offset -07:00 + tz = datetime.strptime("-0700", "%z").tzinfo + expected_dt = datetime(2023, 4, 13, 12, 0, 0, tzinfo=tz) + + example_func.assert_called_once_with( + scheduler_fn.ScheduledEvent( + job_name="example-job", + schedule_time=expected_dt, + ) + ) + + def test_on_schedule_call_invalid_timestamp(self): + """ + Tests that invalid timestamps log an error and fallback to current time. + """ + with Flask(__name__).test_request_context("/"): + environ = EnvironBuilder( + headers={ + "X-CloudScheduler-JobName": "example-job", + "X-CloudScheduler-ScheduleTime": "invalid-timestamp", + } + ).get_environ() + mock_request = Request(environ) + example_func = Mock(__name__="example_func") + + with patch("firebase_functions.scheduler_fn._logging") as mock_logging: + decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func) + response = decorated_func(mock_request) + + self.assertEqual(response.status_code, 200) + mock_logging.exception.assert_called_once() + + # Should have called with *some* time (current time), so we just check it's not None + self.assertEqual(example_func.call_count, 1) + called_event = example_func.call_args[0][0] + self.assertEqual(called_event.job_name, "example-job") + self.assertIsNotNone(called_event.schedule_time) + self.assertIsNotNone(called_event.schedule_time.tzinfo) + def test_calls_init(self): hello = None