Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ class FeatureSet:
"https://datatracker.ietf.org/doc/html/rfc5689",
],
"extra_keys": {
"behaviour": "'mkcol-required' when MKCALENDAR is refused and the RFC5689 extended MKCOL has to be used instead - the library selects MKCOL for exactly this value. 'empty-207' when the server answers a successful creation with a multistatus whose DAV:response carries neither a DAV:status nor a DAV:propstat, in violation of RFC4918 section 13 (Bedework 5); purely descriptive, the library copes with it either way. A creation that is accepted but materialises later is synchronous-write.create-calendar.",
"behaviour": "'mkcol-required' when MKCALENDAR is refused and the RFC5689 extended MKCOL has to be used instead - the library selects MKCOL for exactly this value. 'empty-207' when the server answers a successful creation with a multistatus whose DAV:response carries neither a DAV:status nor a DAV:propstat, in violation of RFC4918 section 13 (Bedework 5); purely descriptive, the library copes with it either way. Otherwise free text describing a limit, e.g. a cap on the number of calendars per account (GMX). A creation that is accepted but materialises later is synchronous-write.create-calendar.",
},
},
"create-calendar.auto": {
Expand Down Expand Up @@ -1058,11 +1058,10 @@ class FeatureSet:
"description": "Server allows two events with identical content but different UIDs to coexist in the same calendar. Some servers reject or de-duplicate such an event ('duplicates not allowed even with a different UID'), in which case this is 'unsupported' (silently dropped) or 'ungraceful' (rejected with an error). The default 'full' is the usual behaviour.",
"default": {"support": "full"},
},
## TODO: as for now, the tests will run towards the first calendar it will find, and most of the tests will assume the calendar is empty. This is bad.
"test-calendar": {
"type": "tests-behaviour",
"description": "if the server does not allow creating new calendars, then use the calendar with the given name for running tests (NOT SUPPORTED YET!), wipe the calendar between each test run (alternative for calendars not supporting the creation of new calendars is a very expensive delete objects one-by-one by uid)",
"extra_keys": { "name": "calendar name", "cleanup-regime": "thorough|pre|post|light|wipe-calendar" }
"description": "if the server does not allow creating new calendars, then use the calendar with the given name, cal_id or cal_url for running tests, wipe the calendar between each test run (alternative for calendars not supporting the creation of new calendars is a very expensive delete objects one-by-one by uid). Without it, only a calendar with 'pythoncaldav-test' in its display name or at the test fixture's cal_id is used, and the tests needing a calendar are skipped if there is none",
"extra_keys": { "name": "calendar name", "cal_id": "calendar id", "cal_url": "calendar URL", "cleanup-regime": "thorough|pre|post|light|wipe-calendar" }
},
"test-calendar.compatibility-tests": {
"type": "tests-behaviour",
Expand Down Expand Up @@ -3042,7 +3041,13 @@ def __getattr__(name: str) -> Any:
## created (at the requested URL) and deleted on the first attempt.
## 2026-10-23: one run created calendars fine (set-displayname and
## stable-url full too).
'create-calendar': {'support': 'fragile'},
## 2026-11-10: the failures were a cap, not flapping. Holding three
## calendars (one a leftover from a test run), the account took two
## MKCALENDARs and refused the third with 403, twice in a row; holding
## two, it took three and refused the fourth. Calendars leaked by test
## runs eat into the cap: if creation starts failing with 403, delete
## the leftovers.
'create-calendar': {'support': 'full', 'behaviour': 'at most 5 calendars per account, the built-in Geburtstage (birthday) calendar included; MKCALENDAR beyond that is refused with 403'},
'create-calendar.auto': {'support': 'unsupported'},
'create-calendar.with-supported-component-types': {'support': 'unsupported'},
## Never probed: stated so the parent's hint is not read as an answer
Expand Down
1 change: 1 addition & 0 deletions changes/+no-borrowed-calendar-wipe.tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The integration tests no longer wipe the first calendar they find on a server where they cannot create one (Posteo and other servers where `create-calendar` is unsupported or fragile, GMX once its five-calendar cap is reached, and any server that refuses MKCALENDAR when the caller gives no cal_id). They use a calendar with `pythoncaldav-test` in its display name, the one at the test fixture's cal_id, or the one named under `test-calendar` in the server's features, and skip otherwise. On such servers, set a calendar aside for the tests to run them again.
1 change: 1 addition & 0 deletions changes/624.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GMX: `create-calendar` is `full` (it was `fragile`). GMX allows at most 5 calendars per account, its birthday calendar included, and refuses MKCALENDAR beyond that with 403; calendars left behind by earlier test runs made creation look fragile.
61 changes: 59 additions & 2 deletions docs/design/RETRY_AND_RESILIENCE_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,28 @@ arguably 500/502/504. This is #620's original subject. Retrying is safe (the
server declined to act), but it needs a *sleep*, ideally honouring
`Retry-After`.

