fix(web-app-n8n): stop paging when n8n hands back a page it already sent

Both provisioners follow nextCursor for workflows and step skip for API
keys until a short page arrives. A cursor n8n repeats, or a key listing
that ignores skip, would keep that loop running forever and hang the
deploy instead of failing it.

workflows_named now remembers every cursor it followed and exits with
"FAILED listing workflows: nextCursor repeats" when one comes back.
api_keys_labelled exits when a non-empty page brings no key id it has not
already seen. Normal paging is unchanged.

Four regression tests drive both scripts against a fake API that repeats
its cursor or ignores skip; without the guards exactly those four fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-09 10:06:29 +02:00
1 parent df1a2ec2af
commit ad99ded588
4 files changed
+106

No files matched your search

@@ -145,6 +145,7 @@ def api_keys_labelled(label):
label: text n8n looks for inside each label.
"""
found = []
seen = set()
while True:
query = urllib.parse.urlencode(
{"label": label, "take": PAGE, "skip": len(found)}
@@ -153,6 +154,10 @@ def api_keys_labelled(label):
if status != 200:
sys.exit(f"FAILED listing api keys: {status} {body}")
page = records(body, "api keys")
ids = {str(key.get("id")) for key in page}
if page and ids <= seen:
sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats")
seen |= ids
found.extend(page)
if len(page) < PAGE:
return found
@@ -166,6 +171,7 @@ def workflows_named(key, name):
name: workflow name n8n filters on.
"""
found = []
cursors = set()
query = {"name": name, "limit": PAGE}
while True:
status, body = call(
@@ -177,6 +183,9 @@ def workflows_named(key, name):
cursor = body.get("nextCursor") if isinstance(body, dict) else None
if not cursor:
return found
if cursor in cursors:
sys.exit("FAILED listing workflows: nextCursor repeats")
cursors.add(cursor)
query = {"name": name, "cursor": cursor}
@@ -150,6 +150,7 @@ def api_keys_labelled(label):
label: text n8n looks for inside each label.
"""
found = []
seen = set()
while True:
query = urllib.parse.urlencode(
{"label": label, "take": PAGE, "skip": len(found)}
@@ -158,6 +159,10 @@ def api_keys_labelled(label):
if status != 200:
sys.exit(f"FAILED listing api keys: {status} {body}")
page = records(body, "api keys")
ids = {str(key.get("id")) for key in page}
if page and ids <= seen:
sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats")
seen |= ids
found.extend(page)
if len(page) < PAGE:
return found
@@ -171,6 +176,7 @@ def workflows_named(key, name):
name: workflow name n8n filters on.
"""
found = []
cursors = set()
query = {"name": name, "limit": PAGE}
while True:
status, body = call(
@@ -182,6 +188,9 @@ def workflows_named(key, name):
cursor = body.get("nextCursor") if isinstance(body, dict) else None
if not cursor:
return found
if cursor in cursors:
sys.exit("FAILED listing workflows: nextCursor repeats")
cursors.add(cursor)
query = {"name": name, "cursor": cursor}
@@ -249,6 +249,50 @@ class TestProvisionClient(unittest.TestCase):
self.assertEqual([], api.created_workflows)
self.assertEqual(1, len(api.updated_workflows))
def test_a_repeated_workflow_cursor_aborts_rather_than_loops(self) -> None:
module = load_script()
api = FakeApi()
listings = []
def stuck(path, method="GET", payload=None, api_key=None):
if path.startswith("/api/v1/workflows?"):
listings.append(path)
if len(listings) > 3:
raise AssertionError("the workflow listing never ends")
return 200, {"data": [], "nextCursor": "same"}
return api(path, method, payload, api_key)
with (
patch.object(module, "call", stuck),
self.assertRaises(SystemExit) as exit_,
):
module.main()
self.assertIn("nextCursor repeats", str(exit_.exception))
def test_a_key_listing_that_ignores_skip_aborts_rather_than_loops(self) -> None:
module = load_script()
api = FakeApi()
full = [
{"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"}
for n in range(MAX_PAGE_SIZE)
]
listings = []
def frozen(path, method="GET", payload=None, api_key=None):
if path.startswith("/rest/api-keys?"):
listings.append(path)
if len(listings) > 3:
raise AssertionError("the key listing never ends")
return 200, {"data": {"items": full}}
return api(path, method, payload, api_key)
with (
patch.object(module, "call", frozen),
self.assertRaises(SystemExit) as exit_,
):
module.main()
self.assertIn("repeats", str(exit_.exception))
def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None:
module = load_script()
api = FakeApi()
@@ -285,6 +285,50 @@ class TestProvisionMcp(unittest.TestCase):
self.assertEqual([], api.created_workflows)
self.assertEqual(1, len(api.updated_workflows))
def test_a_repeated_workflow_cursor_aborts_rather_than_loops(self) -> None:
module = load_script()
api = FakeApi()
listings = []
def stuck(path, method="GET", payload=None, api_key=None):
if path.startswith("/api/v1/workflows?"):
listings.append(path)
if len(listings) > 3:
raise AssertionError("the workflow listing never ends")
return 200, {"data": [], "nextCursor": "same"}
return api(path, method, payload, api_key)
with (
patch.object(module, "call", stuck),
self.assertRaises(SystemExit) as exit_,
):
module.main()
self.assertIn("nextCursor repeats", str(exit_.exception))
def test_a_key_listing_that_ignores_skip_aborts_rather_than_loops(self) -> None:
module = load_script()
api = FakeApi()
full = [
{"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"}
for n in range(MAX_PAGE_SIZE)
]
listings = []
def frozen(path, method="GET", payload=None, api_key=None):
if path.startswith("/rest/api-keys?"):
listings.append(path)
if len(listings) > 3:
raise AssertionError("the key listing never ends")
return 200, {"data": {"items": full}}
return api(path, method, payload, api_key)
with (
patch.object(module, "call", frozen),
self.assertRaises(SystemExit) as exit_,
):
module.main()
self.assertIn("repeats", str(exit_.exception))
def test_a_key_that_only_contains_the_managed_name_is_left_alone(self) -> None:
module = load_script()
api = FakeApi(keys=[{"id": "near", "label": "infinito:mcp-legacy"}])