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
30 changes: 22 additions & 8 deletions src/py_moodle/transport/webservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,22 @@
from ..http import MoodleHttpError, MoodleWebserviceError, request_webservice
from . import TransportError, TransportUnavailableError

#: Substring (case-insensitive) that identifies an invalid/expired token
#: Moodle exception, distinguishing "try another transport" from a genuine
#: webservice-level failure.
_INVALID_TOKEN_MARKER = "invalid token"
#: Substrings (case-insensitive) identifying a Moodle webservice failure
#: that means "this call path isn't usable right now, try another
#: transport" rather than a genuine webservice-level failure worth
#: surfacing immediately:
#: - an invalid/expired token;
#: - a webservice-side context-validation failure (observed in practice
#: as "You cannot execute functions in the course context (course
#: id:N). ... Context does not exist"), e.g. when the target course's
#: context is momentarily stale relative to the calling user's cached
#: session state. The AJAX transport re-derives context from the
#: current page/session instead of a cached webservice-side lookup,
#: so it is not subject to the same failure.
_TRANSPORT_UNAVAILABLE_MARKERS = (
"invalid token",
"cannot execute functions in the course context",
)


def call(
Expand All @@ -41,15 +53,17 @@ def call(

Raises:
TransportUnavailableError: If Moodle reports an invalid/expired
token, indicating the caller should fall back to another
transport.
token, or a context-validation failure, indicating the caller
should fall back to another transport (see
:data:`_TRANSPORT_UNAVAILABLE_MARKERS`).
TransportError: If the call fails for any other reason (network
error, non-token Moodle exception, invalid JSON).
error, non-token/context Moodle exception, invalid JSON).
"""
try:
return request_webservice(session, base_url, wsfunction, params, token=token)
except MoodleWebserviceError as exc:
if _INVALID_TOKEN_MARKER in str(exc).lower():
message = str(exc).lower()
if any(marker in message for marker in _TRANSPORT_UNAVAILABLE_MARKERS):
raise TransportUnavailableError(str(exc)) from exc
raise TransportError(str(exc)) from exc
except MoodleHttpError as exc:
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,37 @@ def test_webservice_call_raises_transport_unavailable_on_invalid_token():
)


def test_webservice_call_raises_transport_unavailable_on_context_error():
"""A 'cannot execute functions in the course context' error signals fallback.

Regression test for a Moodle webservice quirk observed in CI: a call to
a generic, non-course-scoped function like ``core_course_get_courses``
can still fail with a stale/invalid course-context validation error
unrelated to the token itself. This must be treated the same as an
invalid token -- fall back to another transport -- rather than
surfacing immediately, since the AJAX transport re-derives context from
the current session instead of a cached webservice-side lookup.
"""
session = StubSession(
post_result=StubResponse(
json_data={
"exception": "invalid_parameter_exception",
"errorcode": "invalidparameter",
"message": (
"You cannot execute functions in the course context "
"(course id:8). The context error message was: Invalid "
"parameter value detected (Context does not exist)"
),
}
)
)

with pytest.raises(TransportUnavailableError):
webservice_transport.call(
session, BASE_URL, "core_course_get_courses", FAKE_TOKEN
)


def test_webservice_call_raises_transport_error_on_other_moodle_exception():
"""A non-token Moodle exception raises a plain TransportError."""
session = StubSession(
Expand Down Expand Up @@ -177,6 +208,37 @@ def test_list_courses_falls_back_to_ajax_on_invalid_token():
assert result == [{"id": 1}, {"id": 2}]


def test_list_courses_falls_back_to_ajax_on_context_error():
"""list_courses() falls back to AJAX on a course-context validation error.

Same regression as test_webservice_call_raises_transport_unavailable_on_context_error,
exercised through the public list_courses() entry point.
"""
session = StubSession(
get_result=StubResponse(text="<html></html>"),
post_dispatch={
f"{BASE_URL}/webservice/rest/server.php": StubResponse(
json_data={
"exception": "invalid_parameter_exception",
"errorcode": "invalidparameter",
"message": (
"You cannot execute functions in the course context "
"(course id:8). The context error message was: Invalid "
"parameter value detected (Context does not exist)"
),
}
),
f"{BASE_URL}/lib/ajax/service.php": StubResponse(
json_data=[{"error": False, "data": [{"id": 2}, {"id": 1}]}]
),
},
)

result = list_courses(session, BASE_URL, token=FAKE_TOKEN, sesskey=FAKE_SESSKEY)

assert result == [{"id": 1}, {"id": 2}]


def test_list_courses_raises_when_neither_transport_usable():
"""list_courses() raises a clear error with no token and no sesskey."""
session = StubSession()
Expand Down