423 Locked belongs to this class too. RFC 4918 section 11.3: "The 423
(Locked) status code means the source or destination resource of a method is
locked." It is meant for resources locked with WebDAV `LOCK`, but servers
also use it for their own short internal locks. Xandikos answers a PUT or
DELETE with 423 when its git store's `index.lock` is held, i.e. when another
request is writing to the same collection at that moment. It does not wait
for the lock itself. A retry after a short sleep then succeeds. Seen in the
wild from plann's panic postponement. That saves many tasks in a row, but one
at a time, and Xandikos frees the lock before it answers, so plann cannot
collide with itself: some other writer was busy at the same moment, and which
one is unknown. A burst of saves only makes such a collision more likely.
The library already retried that PUT, but at once (see §4.6), so the lock was
still held and the 423 reached the caller. Two cases where retrying does not
help: a real `LOCK` held by another client, which lasts until that client
unlocks or the lock times out, which may be minutes; and a stale `index.lock`
left behind by a crashed Xandikos or git process, which lasts until someone
deletes it. A `Retry-After` on the 423 would not help either: urllib3 only
honours it on 413, 429 and 503 (`Retry.RETRY_AFTER_STATUS_CODES`), so a capped
backoff is what decides how long we wait. Unlike
429/503, 423 is about one resource and not the whole server, so it is not
rate limiting and does not belong to the `rate-limit` feature.

The library already handles a subset of (C): `raise_if_rate_limited()` raises
`RateLimitError` on 429/503, and `DAVClient.request()` /
`AsyncDAVClient._async_request()` sleep and retry via
Expand Down Expand Up @@ -380,7 +402,7 @@ misdescribes class (A):
"type": "client-feature",
"description": "The client retries requests that failed for transport "
"reasons, and optionally requests answered with a "
"server-error status. The retrying itself is done by the "
"status in status_forcelist. The retrying itself is done by the "
"HTTP library, not by the CalDAV library: these keys are "
"mapped onto urllib3's Retry for niquests and requests. "
"The httpx fallbacks can only retry connection setup, and "
Expand All @@ -404,7 +426,14 @@ keep-alive connection has been closed") without turning fast failures into slow
ones:

* `connect=2`, `read=1`, `status=0`, `status_forcelist=[]` - **on by default**
* everything in class (C) beyond 429/503 - **opt-in**, as #620 asked
* everything in class (C) beyond 429/503 - **opt-in**, as #620 asked.
That includes 423: `status_forcelist=[423]` with `status=3` covers the
Xandikos case. Note that urllib3 never sleeps before the *first* retry
(`get_backoff_time()` is 0 until the second), so with `backoff_factor=0.5`
the sleeps are 0, 1 and 2 seconds, and the first retry is as immediate as
the one in §4.6. `initial_delay` has no counterpart in `Retry`; it needs a
subclass overriding `get_backoff_time()`. Whether 423 should be on by
default is Q7.
* `backoff_factor=0.5`, `max_delay=30`, `jitter=true`, `initial_delay=0`

Rationale for `read=1` being on by default: the #695 case is a *read* error in
Expand Down Expand Up @@ -488,6 +517,21 @@ is unaffected by all of this, and niquests' `LeakyBucketLimiter` /
`TokenBucketLimiter` are the obvious home for it if it ever moves into the
library.

### 4.6 The immediate PUT retry in `_post_put`

