Skip to content

Commit f95f700

Browse files
committed
fix(http-api): prevent infinite loop in rule engine listing
Older versions of the rule engine ignore "offset" and "limit" parameters and omit the "total_count" field in the response. This caused an infinite loop in the pagination utility as it kept receiving the same results for increasing offsets. Implemented a local slicing workaround in _list_rules when "total_count" is missing. Updated unit tests to include "total_count" in mock responses and added a regression test for the unpaginated case.
1 parent a4c3713 commit f95f700

2 files changed

Lines changed: 61 additions & 6 deletions

File tree

src/enapter/http/api/rule_engine/client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,18 @@ async def _list_rules(
6464
params = {"offset": offset, "limit": limit}
6565
response = await self._client.get(url, params=params)
6666
await api.check_error(response)
67-
return [Rule.from_dto(dto) for dto in response.json()["rules"]]
67+
68+
payload = response.json()
69+
rules = [Rule.from_dto(dto) for dto in payload["rules"]]
70+
71+
# NOTE: Some older versions of the rule engine ignore pagination parameters
72+
# (offset and limit) and return all rules without a "total_count" field.
73+
# In such cases, we slice the list locally to simulate pagination and
74+
# prevent an infinite loop in the `paginate` utility.
75+
if "total_count" not in payload:
76+
return rules[offset : offset + limit]
77+
78+
return rules
6879

6980
async def get_rule(self, rule_id: str, site_id: str | None = None) -> Rule:
7081
"""Get a single rule."""

tests/unit/test_http/test_api/test_rule_engine/test_client.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,17 @@ async def test_list_rules(client, mock_httpx_client):
109109
"state": "STOPPED",
110110
"script": {"code": "cHJpbnQoJzInKQ==", "runtime_version": "V3"},
111111
},
112-
]
112+
],
113+
"total_count": 2,
113114
}
114115
mock_httpx_client.get = AsyncMock(
115116
side_effect=[
116117
mock_response,
117-
MagicMock(spec=httpx.Response, status_code=200, json=lambda: {"rules": []}),
118+
MagicMock(
119+
spec=httpx.Response,
120+
status_code=200,
121+
json=lambda: {"rules": [], "total_count": 2},
122+
),
118123
]
119124
)
120125

@@ -154,7 +159,8 @@ async def test_list_rules_pagination(client, mock_httpx_client):
154159
"script": {"code": "cHJpbnQoJzEnKQ==", "runtime_version": "V3"},
155160
}
156161
for i in range(50)
157-
]
162+
],
163+
"total_count": 51,
158164
}
159165

160166
mock_response_2 = MagicMock(spec=httpx.Response)
@@ -168,12 +174,13 @@ async def test_list_rules_pagination(client, mock_httpx_client):
168174
"state": "STARTED",
169175
"script": {"code": "cHJpbnQoJzEnKQ==", "runtime_version": "V3"},
170176
}
171-
]
177+
],
178+
"total_count": 51,
172179
}
173180

174181
mock_response_3 = MagicMock(spec=httpx.Response)
175182
mock_response_3.status_code = 200
176-
mock_response_3.json.return_value = {"rules": []}
183+
mock_response_3.json.return_value = {"rules": [], "total_count": 51}
177184

178185
mock_httpx_client.get = AsyncMock(
179186
side_effect=[mock_response_1, mock_response_2, mock_response_3]
@@ -200,6 +207,43 @@ async def test_list_rules_pagination(client, mock_httpx_client):
200207
)
201208

202209

210+
@pytest.mark.asyncio
211+
async def test_list_rules_unpaginated_workaround(client, mock_httpx_client):
212+
"""Test listing rules when the API ignores pagination and total_count is missing."""
213+
mock_response = MagicMock(spec=httpx.Response)
214+
mock_response.status_code = 200
215+
# No "total_count" in the payload
216+
mock_response.json.return_value = {
217+
"rules": [
218+
{
219+
"id": "rule_1",
220+
"slug": "rule-1",
221+
"disabled": False,
222+
"state": "STARTED",
223+
"script": {"code": "cHJpbnQoJzEnKQ==", "runtime_version": "V3"},
224+
}
225+
]
226+
}
227+
mock_httpx_client.get = AsyncMock(return_value=mock_response)
228+
229+
rules = []
230+
# If the workaround is NOT implemented, this will loop forever because paginate()
231+
# will keep calling _list_rules with increasing offsets, and _list_rules will
232+
# keep returning the same rule_1.
233+
async with client.list_rules(site_id="site_123") as stream:
234+
async for rule in stream:
235+
rules.append(rule)
236+
if len(rules) > 10:
237+
pytest.fail("Infinite loop detected in list_rules")
238+
239+
# With the workaround, it should only return the rule once (for offset 0)
240+
# and then return an empty list for offset 50 (sliced [50:100]).
241+
assert len(rules) == 1
242+
assert rules[0].id == "rule_1"
243+
# It should have been called twice: once for offset 0, and once for offset 50 (which returns empty)
244+
assert mock_httpx_client.get.call_count == 2
245+
246+
203247
@pytest.mark.asyncio
204248
async def test_get_rule(client, mock_httpx_client):
205249
"""Test getting a single rule."""

0 commit comments

Comments
 (0)