Skip to content

Commit 21c32f2

Browse files
authored
Merge pull request #355 from ynput/bugfix/graphql-infinite-pagination-loop
GraphQl: Prevent infinite query loop on unexpected responses
2 parents 49a9aa4 + eefcfef commit 21c32f2

3 files changed

Lines changed: 79 additions & 34 deletions

File tree

‎ayon_api/graphql.py‎

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,25 @@ def parse_result(
353353
for child in self._children:
354354
child.parse_result(data, output, progress_data)
355355

356+
def _query_data(self, con: ServerAPI) -> dict[str, Any]:
357+
"""Send single query to server and return 'data' of the response."""
358+
query_str = self.calculate_query()
359+
variables = self.get_variables_values()
360+
response = con.query_graphql(query_str, variables)
361+
if response.errors:
362+
raise GraphQlQueryFailed(response.errors, query_str, variables)
363+
364+
data = response.data.get("data")
365+
if data is None:
366+
# Parsing 'None' would not change pagination state and the same
367+
# query would be sent again in an infinite loop.
368+
raise GraphQlQueryError(
369+
f"GraphQl query '{self._name}' response does not contain"
370+
f" 'data'. Response: {str(response.data)[:1000]}"
371+
f"\nQuery:\n{query_str}\nVariables: {variables}"
372+
)
373+
return data
374+
356375
def query(self, con: ServerAPI) -> dict[str, Any]:
357376
"""Do a query from server.
358377
@@ -366,15 +385,8 @@ def query(self, con: ServerAPI) -> dict[str, Any]:
366385
progress_data = {}
367386
output = {}
368387
while self.need_query:
369-
query_str = self.calculate_query()
370-
variables = self.get_variables_values()
371-
response = con.query_graphql(
372-
query_str,
373-
variables
374-
)
375-
if response.errors:
376-
raise GraphQlQueryFailed(response.errors, query_str, variables)
377-
self.parse_result(response.data["data"], output, progress_data)
388+
data = self._query_data(con)
389+
self.parse_result(data, output, progress_data)
378390

379391
return output
380392

@@ -394,30 +406,16 @@ def continuous_query(
394406
if self.has_multiple_edge_fields:
395407
output = {}
396408
while self.need_query:
397-
query_str = self.calculate_query()
398-
variables = self.get_variables_values()
399-
400-
response = con.query_graphql(query_str, variables)
401-
if response.errors:
402-
raise GraphQlQueryFailed(
403-
response.errors, query_str, variables
404-
)
405-
self.parse_result(response.data["data"], output, progress_data)
409+
data = self._query_data(con)
410+
self.parse_result(data, output, progress_data)
406411

407412
yield output
408413

409414
else:
410415
while self.need_query:
411416
output = {}
412-
query_str = self.calculate_query()
413-
variables = self.get_variables_values()
414-
response = con.query_graphql(query_str, variables)
415-
if response.errors:
416-
raise GraphQlQueryFailed(
417-
response.errors, query_str, variables
418-
)
419-
420-
self.parse_result(response.data["data"], output, progress_data)
417+
data = self._query_data(con)
418+
self.parse_result(data, output, progress_data)
421419

422420
yield output
423421

@@ -945,6 +943,14 @@ def parse_result(
945943
change_cursor = False
946944

947945
if change_cursor and self._need_query:
946+
if new_cursor is None:
947+
# Without cursor the pagination would start from beginning
948+
raise GraphQlQueryError(
949+
f"Field '{self.path}' reported another page without"
950+
" a cursor. Stopped pagination after"
951+
f" {self._fetched_counter} items."
952+
)
953+
948954
if new_cursor == self._cursor:
949955
raise GraphQlQueryError(
950956
"Cursor didn't change during pagination."

‎tests/test_download_resume.py‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,3 @@ def get_func(url, **kwargs):
5151
content, progress = _download(con, get_func)
5252
assert content == CONTENT
5353
assert progress.transferred_size == len(CONTENT)
54-
55-
56-
def test_download_without_content_length(con):
57-
content, _ = _download(
58-
con, lambda url, **kwargs: FakeResponse(200, CONTENT)
59-
)
60-
assert content == CONTENT
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Unexpected GraphQl responses must not cause infinite loop.
2+
3+
Does not require running AYON server.
4+
"""
5+
import pytest
6+
7+
from ayon_api.exceptions import GraphQlQueryError
8+
from ayon_api.graphql_queries import events_graphql_query
9+
from ayon_api.utils import SortOrder
10+
11+
from .graphql_fake_server import FakeResponse
12+
13+
14+
class _ScriptedServer:
15+
"""Return prepared responses, fail if queried too many times."""
16+
def __init__(self, responses):
17+
self._responses = responses
18+
self.queries = []
19+
20+
def query_graphql(self, query_str, variables):
21+
self.queries.append(query_str)
22+
if len(self.queries) > 10:
23+
raise RuntimeError("Infinite query loop")
24+
idx = min(len(self.queries), len(self._responses)) - 1
25+
return FakeResponse(self._responses[idx])
26+
27+
28+
def test_null_data_raises_instead_of_infinite_loop():
29+
server = _ScriptedServer([{"data": None}])
30+
query = events_graphql_query({"id"}, SortOrder.ascending)
31+
with pytest.raises(GraphQlQueryError, match="does not contain 'data'"):
32+
query.query(server)
33+
34+
35+
def test_missing_cursor_stops_pagination():
36+
def page(ids, end_cursor):
37+
return {"data": {"events": {
38+
"edges": [{"node": {"id": id_}} for id_ in ids],
39+
"pageInfo": {"endCursor": end_cursor, "hasNextPage": True},
40+
}}}
41+
42+
server = _ScriptedServer([page(["e0"], "c0"), page([], None)])
43+
query = events_graphql_query({"id"}, SortOrder.ascending)
44+
45+
with pytest.raises(GraphQlQueryError, match="page without a cursor"):
46+
query.query(server)

0 commit comments

Comments
 (0)