Separate from all of the above, `CalendarObjectResource._post_put()` retries a
failed PUT once, at once and without a sleep, when vobject is installed. It
is meant for data that the server rejected and that vobject's cleanup might
fix (https://github.com/python-caldav/caldav/issues/43), but it fires on
almost any status that is not 2xx or 3xx: only a UID conflict, a 412 and a
save sent with a wildcard (`If-None-Match: *` / `If-Match: *`) are left
alone. On a 423 (or a 503 without `Retry-After`, which
`raise_if_rate_limited()` lets through) it only sends the same PUT into the same
lock. Its other problem, that the first answer is hidden from the caller, is
https://github.com/python-caldav/caldav/issues/847. Whatever is decided there,
the immediate retry should leave the statuses of class (C) alone, so that the
retry logic of this design is the only one that acts on them.

## 5. Consequences for PR #648

PR #648 is closed rather than merged. It is not wrong about the bug - it is at
Expand Down Expand Up @@ -596,6 +640,19 @@ feature. Step 7 is optional and step 6 is not.
urllib3-future, since without a cap on `Retry-After` the delegation is a
regression, not a simplification. Sub-question: is filing that upstream
something we want to spend the effort on?
* **Q7.** Should 423 be in the default `status_forcelist`? For it: the
Xandikos-style lock lasts milliseconds, so a few retries with backoff fix
it without anyone having to configure anything, and the server refused, so a
retry cannot duplicate a write. Against: with a real WebDAV `LOCK` the
retries only delay the error, by up to the backoff budget, and §4.2 keeps
class (C) opt-in on principle. A middle way is a small default budget,
`status=2` with `status_forcelist=[423]`, that adds about a second to the
worst case. It is one budget shared by every status in the list, so it is
"for 423 only" only while 423 is the only entry. Given the zero sleep
before the first retry (§4.2), it is in effect one delayed retry, unless
`initial_delay` is implemented. The
long-term fix for the Xandikos case is on the server side: wait briefly for
`index.lock` instead of failing at once.

## 9. Rejected alternatives

Expand Down
4 changes: 0 additions & 4 deletions docs/design/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@

`Todo._next()` in `caldav/calendarobjectresource.py` raises `TypeError` (naive vs aware) for a task with `DTSTART;VALUE=DATE` and `BY*` parts in its `RRULE`: dateutil turns the date into a naive datetime and compares it with the UTC completion timestamp, so `complete(handle_rrule=True)` fails for such tasks. Fix: convert a date DTSTART to an aware datetime (or the timestamp to a date) before both the comparison and `rrulestr()`, with a unit test. Found in the review of the fix for https://github.com/python-caldav/caldav/issues/782.

## Calendar cleanup in integration tests

For servers where calendars cannot be created, the test will take the first available calendar it finds and wipe it. That is not acceptable without the user explicitly configuring that this is OK.

## Async scheduling fixture leaks clients when setup fails

`scheduling_setup` in `tests/test_async_integration.py` closes a client only on its `continue` branches. If `check_scheduling_support()`, `principal()`, `aget_or_create_test_calendar()` or `cleanup_calendar_objects()` raises, the fixture exits before `yield`, and that client plus every one already set up stays open. Their connections outlive the test's event loop and surface later as `ResourceWarning: loop is closed` in an unrelated test. Fix: an `AsyncExitStack` around the setup and the `yield`.
Expand Down
39 changes: 29 additions & 10 deletions tests/fixture_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

import asyncio
import inspect
import logging
from typing import Any
from urllib.parse import unquote, urlparse


async def _maybe_await(result: Any) -> Any:
Expand Down Expand Up @@ -86,20 +88,37 @@ async def _filter_calendars_by_component_set(
return matching_calendars or None


async def _find_test_calendar(calendars: list[Any]) -> Any:
"""Find a dedicated test calendar by display name, or return first calendar.
## Skip reason when _find_test_calendar() finds nothing set aside for the tests.
NO_TEST_CALENDAR = (
"no calendar could be created and none is set aside for the tests - give a "
"calendar the tests may wipe a display name containing 'pythoncaldav-test' "
"or the fixture's cal_id, or name it under 'test-calendar' in the server's features"
)


async def _find_test_calendar(calendars: list[Any], cal_id: str | None = None) -> Any:
"""Find the calendar set aside for the tests, or None.

A calendar is set aside when its URL path ends in ``cal_id`` or its display
name contains ``pythoncaldav-test``. Any other calendar is the user's own: the
tests wipe the calendar they use, so it must never be picked just because
the server lists it first. A different calendar can be named in the
``test-calendar`` server config.

Works with both sync and async calendar objects via _maybe_await.
"""
for c in calendars:
last_segment = urlparse(str(c.url)).path.rstrip("/").rsplit("/", 1)[-1]
if cal_id and unquote(last_segment) == cal_id:
return c
try:
props = await _maybe_await(c.get_properties([]))
display_name = props.get("{DAV:}displayname", "")
if "pythoncaldav-test" in str(display_name):
return c
except Exception:
pass
return calendars[0] if calendars else None
display_name = await _maybe_await(c.get_display_name())
except Exception as e:
logging.debug(f"no display name for {c.url}: {e}")
continue
if "pythoncaldav-test" in str(display_name):
return c
return None


async def _get_or_create_impl(
Expand Down Expand Up @@ -170,7 +189,7 @@ async def _get_or_create_impl(
return None, False
calendars = filtered

calendar = await _find_test_calendar(calendars)
calendar = await _find_test_calendar(calendars, cal_id)

return calendar, created

Expand Down
12 changes: 9 additions & 3 deletions tests/test_async_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ async def _afixture_calendar(
lives in exactly one place - see fixture_helpers.afix_calendar.
"""
from .fixture_helpers import (
NO_TEST_CALENDAR,
afix_calendar,
arelease_calendar,
atry_principal,
Expand All @@ -284,7 +285,7 @@ async def _afixture_calendar(
supported_calendar_component_set=supported_calendar_component_set,
)
if calendar is None:
pytest.skip(f"Could not create or find {what}")
pytest.skip(f"Could not create or find {what}: {NO_TEST_CALENDAR}")

yield calendar

Expand Down Expand Up @@ -2342,7 +2343,12 @@ async def test_utf8_event(self, async_client: Any) -> None:
self.skip_unless_support("save-load.event")
self.skip_unless_support("create-calendar")

from .fixture_helpers import afix_calendar, arelease_calendar, atry_principal
from .fixture_helpers import (
NO_TEST_CALENDAR,
afix_calendar,
arelease_calendar,
atry_principal,
)

principal = await atry_principal(async_client)
if principal is None:
Expand All @@ -2362,7 +2368,7 @@ async def test_utf8_event(self, async_client: Any) -> None:
keep_name=True,
)
if c is None:
pytest.skip("Could not create or find a calendar for testing")
pytest.skip(NO_TEST_CALENDAR)
try:
await c.add_event(
near_now_ics(ev1_static).replace("Bastille Day Party", "Bringebærsyltetøyfestival")
Expand Down
15 changes: 12 additions & 3 deletions tests/test_caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -1577,12 +1577,17 @@ def _fixCalendar_(self, **kwargs):
"""
Should ideally return a new calendar, if that's not possible it
should see if there exists a test calendar, if that's not
possible, give up and return the primary calendar.
possible, skip the test. The user's other calendars are never
used, as the tests wipe the calendar they get.

Delegates core create-or-find logic to fixture_helpers.get_or_create_test_calendar,
handling test-infrastructure concerns (caching, cleanup, cal_id defaults) here.
"""
from .fixture_helpers import component_set_unobtainable, get_or_create_test_calendar
from .fixture_helpers import (
NO_TEST_CALENDAR,
component_set_unobtainable,
get_or_create_test_calendar,
)

reason = component_set_unobtainable(
self.caldav, kwargs.get("supported_calendar_component_set")
Expand All @@ -1593,9 +1598,11 @@ def _fixCalendar_(self, **kwargs):
if not self.is_supported("create-calendar"):
if not self._default_calendar:
self._default_calendar, was_created = get_or_create_test_calendar(
self.caldav, self.principal
self.caldav, self.principal, cal_id=self.testcal_id
)
self._track_calendar(self._default_calendar, was_created)
if self._default_calendar is None:
pytest.skip(NO_TEST_CALENDAR)
return self._default_calendar

# Pre-processing: set up defaults for name and cal_id
Expand Down Expand Up @@ -1647,6 +1654,8 @@ def _fixCalendar_(self, **kwargs):
)

self._track_calendar(ret, was_created)
if ret is None:
pytest.skip(NO_TEST_CALENDAR)
return ret

def testCheckCompatibility(self, request) -> None:
Expand Down
Loading
Loading