Skip to content

Commit e76edcb

Browse files
committed
fix: set ResponseError.status for failed replies
DAVError gained a status attribute in 3.6.0, but a failing status inside a multistatus (a 507 <propstat> or <response> status in a PROPFIND reply, say) raised a ResponseError with status None, so the code could only be read from the message text. That check now passes the code, or None for a status line it cannot parse. For consistency the parse_* methods' non-2xx raises go through error_from_response(), keeping body and preconditions too, and the foreign-href sync 507 passes its code. Prompt: A 507 from propfind comes back as a ResponseError with status set to None Assisted-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Reviewed-by: Tobias Brox <tobias@redpill-linpro.com>
1 parent ccae99e commit e76edcb

5 files changed

Lines changed: 53 additions & 9 deletions

File tree

‎caldav/collection.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2349,7 +2349,9 @@ def _sync_page_truncated(self, response: Any) -> bool:
23492349
## while the calendar URL may keep a literal @ (Nextcloud).
23502350
marker = unquote(self.url.join(self._href_to_url(href)).path).rstrip("/")
23512351
if marker != unquote(self.url.path).rstrip("/"):
2352-
raise error.ResponseError(f"sync-collection: 507 Insufficient Storage for {href}")
2352+
raise error.ResponseError(
2353+
f"sync-collection: 507 Insufficient Storage for {href}", status=507
2354+
)
23532355
return True
23542356

23552357
def _absorb_sync_page(

‎caldav/response.py‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,16 @@ def _validate_status(status: str | None) -> None:
100100
if status is None:
101101
return
102102
if not any(code in status for code in (" 200 ", " 201 ", " 207 ", " 404 ")):
103-
raise error.ResponseError(status)
103+
raise error.ResponseError(status, status=_parse_status_code(status))
104+
105+
106+
def _parse_status_code(status: str) -> int | None:
107+
"""The code in a status line like "HTTP/1.1 507 Insufficient Storage",
108+
or None if there is none (unlike _status_to_code, no 200 fallback)."""
109+
parts = status.split()
110+
if len(parts) >= 2 and parts[1].isdigit():
111+
return int(parts[1])
112+
return None
104113

105114

106115
def _is_sync_truncation(response: _Element, siblings: Iterable[_Element]) -> bool:
@@ -497,7 +506,9 @@ def parse_propfind(self) -> "list[PropfindResult]":
497506
if self.status == 404:
498507
return []
499508
if self.status not in (200, 207):
500-
raise error.ResponseError(f"PROPFIND failed with status {self.status}")
509+
raise error.error_from_response(
510+
error.ResponseError, self, f"PROPFIND failed with status {self.status}"
511+
)
501512
if self.tree is None:
502513
return []
503514
results: list[PropfindResult] = []
@@ -520,7 +531,9 @@ def parse_propfind(self) -> "list[PropfindResult]":
520531
def parse_calendar_query(self) -> "list[CalendarQueryResult]":
521532
"""Parse the response body as a calendar-query or calendar-multiget REPORT reply."""
522533
if self.status not in (200, 207):
523-
raise error.ResponseError(f"REPORT failed with status {self.status}")
534+
raise error.error_from_response(
535+
error.ResponseError, self, f"REPORT failed with status {self.status}"
536+
)
524537
if self.tree is None:
525538
return []
526539
results: list[CalendarQueryResult] = []
@@ -542,7 +555,9 @@ def parse_calendar_query(self) -> "list[CalendarQueryResult]":
542555
def parse_sync_collection(self) -> "SyncCollectionResult":
543556
"""Parse the response body as a sync-collection REPORT reply."""
544557
if self.status not in (200, 207):
545-
raise error.ResponseError(f"sync-collection failed with status {self.status}")
558+
raise error.error_from_response(
559+
error.ResponseError, self, f"sync-collection failed with status {self.status}"
560+
)
546561
if self.tree is None:
547562
return SyncCollectionResult()
548563
changed: list[CalendarQueryResult] = []
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A `ResponseError` raised for a failed PROPFIND, REPORT or sync-collection reply, or for a failing `<propstat>` status inside a multistatus (e.g. `507 Insufficient Storage`), now has its `status` attribute set; it was `None`, so the code had to be parsed out of the message. The failed replies also keep `body` and `preconditions`, like other `DAVError`s.

‎tests/test_caldav_unit.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5881,13 +5881,15 @@ class TestPropstatStatusValidation:
58815881
"""
58825882

58835883
def test_server_error_propstat_raises(self):
5884-
with pytest.raises(error.ResponseError):
5884+
with pytest.raises(error.ResponseError) as excinfo:
58855885
MockedDAVResponse(self.XML).expand_simple_props(props=[dav.DisplayName()])
5886+
assert excinfo.value.status == 500
58865887

58875888
def test_insufficient_storage_propstat_raises(self):
58885889
xml = self.XML.replace("500 Internal Server Error", "507 Insufficient Storage")
5889-
with pytest.raises(error.ResponseError):
5890+
with pytest.raises(error.ResponseError) as excinfo:
58905891
MockedDAVResponse(xml).expand_simple_props(props=[dav.DisplayName()])
5892+
assert excinfo.value.status == 507
58915893

58925894
def test_404_propstat_is_still_a_missing_property(self):
58935895
"""The 404 quirk must survive: a 404 propstat means "not set here",
@@ -5896,6 +5898,17 @@ def test_404_propstat_is_still_a_missing_property(self):
58965898
result = MockedDAVResponse(xml).expand_simple_props(props=[dav.DisplayName()])
58975899
assert result == {"/dav/calendars/user/broken/": {"{DAV:}displayname": None}}
58985900

5901+
@pytest.mark.parametrize(
5902+
"status", ["500 Internal Server Error", "HTTP/1.1 Internal Server Error"]
5903+
)
5904+
def test_unparseable_status_line_leaves_status_unset(self, status):
5905+
"""A status line the client cannot read is no evidence of a code:
5906+
``status`` stays None rather than claiming 200."""
5907+
xml = self.XML.replace("HTTP/1.1 500 Internal Server Error", status)
5908+
with pytest.raises(error.ResponseError) as excinfo:
5909+
MockedDAVResponse(xml).expand_simple_props(props=[dav.DisplayName()])
5910+
assert excinfo.value.status is None
5911+
58995912

59005913
class TestAsyncDAVClientCredentialPairing:
59015914
"""Gate finding F6, async twin: same field-by-field merge, same result."""
@@ -7658,8 +7671,9 @@ def test_507_for_another_href_is_not_a_truncation(self):
76587671
b"<D:href>/dav/cal/</D:href>", b"<D:href>/dav/othercal/</D:href>"
76597672
)
76607673
_, calendar = self._calendar(page)
7661-
with pytest.raises(error.DAVError):
7674+
with pytest.raises(error.DAVError) as excinfo:
76627675
calendar.get_objects_by_sync_token("tok-1", disable_fallback=True)
7676+
assert excinfo.value.status == 507
76637677

