Skip to content

[BUG] CalDAV push to a remote calendar always fails: If-Match is sent unquoted #1778

Closed

Description

@Cyber1000

Description

Pushing local changes to a remote CalDAV calendar never succeeds against Radicale. Every attempt is answered with 412 Precondition Failed, tududi records it as a conflict, and _findLocalChanges skips that task from then on, so the task silently stops syncing for good, even though nothing on the server ever changed.

backend/modules/caldav/sync/push-phase.js:174 sends the stored ETag verbatim:

headers['If-Match'] = syncState.etag;

The value is stored without the surrounding quotes. RFC 9110 §13.1.1 defines the field as If-Match = "*" / 1#entity-tag, and §8.8.3 defines opaque-tag = DQUOTE *etagc DQUOTE, the quotes are part of the entity-tag, not decoration around it. Radicale compares strictly and rejects the unquoted form. The stored ETag is otherwise correct: it matches what Radicale returns, only the quotes are missing.

Steps to Reproduce

  1. Self-hosted tududi 1.7.0 with SQLite.
  2. Add a remote calendar pointing at a Radicale collection (POST /api/caldav/remote-calendars, plain HTTP with CALDAV_ALLOW_PRIVATE_HOSTS=true).
  3. Sync once (POST /api/caldav/sync/calendars/:id) so the tasks are pulled and their ETags are stored.
  4. Change a task, e.g. rename it.
  5. Sync again.

Expected Behavior

The changed task is written to the remote collection and the response reports pushedCount: 1.

Actual Behavior

pushedCount: 0 with errors: [{ "error": "Conflict detected: task was modified on server" }], and Radicale logs Precondition failed on PUT request. Nothing on the server had changed.

The task is left with sync_status = 'conflict', and because _findLocalChanges skips tasks in that state, it is never pushed again — subsequent syncs report no error at all, which makes the failure look like it went away.

Isolated with two identical PUTs of the same body to the same URL, differing only in the quoting:

If-Match: 8b36704d1f6b552746f43f95ea726497674aa7b34f24c09841b1b63a33a4e9b0   -> 412
If-Match: "8b36704d1f6b552746f43f95ea726497674aa7b34f24c09841b1b63a33a4e9b0" -> 204

Operating System

Linux

Browser

Other

Tududi Version

1.7.0

Screenshots

No response

Additional Context

No browser is involved — the push runs server-side in the sync engine, so the Browser field is only filled because it is required.

Suggested fix: keep the quotes when storing the ETag, or add them when building the header. sync/pull-phase.js stores the value and push-phase.js reads it back, so whichever side is normalised has to match the other.

Remote server: Radicale 3.7.8.

Activity

  1. added a commit that references this issue on Oct 6, 2026
    6634575
  2. added a commit that references this issue on Oct 11, 2026
    d6f6db1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions