Skip to content

Commit cf16bcd

Browse files
committed
Extract OCS error messages from Nextcloud response body
OCS endpoints return specific error details in ocs.meta.message (e.g. "User already exists", "Wrong share ID, share does not exist") but the client was discarding these and mapping only HTTP status codes to generic messages. Add _raise_for_ocs_status() that extracts the OCS message when available, falling back to the HTTP status code mapping otherwise.
1 parent f4d66bd commit cf16bcd

3 files changed

Lines changed: 193 additions & 14 deletions

File tree

src/nextcloud_mcp/client.py

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,46 @@ def __init__(self, message: str, status_code: int) -> None:
1818
super().__init__(message)
1919

2020

21+
_STATUS_MESSAGES: dict[int, str] = {
22+
401: "Authentication failed. Check NEXTCLOUD_USER and NEXTCLOUD_PASSWORD.",
23+
403: "Forbidden. The user does not have permission for this operation.",
24+
404: "Not found.",
25+
409: "Conflict. The resource may already exist or the parent directory is missing.",
26+
423: "Locked. The resource is currently locked by another process.",
27+
}
28+
29+
2130
def _raise_for_status(response: niquests.Response, context: str = "") -> None:
2231
"""Raise NextcloudError with a helpful message instead of raw HTTPError."""
2332
if response.ok:
2433
return
2534
code = response.status_code or 0
2635
prefix = f"{context}: " if context else ""
27-
messages: dict[int, str] = {
28-
401: "Authentication failed. Check NEXTCLOUD_USER and NEXTCLOUD_PASSWORD.",
29-
403: "Forbidden. The user does not have permission for this operation.",
30-
404: "Not found.",
31-
409: "Conflict. The resource may already exist or the parent directory is missing.",
32-
423: "Locked. The resource is currently locked by another process.",
33-
}
34-
detail = messages.get(code, f"HTTP {code}")
36+
detail = _STATUS_MESSAGES.get(code, f"HTTP {code}")
37+
raise NextcloudError(f"{prefix}{detail}", code)
38+
39+
40+
def _raise_for_ocs_status(response: niquests.Response, context: str = "") -> None:
41+
"""Raise NextcloudError using the OCS error message from the response body when available.
42+
43+
Nextcloud OCS endpoints return error details in ocs.meta.message (e.g.
44+
"User already exists", "Wrong share ID, share does not exist"). This
45+
function extracts that message for a much better error experience than
46+
the generic HTTP status code mapping.
47+
48+
Falls back to _raise_for_status() when the OCS body cannot be parsed.
49+
"""
50+
if response.ok:
51+
return
52+
code = response.status_code or 0
53+
prefix = f"{context}: " if context else ""
54+
try:
55+
ocs_message: str = response.json()["ocs"]["meta"]["message"]
56+
if ocs_message:
57+
raise NextcloudError(f"{prefix}{ocs_message}", code)
58+
except (ValueError, KeyError, TypeError):
59+
pass
60+
detail = _STATUS_MESSAGES.get(code, f"HTTP {code}")
3561
raise NextcloudError(f"{prefix}{detail}", code)
3662

3763

@@ -108,7 +134,7 @@ async def ocs_get(self, path: str, params: dict[str, Any] | None = None) -> Any:
108134
session = await self._get_session()
109135
url = f"{self._base_url}/ocs/v2.php/{path}"
110136
response = await session.get(url, params=params or {})
111-
_raise_for_status(response, f"OCS GET {path}")
137+
_raise_for_ocs_status(response, f"OCS GET {path}")
112138
result: dict[str, Any] = response.json() # type: ignore[assignment]
113139
return result["ocs"]["data"]
114140

@@ -117,7 +143,7 @@ async def ocs_post(self, path: str, data: dict[str, Any] | None = None) -> Any:
117143
session = await self._get_session()
118144
url = f"{self._base_url}/ocs/v2.php/{path}"
119145
response = await session.post(url, data=data or {})
120-
_raise_for_status(response, f"OCS POST {path}")
146+
_raise_for_ocs_status(response, f"OCS POST {path}")
121147
result: dict[str, Any] = response.json() # type: ignore[assignment]
122148
return result["ocs"]["data"]
123149

@@ -126,7 +152,7 @@ async def ocs_put(self, path: str, data: dict[str, Any] | None = None) -> Any:
126152
session = await self._get_session()
127153
url = f"{self._base_url}/ocs/v2.php/{path}"
128154
response = await session.put(url, data=data or {})
129-
_raise_for_status(response, f"OCS PUT {path}")
155+
_raise_for_ocs_status(response, f"OCS PUT {path}")
130156
result: dict[str, Any] = response.json() # type: ignore[assignment]
131157
return result["ocs"]["data"]
132158