76647678
def test_truncated_initial_sync_on_stalwart_stops_short(self):
76657679
"""Stalwart's checkpoint of a truncated initial sync skips members.

‎tests/test_protocol.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,17 @@ def test_parse_propfind_404_returns_empty(self):
151151
results = DAVResponse.from_bytes(b"", status_code=404).parse_propfind()
152152
assert results == []
153153

154+
@pytest.mark.parametrize(
155+
"method", ["parse_propfind", "parse_calendar_query", "parse_sync_collection"]
156+
)
157+
def test_parse_failure_keeps_status(self, method):
158+
"""A failing reply raises a ResponseError carrying the HTTP status."""
159+
from caldav.lib import error
160+
161+
with pytest.raises(error.ResponseError) as excinfo:
162+
getattr(DAVResponse.from_bytes(b"", status_code=507), method)()
163+
assert excinfo.value.status == 507
164+
154165
def test_parse_calendar_query_response(self):
155166
"""Parse calendar-query response with calendar data."""
156167
xml = b"""<?xml version="1.0"?>
@@ -279,8 +290,9 @@ def test_507_outside_sync_reply_still_raises(self):
279290
<D:status>HTTP/1.1 507 Insufficient Storage</D:status>
280291
</D:response>
281292
</D:multistatus>"""
282-
with pytest.raises(error.ResponseError):
293+
with pytest.raises(error.ResponseError) as excinfo:
283294
DAVResponse.from_bytes(xml).expand_simple_props([dav.GetEtag()])
295+
assert excinfo.value.status == 507
284296

285297
def test_parse_sync_collection_generic_responsedescription(self):
286298
"""A 404 <response> may carry an arbitrary <responsedescription>.

0 commit comments

Comments
 (0)