Skip to content

Commit 4ee139d

Browse files
authored
feat(automation): structured try/catch/retry block (ADR-0031, task 4) (#1499)
Engine execution for the `try_catch` construct (ADR-0031 §Decision 3), completing the native control-flow trio (loop/parallel/try-catch). Runs the protected `try` region via runRegion; on failure retries with exponential backoff (config.retry), then runs the optional `catch` region with the caught error bound to config.errorVariable. Adds builtin/try-catch-node.ts + 8 tests and the ResilientSyncFlow showcase.
1 parent 02d6359 commit 4ee139d

5 files changed

Lines changed: 407 additions & 0 deletions

File tree

‎.changeset/structured-try-catch.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
feat(automation): structured try/catch/retry block (ADR-0031, task 4)
6+
7+
Implement engine execution for the `try_catch` construct — structured error
8+
handling (ADR-0031 §Decision 3). The node runs a protected `try` region; on
9+
failure it retries with exponential backoff (`config.retry`), and if it still
10+
fails the optional `catch` region runs with the caught error bound to
11+
`config.errorVariable` (default `$error`). Both regions execute in the enclosing
12+
variable scope via `AutomationEngine.runRegion`.
13+
14+
- New `builtin/try-catch-node.ts` executor (registered as a built-in).
15+
- `try` success (incl. a successful retry) → node succeeds; `catch` handling a
16+
failure → node succeeds; no `catch` / failing `catch` → node fails to the
17+
flow's fault edge / error handling.
18+
- Well-formedness (single-entry/single-exit `try`/`catch` regions) is already
19+
enforced at `registerFlow()` by `validateControlFlow` (shipped with the loop
20+
container).
21+
22+
Showcase `ResilientSyncFlow` demonstrates the construct. This completes the
23+
native control-flow execution trio (loop / parallel / try-catch); BPMN interop
24+
mapping remains a follow-up (#1479 task 5).

‎examples/app-showcase/src/flows/index.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,78 @@ export const FanOutNotifyFlow = defineFlow({
633633
],
634634
});
635635

636+
/**
637+
* Resilient Sync — demonstrates the ADR-0031 **try/catch/retry** construct.
638+
*
639+
* The `try_catch` node runs a protected `try` region (an outbound HTTP push);
640+
* on failure it retries with exponential backoff, and if it still fails the
641+
* `catch` region records the failure with the caught error bound to `$error`.
642+
* Both regions are single-entry/single-exit and run in the enclosing scope; the
643+
* node's ordinary out-edge (`→ end`) is the after-block continuation.
644+
*/
645+
export const ResilientSyncFlow = defineFlow({
646+
name: 'showcase_resilient_sync',
647+
label: 'Resilient Sync (Try/Catch/Retry)',
648+
description: 'Pushes a task to an external system, retrying on failure and recording errors via try/catch (ADR-0031).',
649+
type: 'autolaunched',
650+
nodes: [
651+
{
652+
id: 'start',
653+
type: 'start',
654+
label: 'On Task Completed',
655+
config: {
656+
objectName: 'showcase_task',
657+
triggerType: 'record-after-update',
658+
condition: 'status == "done" && previous.status != "done"',
659+
},
660+
},
661+
{
662+
id: 'guarded_push',
663+
type: 'try_catch',
664+
label: 'Push with retry',
665+
config: {
666+
retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2, maxRetryDelayMs: 10000 },
667+
errorVariable: '$error',
668+
try: {
669+
nodes: [
670+
{
671+
id: 'push',
672+
type: 'http_request',
673+
label: 'Push to CRM',
674+
config: {
675+
url: 'https://api.example.com/v1/tasks',
676+
method: 'POST',
677+
body: { id: '{record.id}', title: '{record.title}', status: 'done' },
678+
},
679+
},
680+
],
681+
edges: [],
682+
},
683+
catch: {
684+
nodes: [
685+
{
686+
id: 'record_failure',
687+
type: 'update_record',
688+
label: 'Flag Sync Failure',
689+
config: {
690+
objectName: 'showcase_task',
691+
filter: { id: '{record.id}' },
692+
fields: { sync_status: 'failed', sync_error: '{$error.message}' },
693+
},
694+
},
695+
],
696+
edges: [],
697+
},
698+
},
699+
},
700+
{ id: 'end', type: 'end', label: 'End' },
701+
],
702+
edges: [
703+
{ id: 'e1', source: 'start', target: 'guarded_push' },
704+
{ id: 'e2', source: 'guarded_push', target: 'end' },
705+
],
706+
});
707+
636708
export const allFlows = [
637709
TaskCompletedFlow,
638710
ReassignWizardFlow,
@@ -646,4 +718,5 @@ export const allFlows = [
646718
TaskDoneNotifyOwnerFlow,
647719
BatchRemindersFlow,
648720
FanOutNotifyFlow,
721+
ResilientSyncFlow,
649722
];

‎packages/services/service-automation/src/builtin/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* - logic — decision / assignment (engine core)
1414
* - logic — loop (structured iteration container, ADR-0031)
1515
* - logic — parallel (structured parallel block, implicit join, ADR-0031)
16+
* - logic — try_catch (structured try/catch/retry, ADR-0031)
1617
* - data — get/create/update/delete_record (platform CRUD baseline)
1718
* - human — screen / script (core flow capability)
1819
* - io — http_request (foundational outbound I/O)
@@ -32,6 +33,7 @@ import type { AutomationEngine } from '../engine.js';
3233
import { registerLogicNodes } from './logic-nodes.js';
3334
import { registerLoopNode } from './loop-node.js';
3435
import { registerParallelNode } from './parallel-node.js';
36+
import { registerTryCatchNode } from './try-catch-node.js';
3537
import { registerCrudNodes } from './crud-nodes.js';
3638
import { registerScreenNodes } from './screen-nodes.js';
3739
import { registerHttpNodes } from './http-nodes.js';
@@ -43,6 +45,7 @@ import { registerSubflowNode } from './subflow-node.js';
4345
export { registerLogicNodes } from './logic-nodes.js';
4446
export { registerLoopNode } from './loop-node.js';
4547
export { registerParallelNode } from './parallel-node.js';
48+
export { registerTryCatchNode } from './try-catch-node.js';
4649
export { registerCrudNodes } from './crud-nodes.js';
4750
export { registerScreenNodes } from './screen-nodes.js';
4851
export { registerHttpNodes } from './http-nodes.js';
@@ -60,6 +63,7 @@ export function installBuiltinNodes(engine: AutomationEngine, ctx: PluginContext
6063
registerLogicNodes(engine, ctx);
6164
registerLoopNode(engine, ctx);
6265
registerParallelNode(engine, ctx);
66+
registerTryCatchNode(engine, ctx);
6367
registerCrudNodes(engine, ctx);
6468
registerScreenNodes(engine, ctx);
6569
registerHttpNodes(engine, ctx);
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { AutomationEngine } from '../engine.js';
5+
import type { NodeExecutor } from '../engine.js';
6+
import { registerTryCatchNode } from './try-catch-node.js';
7+
8+
function silentLogger() {
9+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
10+
}
11+
function ctx() {
12+
return { logger: silentLogger(), getService() { throw new Error('none'); } } as any;
13+
}
14+
15+
describe('try/catch/retry executor (ADR-0031)', () => {
16+
let engine: AutomationEngine;
17+
let ran: string[];
18+
let attempts: number;
19+
20+
beforeEach(() => {
21+
engine = new AutomationEngine(silentLogger());
22+
ran = [];
23+
attempts = 0;
24+
registerTryCatchNode(engine, ctx());
25+
26+
engine.registerNodeExecutor({
27+
type: 'ok',
28+
async execute(node) { ran.push((node.config as any)?.tag ?? 'ok'); return { success: true }; },
29+
} as NodeExecutor);
30+
31+
// Always fails.
32+
engine.registerNodeExecutor({
33+
type: 'boom',
34+
async execute() { ran.push('boom'); return { success: false, error: 'kaboom' }; },
35+
} as NodeExecutor);
36+
37+
// Fails the first N times (config.failTimes), then succeeds — for retry tests.
38+
engine.registerNodeExecutor({
39+
type: 'flaky',
40+
async execute(node) {
41+
attempts++;
42+
const failTimes = Number((node.config as any)?.failTimes ?? 0);
43+
ran.push(`flaky#${attempts}`);
44+
if (attempts <= failTimes) return { success: false, error: `transient ${attempts}` };
45+
return { success: true };
46+
},
47+
} as NodeExecutor);
48+
49+
// Reads the caught error variable.
50+
engine.registerNodeExecutor({
51+
type: 'handler',
52+
async execute(node, variables) {
53+
const v = (node.config as any)?.errVar ?? '$error';
54+
ran.push(`handler:${JSON.stringify(variables.get(v))}`);
55+
return { success: true };
56+
},
57+
} as NodeExecutor);
58+
});
59+
60+
const tcFlow = (tcConfig: Record<string, unknown>) => ({
61+
name: 'tc_flow',
62+
label: 'TryCatch Flow',
63+
type: 'autolaunched' as const,
64+
nodes: [
65+
{ id: 'start', type: 'start', label: 'Start' },
66+
{ id: 'tc', type: 'try_catch', label: 'Guarded', config: tcConfig },
67+
{ id: 'after', type: 'ok', label: 'After', config: { tag: 'after' } },
68+
{ id: 'end', type: 'end', label: 'End' },
69+
],
70+
edges: [
71+
{ id: 'e1', source: 'start', target: 'tc' },
72+
{ id: 'e2', source: 'tc', target: 'after' },
73+
{ id: 'e3', source: 'after', target: 'end' },
74+
],
75+
});
76+
77+
it('runs the try region and continues when it succeeds (no catch invoked)', async () => {
78+
engine.registerFlow('tc_flow', tcFlow({
79+
try: { nodes: [{ id: 't', type: 'ok', label: 'T', config: { tag: 'try' } }], edges: [] },
80+
catch: { nodes: [{ id: 'c', type: 'ok', label: 'C', config: { tag: 'catch' } }], edges: [] },
81+
}));
82+
83+
const result = await engine.execute('tc_flow');
84+
expect(result.success).toBe(true);
85+
expect(ran).toEqual(['try', 'after']); // catch not run, downstream continued
86+
});
87+
88+
it('runs the catch region when the try region fails, binding the error', async () => {
89+
engine.registerFlow('tc_flow', tcFlow({
90+
try: { nodes: [{ id: 't', type: 'boom', label: 'T' }], edges: [] },
91+
catch: { nodes: [{ id: 'c', type: 'handler', label: 'C' }], edges: [] },
92+
}));
93+
94+
const result = await engine.execute('tc_flow');
95+
expect(result.success).toBe(true); // error handled by catch
96+
expect(ran[0]).toBe('boom');
97+
expect(ran.some(r => r.startsWith('handler:') && r.includes('kaboom'))).toBe(true);
98+
expect(ran[ran.length - 1]).toBe('after'); // downstream continued after catch
99+
});
100+
101+
it('retries the try region with backoff and succeeds without running catch', async () => {
102+
engine.registerFlow('tc_flow', tcFlow({
103+
try: { nodes: [{ id: 't', type: 'flaky', label: 'T', config: { failTimes: 2 } }], edges: [] },
104+
catch: { nodes: [{ id: 'c', type: 'boom', label: 'C' }], edges: [] },
105+
retry: { maxRetries: 3, retryDelayMs: 0 },
106+
}));
107+
108+
const result = await engine.execute('tc_flow');
109+
expect(result.success).toBe(true);
110+
expect(attempts).toBe(3); // failed twice, succeeded on the third
111+
expect(ran).not.toContain('boom'); // catch never ran
112+
expect(ran[ran.length - 1]).toBe('after');
113+
});
114+
115+
it('falls through to catch after exhausting retries', async () => {
116+
engine.registerFlow('tc_flow', tcFlow({
117+
try: { nodes: [{ id: 't', type: 'flaky', label: 'T', config: { failTimes: 99 } }], edges: [] },
118+
catch: { nodes: [{ id: 'c', type: 'ok', label: 'C', config: { tag: 'catch' } }], edges: [] },
119+
retry: { maxRetries: 2, retryDelayMs: 0 },
120+
}));
121+
122+
const result = await engine.execute('tc_flow');
123+
expect(result.success).toBe(true);
124+
expect(attempts).toBe(3); // initial + 2 retries
125+
expect(ran).toContain('catch');
126+
});
127+
128+
it('fails the node when the try region fails and there is no catch', async () => {
129+
engine.registerFlow('tc_flow', tcFlow({
130+
try: { nodes: [{ id: 't', type: 'boom', label: 'T' }], edges: [] },
131+
}));
132+
133+
const result = await engine.execute('tc_flow');
134+
expect(result.success).toBe(false);
135+
expect(result.error).toMatch(/try region failed.*kaboom/);
136+
expect(ran).not.toContain('after'); // downstream did not run
137+
});
138+
139+
it('fails the node when the catch region itself fails', async () => {
140+
engine.registerFlow('tc_flow', tcFlow({
141+
try: { nodes: [{ id: 't', type: 'boom', label: 'T' }], edges: [] },
142+
catch: { nodes: [{ id: 'c', type: 'boom', label: 'C' }], edges: [] },
143+
}));
144+
145+
const result = await engine.execute('tc_flow');
146+
expect(result.success).toBe(false);
147+
expect(result.error).toMatch(/catch region failed/);
148+
});
149+
150+
it('rejects a malformed try region at registerFlow', () => {
151+
expect(() =>
152+
engine.registerFlow('bad_tc', tcFlow({
153+
// two entry/exit nodes, no edges → not single-entry/single-exit
154+
try: { nodes: [{ id: 'a', type: 'ok', label: 'A' }, { id: 'b', type: 'ok', label: 'B' }], edges: [] },
155+
})),
156+
).toThrow(/try_catch 'tc' try/);
157+
});
158+
159+
it('runs a multi-node try region in order', async () => {
160+
engine.registerFlow('tc_flow', tcFlow({
161+
try: {
162+
nodes: [
163+
{ id: 't1', type: 'ok', label: 'T1', config: { tag: 't1' } },
164+
{ id: 't2', type: 'ok', label: 'T2', config: { tag: 't2' } },
165+
],
166+
edges: [{ id: 'te', source: 't1', target: 't2' }],
167+
},
168+
}));
169+
170+
const result = await engine.execute('tc_flow');
171+
expect(result.success).toBe(true);
172+
expect(ran).toEqual(['t1', 't2', 'after']);
173+
});
174+
});

0 commit comments

Comments
 (0)