@@ -135,7 +161,7 @@ async def ocs_delete(self, path: str) -> Any:
135161
session = await self._get_session()
136162
url = f"{self._base_url}/ocs/v2.php/{path}"
137163
response = await session.delete(url)
138-
_raise_for_status(response, f"OCS DELETE {path}")
164+
_raise_for_ocs_status(response, f"OCS DELETE {path}")
139165
result: dict[str, Any] = response.json() # type: ignore[assignment]
140166
return result["ocs"]["data"]
141167

tests/integration/test_errors.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,45 @@ async def test_error_has_status_code_attribute(self, nc_client: NextcloudClient)
5959
assert exc_info.value.status_code > 0
6060

6161

62+
class TestOcsErrorMessages:
63+
"""Verify that OCS error messages from Nextcloud are extracted, not discarded."""
64+
65+
@pytest.mark.asyncio
66+
async def test_get_nonexistent_user_includes_ocs_message(self, nc_client: NextcloudClient) -> None:
67+
with pytest.raises(NextcloudError, match="User does not exist") as exc_info:
68+
await nc_client.ocs_get("cloud/users/nonexistent-user-xyz-12345")
69+
assert exc_info.value.status_code == 404
70+
71+
@pytest.mark.asyncio
72+
async def test_get_nonexistent_share_includes_ocs_message(self, nc_client: NextcloudClient) -> None:
73+
with pytest.raises(NextcloudError, match="Wrong share ID, share does not exist") as exc_info:
74+
await nc_client.ocs_get("apps/files_sharing/api/v1/shares/999999")
75+
assert exc_info.value.status_code == 404
76+
77+
@pytest.mark.asyncio
78+
async def test_share_nonexistent_path_includes_ocs_message(self, nc_client: NextcloudClient) -> None:
79+
with pytest.raises(NextcloudError, match="Wrong path") as exc_info:
80+
await nc_client.ocs_post(
81+
"apps/files_sharing/api/v1/shares",
82+
data={"path": "/nonexistent-xyz-12345.txt", "shareType": 3},
83+
)
84+
assert exc_info.value.status_code == 404
85+
86+
@pytest.mark.asyncio
87+
async def test_create_duplicate_user_includes_ocs_message(self, nc_client: NextcloudClient) -> None:
88+
with pytest.raises(NextcloudError, match="already exists") as exc_info:
89+
await nc_client.ocs_post("cloud/users", data={"userid": "admin", "password": "test"})
90+
assert exc_info.value.status_code == 400
91+
92+
@pytest.mark.asyncio
93+
async def test_ocs_error_preserves_context_prefix(self, nc_client: NextcloudClient) -> None:
94+
with pytest.raises(NextcloudError) as exc_info:
95+
await nc_client.ocs_get("cloud/users/nonexistent-user-xyz-12345")
96+
msg = str(exc_info.value)
97+
assert "OCS GET" in msg
98+
assert "User does not exist" in msg
99+
100+
62101
class TestErrorsThroughMcpTools:
63102
"""Verify that errors propagate correctly through the MCP tool layer."""
64103

@@ -78,6 +117,11 @@ async def test_delete_not_found_via_tool(self, nc_mcp: McpTestHelper) -> None:
78117
await nc_mcp.call("delete_file", path="nonexistent-12345.txt")
79118

80119
@pytest.mark.asyncio
81-
async def test_get_nonexistent_user_via_tool(self, nc_mcp: McpTestHelper) -> None:
82-
with pytest.raises(ToolError):
120+
async def test_get_nonexistent_user_ocs_message_via_tool(self, nc_mcp: McpTestHelper) -> None:
121+
with pytest.raises(ToolError, match="User does not exist"):
83122
await nc_mcp.call("get_user", user_id="nonexistent-user-xyz-12345")
123+
124+
@pytest.mark.asyncio
125+
async def test_get_nonexistent_share_ocs_message_via_tool(self, nc_mcp: McpTestHelper) -> None:
126+
with pytest.raises(ToolError, match="Wrong share ID"):
127+
await nc_mcp.call("get_share", share_id=999999)

