From 1aa954991215e3ee2a6e88643a7585ab8a69a40c Mon Sep 17 00:00:00 2001 From: corvus-0x Date: Tue, 30 Jun 2026 04:37:22 -0400 Subject: [PATCH] feat: make bootstrapper initialiser timeouts configurable (#999) * feat: make bootstrapper initialiser timeouts configurable DefaultFlowStart and WorkspaceInit hardcoded the request timeouts for their flow-svc and IAM calls, leaving operators no way to tune them for high-latency environments (#874). Expose them as constructor parameters threaded through the existing initialiser `params:` mechanism, defaulting to the current values so behaviour is unchanged unless explicitly overridden: - DefaultFlowStart: list_timeout=10 (list-flows), start_timeout=30 (start-flow) - WorkspaceInit: iam_timeout=10 (create-workspace) Add unit tests for the defaults, override storage, and that configured values reach the underlying request calls. * test: mark async bootstrap test with @pytest.mark.asyncio Addresses review feedback on PR #999: add the explicit @pytest.mark.asyncio decorator to test_run_forwards_configured_timeouts so it does not rely on asyncio_mode=auto and stays consistent with the rest of the suite. --- .../test_bootstrap/test_default_flow_start.py | 54 +++++++++++++++++++ .../test_bootstrap/test_workspace_init.py | 13 +++++ .../initialisers/default_flow_start.py | 12 ++++- .../bootstrap/initialisers/workspace_init.py | 11 ++-- 4 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_bootstrap/test_default_flow_start.py create mode 100644 tests/unit/test_bootstrap/test_workspace_init.py diff --git a/tests/unit/test_bootstrap/test_default_flow_start.py b/tests/unit/test_bootstrap/test_default_flow_start.py new file mode 100644 index 00000000..7846bee7 --- /dev/null +++ b/tests/unit/test_bootstrap/test_default_flow_start.py @@ -0,0 +1,54 @@ +""" +Unit tests for trustgraph.bootstrap.initialisers.DefaultFlowStart + +Verifies the list/start timeouts are configurable and that the +configured values actually reach the flow-client request calls. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from trustgraph.bootstrap.initialisers.default_flow_start import ( + DefaultFlowStart, +) + + +def test_default_timeouts(): + init = DefaultFlowStart(blueprint="bp") + assert init.list_timeout == 10 + assert init.start_timeout == 30 + + +def test_timeout_overrides_are_stored(): + init = DefaultFlowStart(blueprint="bp", list_timeout=5, start_timeout=99) + assert init.list_timeout == 5 + assert init.start_timeout == 99 + + +@pytest.mark.asyncio +async def test_run_forwards_configured_timeouts(): + init = DefaultFlowStart(blueprint="bp", list_timeout=5, start_timeout=99) + + # Flow client: list-flows returns no error + empty flow list, + # start-flow returns no error. + flow = MagicMock() + flow.start = AsyncMock() + flow.stop = AsyncMock() + flow.request = AsyncMock(side_effect=[ + MagicMock(error=None, flow_ids=[]), # list-flows response + MagicMock(error=None), # start-flow response + ]) + + # Context: workspace "default" exists, hands back our mock flow client. + ctx = MagicMock() + ctx.logger = MagicMock() + ctx.config.keys = AsyncMock(return_value=["default"]) + ctx.make_flow_client = MagicMock(return_value=flow) + + await init.run(ctx, None, "v1") + + calls = flow.request.call_args_list + assert len(calls) == 2 + assert calls[0].kwargs["timeout"] == 5 + assert calls[1].kwargs["timeout"] == 99 diff --git a/tests/unit/test_bootstrap/test_workspace_init.py b/tests/unit/test_bootstrap/test_workspace_init.py new file mode 100644 index 00000000..aa819904 --- /dev/null +++ b/tests/unit/test_bootstrap/test_workspace_init.py @@ -0,0 +1,13 @@ +"""Unit tests for trustgraph.bootstrap.initialisers.WorkspaceInit.""" + +from trustgraph.bootstrap.initialisers.workspace_init import WorkspaceInit + + +def test_default_iam_timeout(): + init = WorkspaceInit() + assert init.iam_timeout == 10 + + +def test_iam_timeout_override_is_stored(): + init = WorkspaceInit(iam_timeout=42) + assert init.iam_timeout == 42 diff --git a/trustgraph-flow/trustgraph/bootstrap/initialisers/default_flow_start.py b/trustgraph-flow/trustgraph/bootstrap/initialisers/default_flow_start.py index 96d13d28..524fd306 100644 --- a/trustgraph-flow/trustgraph/bootstrap/initialisers/default_flow_start.py +++ b/trustgraph-flow/trustgraph/bootstrap/initialisers/default_flow_start.py @@ -18,6 +18,10 @@ description : str (default "Default") Human-readable description passed to flow-svc. parameters : dict (optional) Optional parameter overrides passed to start-flow. +list_timeout : int (default 10) + Timeout in seconds for the list-flows request. +start_timeout : int (default 30) + Timeout in seconds for the start-flow request. """ from trustgraph.schema import FlowRequest @@ -34,6 +38,8 @@ class DefaultFlowStart(Initialiser): blueprint=None, description="Default", parameters=None, + list_timeout=10, + start_timeout=30, **kwargs, ): super().__init__(**kwargs) @@ -46,6 +52,8 @@ class DefaultFlowStart(Initialiser): self.blueprint = blueprint self.description = description self.parameters = dict(parameters) if parameters else {} + self.list_timeout = list_timeout + self.start_timeout = start_timeout async def run(self, ctx, old_flag, new_flag): @@ -70,7 +78,7 @@ class DefaultFlowStart(Initialiser): FlowRequest( operation="list-flows", ), - timeout=10, + timeout=self.list_timeout, ) if list_resp.error: raise RuntimeError( @@ -99,7 +107,7 @@ class DefaultFlowStart(Initialiser): description=self.description, parameters=self.parameters, ), - timeout=30, + timeout=self.start_timeout, ) if resp.error: raise RuntimeError( diff --git a/trustgraph-flow/trustgraph/bootstrap/initialisers/workspace_init.py b/trustgraph-flow/trustgraph/bootstrap/initialisers/workspace_init.py index 423c5f5e..b1881fff 100644 --- a/trustgraph-flow/trustgraph/bootstrap/initialisers/workspace_init.py +++ b/trustgraph-flow/trustgraph/bootstrap/initialisers/workspace_init.py @@ -14,7 +14,9 @@ seed_file : str (required when source=="seed-file") Path to a JSON seed file with the same shape TemplateSeed consumes. overwrite : bool (default False) On re-run (flag change), if True overwrite all keys; if False, - upsert-missing-only (preserves in-workspace customisations). + upsert-missing-only (preserves in-workspace customisations) +iam_timeout : int (default 10) + Timeout in seconds for the IAM create-workspace request. Raises (in ``run``) ------------------- @@ -41,7 +43,9 @@ class WorkspaceInit(Initialiser): source="template", seed_file=None, overwrite=False, + iam_timeout=10, **kwargs, + ): super().__init__(**kwargs) @@ -59,6 +63,7 @@ class WorkspaceInit(Initialiser): self.source = source self.seed_file = seed_file self.overwrite = overwrite + self.iam_timeout = iam_timeout async def run(self, ctx, old_flag, new_flag): await self._create_workspace(ctx) @@ -120,10 +125,10 @@ class WorkspaceInit(Initialiser): workspace_record=WorkspaceInput( id=self.workspace, name=self.workspace.title(), - enabled=True, + enabled=True, ), ), - timeout=10, + timeout=self.iam_timeout, ) if resp.error: if resp.error.type == "duplicate":