From 684915db54ee0380d346ad182a1ada82a3797f5f Mon Sep 17 00:00:00 2001 From: ANISAYAK MITRA <266799942+anisayakmitra-in@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:42:26 +0530 Subject: [PATCH 1/2] fix(proxy/stagger): preserve timeline arithmetic across DST fall-back in _OffsetTrigger (#48286) --- .../common_utils/scheduled_job_stagger.py | 24 +++++++++++++++---- .../test_scheduled_job_stagger.py | 22 +++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/common_utils/scheduled_job_stagger.py b/litellm/proxy/common_utils/scheduled_job_stagger.py index e48e9686f13..36ca9960d17 100644 --- a/litellm/proxy/common_utils/scheduled_job_stagger.py +++ b/litellm/proxy/common_utils/scheduled_job_stagger.py @@ -33,7 +33,7 @@ import hashlib import os import socket from collections.abc import Callable, Mapping, Sequence -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from types import MappingProxyType from typing import Final, Protocol @@ -108,6 +108,20 @@ class JobSubmission(Protocol): def scheduled_run_times(self) -> Sequence[datetime]: ... +def _shift_datetime(dt: datetime, delta: timedelta) -> datetime: + """ + Shift a datetime by delta preserving absolute time (timeline) arithmetic. + + Standard `datetime - delta` on timezone-aware datetimes resets `fold = 0` during + ambiguous hours (such as DST fall-back). Converting to UTC before arithmetic and + then converting back to the source tzinfo ensures the shift operates on absolute + time and preserves fold and correct fire ordering. + """ + if dt.tzinfo is None: + return dt + delta + return (dt.astimezone(timezone.utc) + delta).astimezone(dt.tzinfo) + + class _OffsetTrigger: """ Delegates to ``base`` on a clock rolled back by ``offset``, then rolls the answer @@ -125,9 +139,11 @@ class _OffsetTrigger: self.offset = offset def get_next_fire_time(self, previous_fire_time: datetime | None, now: datetime) -> datetime | None: - shifted_previous: Final = None if previous_fire_time is None else previous_fire_time - self.offset - next_fire_time: Final = self.base.get_next_fire_time(shifted_previous, now - self.offset) - return None if next_fire_time is None else next_fire_time + self.offset + shifted_previous: Final = ( + None if previous_fire_time is None else _shift_datetime(previous_fire_time, -self.offset) + ) + next_fire_time: Final = self.base.get_next_fire_time(shifted_previous, _shift_datetime(now, -self.offset)) + return None if next_fire_time is None else _shift_datetime(next_fire_time, self.offset) def __str__(self) -> str: return f"{self.base}[+{int(self.offset.total_seconds())}s]" diff --git a/tests/unit/proxy/common_utils/test_scheduled_job_stagger.py b/tests/unit/proxy/common_utils/test_scheduled_job_stagger.py index 3eabfc5c840..ce7d37cef24 100644 --- a/tests/unit/proxy/common_utils/test_scheduled_job_stagger.py +++ b/tests/unit/proxy/common_utils/test_scheduled_job_stagger.py @@ -3,6 +3,7 @@ import logging import os from datetime import datetime, timedelta, timezone from types import SimpleNamespace +from zoneinfo import ZoneInfo import pytest from apscheduler.executors.asyncio import AsyncIOExecutor @@ -14,6 +15,7 @@ from apscheduler.triggers.interval import IntervalTrigger from litellm.constants import PTU_ROLLUP_JOB_ID, PTU_ROLLUP_LOCK_TTL_SECONDS from litellm.proxy._types import ScheduledJobStaggerSettings from litellm.proxy.common_utils.scheduled_job_stagger import ( + _OffsetTrigger, apply_scheduled_job_stagger, attach_job_timing_logger, offset_seconds, @@ -309,3 +311,23 @@ def test_job_timing_is_logged_with_scheduled_and_actual_start(caplog): assert f"scheduled_run_time={scheduled.isoformat()}" in message assert "actual_start_time=" in message assert "delay=2." in message + + +def test_offset_trigger_handles_dst_fall_back_without_looping(): + """Regression test for #48286: during DST fall-back (fold=1), shifted time must not land in fold=0 and loop.""" + tz = ZoneInfo("Europe/Paris") + base = IntervalTrigger(seconds=60, timezone=tz) + trigger = _OffsetTrigger(base, timedelta(seconds=30)) + + # Test normal CEST, DST fall-back hour (fold=1), and normal CET + for label, now in [ + ("01:30 CEST", datetime(2026, 10, 25, 1, 30, tzinfo=tz)), + ("02:30 CET (repeated hour)", datetime(2026, 10, 25, 2, 30, fold=1, tzinfo=tz)), + ("03:30 CET", datetime(2026, 10, 25, 3, 30, tzinfo=tz)), + ]: + nrt = datetime.fromtimestamp(now.timestamp() - 90, tz) + count = 0 + while nrt and nrt.astimezone(timezone.utc) <= now.astimezone(timezone.utc) and count < 100: + count += 1 + nrt = trigger.get_next_fire_time(nrt, now) + assert count == 2, f"Failed for {label}: count={count}" From 0bd3431b88ec7d8e4771b8b4b1d86fe2547c35e4 Mon Sep 17 00:00:00 2001 From: ANISAYAK MITRA <266799942+anisayakmitra-in@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:33:50 +0530 Subject: [PATCH 2/2] test(proxy/stagger): add coverage for naive datetime shifting in _shift_datetime --- .../unit/proxy/common_utils/test_scheduled_job_stagger.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/proxy/common_utils/test_scheduled_job_stagger.py b/tests/unit/proxy/common_utils/test_scheduled_job_stagger.py index ce7d37cef24..a15538a780c 100644 --- a/tests/unit/proxy/common_utils/test_scheduled_job_stagger.py +++ b/tests/unit/proxy/common_utils/test_scheduled_job_stagger.py @@ -16,6 +16,7 @@ from litellm.constants import PTU_ROLLUP_JOB_ID, PTU_ROLLUP_LOCK_TTL_SECONDS from litellm.proxy._types import ScheduledJobStaggerSettings from litellm.proxy.common_utils.scheduled_job_stagger import ( _OffsetTrigger, + _shift_datetime, apply_scheduled_job_stagger, attach_job_timing_logger, offset_seconds, @@ -331,3 +332,9 @@ def test_offset_trigger_handles_dst_fall_back_without_looping(): count += 1 nrt = trigger.get_next_fire_time(nrt, now) assert count == 2, f"Failed for {label}: count={count}" + + +def test_shift_datetime_handles_naive_and_timezone_aware(): + naive = datetime(2026, 1, 1, 12, 0, 0) + assert _shift_datetime(naive, timedelta(seconds=10)) == datetime(2026, 1, 1, 12, 0, 10) + assert _shift_datetime(naive, -timedelta(seconds=10)) == datetime(2026, 1, 1, 11, 59, 50)