mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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:
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"}])
|
||||
|
||||
Reference in new issue
Block a user