sync: report a refused resource instead of losing it

A multiget answer that was neither a body nor an omission fell through
every bucket: seen, so not missing; no body, so not a resource. Nothing
counted it, the sweep left it alone because the listing still named it,
and downloadPhase asked for it again on every sync for ever -- the loop
quarantine exists to break.

FetchResult now carries `failed`, with the status the server actually
gave. Kept apart from `missing` because "refused" and "not mentioned"
are different facts: 404 and 410 are skipped, since the next listing
drops the href and the sweep purges the row, and counting them would
quarantine the resource out of that very sweep; 5xx and a bodiless
success are skipped as the server's own trouble; any other 4xx is
counted, being a judgement about this resource that repeats forever.

A per-property refusal -- a propstat with 403 around calendar-data --
is the usual shape of "you may not read this one object", and
Response.properties drops non-2xx propstats, so the code is read back
out of them rather than reported as a blank.

The two write-side guards had to learn this too. Both read a fetch that
came back Result.success as an answer, so a refusal now looked like
"somebody else's resource" (a second resource under one UID) and like
"the server has no validator" (an unconditional PUT over a concurrent
edit) -- the two things those guards were added to prevent.
This commit is contained in:
2026-09-07 21:26:17 +02:00
parent 83b4fb8e6e
commit d5ce24b673
6 changed files with 317 additions and 2 deletions
@@ -183,6 +183,7 @@ class CalendarCollection(
runCatching {
val resources = mutableListOf<RemoteResource>()
val unsolicited = mutableListOf<HttpUrl>()
val failed = mutableListOf<FetchFailure>()
val seen = mutableSetOf<HttpUrl>()
hrefs.chunked(batchSize).forEach { batch ->
@@ -194,9 +195,26 @@ class CalendarCollection(
unsolicited += response.href
return@multiget
}
// Before the checks below, and deliberately: the server did
// mention this href, so whatever it said, the href is not
// one the server omitted.
seen += asked
if (!response.isSuccess()) return@multiget
val body = response[CalendarData::class.java]?.iCalendar ?: return@multiget
if (!response.isSuccess()) {
failed += FetchFailure(asked, response.status?.code ?: 0)
return@multiget
}
val body = response[CalendarData::class.java]?.iCalendar
if (body == null) {
// Success at the response level, no body. The usual shape
// is a per-property refusal — a `propstat` carrying 403
// around `calendar-data` — and `Response.properties` drops
// non-2xx propstats silently, so the verdict has to be
// read back out of them or a deterministic refusal
// arrives looking like a transient blank.
val refusal = response.propstat.firstOrNull { !it.isSuccess() }
failed += FetchFailure(asked, refusal?.status?.code ?: 0)
return@multiget
}
resources += RemoteResource(
href = asked,
// The tag from *this* response, paired with *this* body.
@@ -209,6 +227,12 @@ class CalendarCollection(
FetchResult(
resources = resources,
missing = hrefs.filterNot { it in seen },
// A server that repeats a response element must not spend two
// thirds of the quarantine threshold in one run — nor count
// against a resource it also answered properly.
failed = failed
.filterNot { failure -> resources.any { it.href == failure.href } }
.distinctBy { it.href },
unsolicited = unsolicited,
)
}
@@ -64,11 +64,28 @@ data class RemoteRef(val href: HttpUrl, val eTag: ETag?)
*/
data class RemoteResource(val href: HttpUrl, val eTag: ETag?, val iCalendar: String)
/**
* A resource the server answered for, but did not hand over.
*
* @param code the response-level status, or `0` when the server reported
* success and supplied no `calendar-data` — the same "no HTTP judgement to
* report" convention [PutOutcome.Rejected] uses.
*/
data class FetchFailure(val href: HttpUrl, val code: Int)
/** What a multiget actually returned, and what it did not. */
data class FetchResult(
val resources: List<RemoteResource>,
/** Asked for, not answered — the server simply omitted them. */
val missing: List<HttpUrl>,
/**
* Asked for, answered, and refused or empty.
*
* Separate from [missing] because "the server said 403" and "the server said
* nothing" are different facts with different right answers — merging them
* is the same mistake as merging the three meanings of a 412.
*/
val failed: List<FetchFailure>,
/**
* Answered without being asked for.
*
@@ -101,6 +101,107 @@ END:VCALENDAR</C:calendar-data>
assertThat(result.missing.map { it.encodedPath }).containsExactly("/dav/tasks/two.ics")
}
@Test fun `a refused resource is reported as failed, not as missing`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
<response>
<href>/dav/tasks/two.ics</href>
<status>HTTP/1.1 403 Forbidden</status>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"), href("two.ics"))).getOrThrow()
// The server did mention two.ics, so it is not missing. Merging the two
// costs the caller the difference between "refused" and "omitted", which
// are not the same fact and do not deserve the same answer.
assertThat(result.missing).isEmpty()
assertThat(result.failed.map { it.href.encodedPath to it.code })
.containsExactly("/dav/tasks/two.ics" to 403)
// One bad member does not cost the batch its good ones.
assertThat(result.resources.map { it.href.encodedPath })
.containsExactly("/dav/tasks/one.ics")
}
@Test fun `a success carrying no calendar-data is reported as failed`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
// No HTTP judgement to report: the server said yes and sent nothing.
assertThat(result.failed.map { it.href.encodedPath to it.code })
.containsExactly("/dav/tasks/one.ics" to 0)
assertThat(result.resources).isEmpty()
assertThat(result.missing).isEmpty()
}
@Test fun `a propstat refusal carries its own status, not a blank`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
</prop><status>HTTP/1.1 200 OK</status></propstat>
<propstat><prop>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav"/>
</prop><status>HTTP/1.1 403 Forbidden</status></propstat>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
// A per-property refusal is the usual shape for "you may not read this
// one object". Response.properties drops non-2xx propstats, so reporting
// 0 here would make a deterministic refusal look like a transient blank.
assertThat(result.failed.map { it.code }).containsExactly(403)
}
@Test fun `a resource the server says is gone carries its status`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<status>HTTP/1.1 404 Not Found</status>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
// The code has to survive the trip: the engine treats 404 and 403 as
// opposite verdicts.
assertThat(result.failed.map { it.code }).containsExactly(404)
assertThat(result.missing).isEmpty()
}
@Test fun `fetch batches`() {
repeat(2) { server.enqueue(multistatus("")) }
collection.fetch((1..3).map { href("$it.ics") }, batchSize = 2).getOrThrow()