From a68d6ecc5eac0588120a7d9fbfce51cf40c72dcc Mon Sep 17 00:00:00 2001 From: bboysoul Date: Mon, 21 Sep 2026 15:38:52 +0800 Subject: [PATCH] fix: pace failed calendar refresh retries --- backend/calendar_refresh.py | 16 +++++--- tests/test_calendar_subscriptions.py | 55 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/backend/calendar_refresh.py b/backend/calendar_refresh.py index dae11bc..d63a601 100644 --- a/backend/calendar_refresh.py +++ b/backend/calendar_refresh.py @@ -10,7 +10,7 @@ from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from . import calendar as calendar_service -from .models import CalendarSubscription, utcnow +from .models import CalendarSubscription logger = logging.getLogger(__name__) DEFAULT_REFRESH_INTERVAL = timedelta(minutes=15) @@ -22,7 +22,9 @@ async def refresh_subscription_cache( row: CalendarSubscription, *, fail_without_cache: bool = True, + attempted_at: datetime | None = None, ) -> bool: + attempt_time = attempted_at or datetime.now(UTC) requested_url = row.url requested_version = getattr(row, "updated_at", None) try: @@ -51,7 +53,8 @@ async def refresh_subscription_cache( row.ics_cache = result.content.decode("utf-8-sig") row.etag = result.etag row.last_modified = result.last_modified - row.refreshed_at = utcnow() + row.refreshed_at = attempt_time + row.updated_at = attempt_time row.last_error = None await db.commit() return True @@ -61,6 +64,7 @@ async def refresh_subscription_cache( await db.rollback() raise HTTPException(502, error) from exc row.last_error = error + row.updated_at = attempt_time await db.commit() return False @@ -76,13 +80,15 @@ async def refresh_due_subscriptions( rows = (await db.scalars(select(CalendarSubscription).where( CalendarSubscription.enabled.is_(True), or_( - CalendarSubscription.refreshed_at.is_(None), - CalendarSubscription.refreshed_at <= cutoff, + CalendarSubscription.updated_at.is_(None), + CalendarSubscription.updated_at <= cutoff, ), ).order_by(CalendarSubscription.refreshed_at, CalendarSubscription.created_at))).all() for row in rows: try: - await refresh_subscription_cache(db, row, fail_without_cache=False) + await refresh_subscription_cache( + db, row, fail_without_cache=False, attempted_at=current + ) except Exception: await db.rollback() logger.exception("Unexpected calendar refresh failure", extra={"subscription_id": str(row.id)}) diff --git a/tests/test_calendar_subscriptions.py b/tests/test_calendar_subscriptions.py index d1e1508..76cf84f 100644 --- a/tests/test_calendar_subscriptions.py +++ b/tests/test_calendar_subscriptions.py @@ -351,3 +351,58 @@ def test_refresh_discards_response_when_url_changes_in_flight(monkeypatch): assert row.url == "https://example.com/new.ics" assert row.ics_cache is None assert db.committed is False + + +def test_failed_source_waits_until_next_interval_and_does_not_abort_batch(client, monkeypatch): + client = initialized(client) + monkeypatch.setattr( + "backend.calendar.validate_calendar_url", + lambda url: (url, "93.184.216.34", 443), + ) + monkeypatch.setattr( + "backend.calendar.fetch_calendar", + lambda *args, **kwargs: FetchResult(ICS, None, None, False), + ) + first = client.post( + "/api/v1/calendar-subscriptions", + json={"name": "first", "url": "https://example.com/first.ics"}, + ).json() + client.post( + "/api/v1/calendar-subscriptions", + json={"name": "second", "url": "https://example.com/second.ics"}, + ) + attempts = [] + + def fetch(url, **kwargs): + attempts.append(url) + if url.endswith("first.ics"): + raise HTTPException(502, "upstream down") + return FetchResult(ICS, None, None, False) + + monkeypatch.setattr("backend.calendar.fetch_calendar", fetch) + + async def refresh_twice(): + from sqlalchemy.ext.asyncio import async_sessionmaker + + from backend.db import get_engine + + factory = async_sessionmaker(get_engine(), expire_on_commit=False) + now = datetime.now(UTC) + timedelta(minutes=16) + async with factory() as db: + first_count = await refresh_due_subscriptions( + db, now=now, refresh_interval=timedelta(minutes=15) + ) + async with factory() as db: + second_count = await refresh_due_subscriptions( + db, now=now + timedelta(minutes=1), refresh_interval=timedelta(minutes=15) + ) + return first_count, second_count + + assert asyncio.run(refresh_twice()) == (2, 0) + assert attempts == [ + "https://example.com/first.ics", + "https://example.com/second.ics", + ] + listed = client.get("/api/v1/calendar-subscriptions").json() + failed = next(item for item in listed if item["id"] == first["id"]) + assert failed["stale"] is True