tests/test_client_errors.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Tests for HTTP client error handling — _raise_for_status and _raise_for_ocs_status."""
2+
3+
import json
4+
from typing import Any
5+
6+
import niquests
7+
import pytest
8+
9+
from nextcloud_mcp.client import NextcloudError, _raise_for_ocs_status, _raise_for_status
10+
11+
12+
def _fake_response(status_code: int, body: dict[str, Any] | str | None = None) -> niquests.Response:
13+
"""Build a minimal niquests.Response with the given status and optional JSON body."""
14+
resp = niquests.Response()
15+
resp.status_code = status_code
16+
if body is not None:
17+
text = json.dumps(body) if isinstance(body, dict) else body
18+
resp._content = text.encode("utf-8")
19+
resp.headers["Content-Type"] = "application/json"
20+
return resp
21+
22+
23+
def _ocs_error_body(message: str, statuscode: int = 404) -> dict[str, Any]:
24+
return {"ocs": {"meta": {"status": "failure", "statuscode": statuscode, "message": message}, "data": []}}
25+
26+
27+
class TestRaiseForStatus:
28+
def test_ok_response_does_not_raise(self) -> None:
29+
_raise_for_status(_fake_response(200))
30+
31+
def test_404_generic_message(self) -> None:
32+
with pytest.raises(NextcloudError, match="Not found") as exc_info:
33+
_raise_for_status(_fake_response(404))
34+
assert exc_info.value.status_code == 404
35+
36+
def test_401_auth_message(self) -> None:
37+
with pytest.raises(NextcloudError, match="Authentication failed"):
38+
_raise_for_status(_fake_response(401))
39+
40+
def test_403_forbidden_message(self) -> None:
41+
with pytest.raises(NextcloudError, match="Forbidden"):
42+
_raise_for_status(_fake_response(403))
43+
44+
def test_409_conflict_message(self) -> None:
45+
with pytest.raises(NextcloudError, match="Conflict"):
46+
_raise_for_status(_fake_response(409))
47+
48+
def test_423_locked_message(self) -> None:
49+
with pytest.raises(NextcloudError, match="Locked"):
50+
_raise_for_status(_fake_response(423))
51+
52+
def test_unmapped_code_shows_http_number(self) -> None:
53+
with pytest.raises(NextcloudError, match="HTTP 418"):
54+
_raise_for_status(_fake_response(418))
55+
56+
def test_context_prefix(self) -> None:
57+
with pytest.raises(NextcloudError, match=r"Delete 'file\.txt': Not found"):
58+
_raise_for_status(_fake_response(404), context="Delete 'file.txt'")
59+
60+
61+
class TestRaiseForOcsStatus:
62+
def test_ok_response_does_not_raise(self) -> None:
63+
_raise_for_ocs_status(_fake_response(200))
64+
65+
def test_extracts_ocs_meta_message(self) -> None:
66+
with pytest.raises(NextcloudError, match="User does not exist") as exc_info:
67+
_raise_for_ocs_status(_fake_response(404, _ocs_error_body("User does not exist")))
68+
assert exc_info.value.status_code == 404
69+
70+
def test_ocs_message_with_context_prefix(self) -> None:
71+
body = _ocs_error_body("User already exists", 102)
72+
with pytest.raises(NextcloudError, match=r"OCS POST cloud/users: User already exists") as exc_info:
73+
_raise_for_ocs_status(_fake_response(400, body), "OCS POST cloud/users")
74+
assert exc_info.value.status_code == 400
75+
76+
def test_share_not_found_message(self) -> None:
77+
body = _ocs_error_body("Wrong share ID, share does not exist")
78+
with pytest.raises(NextcloudError, match="Wrong share ID, share does not exist"):
79+
_raise_for_ocs_status(_fake_response(404, body))
80+
81+
def test_share_wrong_path_message(self) -> None:
82+
body = _ocs_error_body("Wrong path, file/folder does not exist")
83+
with pytest.raises(NextcloudError, match="Wrong path"):
84+
_raise_for_ocs_status(_fake_response(404, body))
85+
86+
def test_falls_back_when_ocs_message_empty(self) -> None:
87+
with pytest.raises(NextcloudError, match="HTTP 400"):
88+
_raise_for_ocs_status(_fake_response(400, _ocs_error_body("", 400)))
89+
90+
def test_falls_back_when_body_not_json(self) -> None:
91+
with pytest.raises(NextcloudError, match="Not found"):
92+
_raise_for_ocs_status(_fake_response(404, "<html>404</html>"))
93+
94+
def test_falls_back_when_body_missing_ocs_key(self) -> None:
95+
with pytest.raises(NextcloudError, match="Not found"):
96+
_raise_for_ocs_status(_fake_response(404, {"error": "something"}))
97+
98+
def test_falls_back_when_body_missing_meta(self) -> None:
99+
with pytest.raises(NextcloudError, match="HTTP 400"):
100+
_raise_for_ocs_status(_fake_response(400, {"ocs": {"data": []}}))
101+
102+
def test_falls_back_when_no_body(self) -> None:
103+
with pytest.raises(NextcloudError, match="HTTP 500"):
104+
_raise_for_ocs_status(_fake_response(500))
105+
106+
def test_preserves_status_code(self) -> None:
107+
with pytest.raises(NextcloudError) as exc_info:
108+
_raise_for_ocs_status(_fake_response(400, _ocs_error_body("User already exists", 102)))
109+
assert exc_info.value.status_code == 400

0 commit comments

Comments
 (0)