@emmvish/stable-request 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1626 -1600
- package/dist/core/stable-api-gateway.d.ts +1 -1
- package/dist/core/stable-api-gateway.d.ts.map +1 -1
- package/dist/core/stable-api-gateway.js.map +1 -1
- package/dist/core/stable-function.js +1 -1
- package/dist/core/stable-function.js.map +1 -1
- package/dist/core/stable-request.js +1 -1
- package/dist/core/stable-request.js.map +1 -1
- package/dist/core/stable-workflow-graph.d.ts +1 -1
- package/dist/core/stable-workflow-graph.d.ts.map +1 -1
- package/dist/core/stable-workflow-graph.js.map +1 -1
- package/dist/core/stable-workflow.d.ts +1 -1
- package/dist/core/stable-workflow.d.ts.map +1 -1
- package/dist/core/stable-workflow.js.map +1 -1
- package/dist/types/index.d.ts +14 -14
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,2048 +1,2074 @@
|
|
|
1
1
|
# @emmvish/stable-request
|
|
2
2
|
|
|
3
|
-
A production-grade
|
|
3
|
+
A production-grade TypeScript library for resilient API integrations, batch processing, and orchestrating complex workflows with deterministic error handling, type safety, and comprehensive observability.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Table of Contents
|
|
6
6
|
|
|
7
7
|
- [Overview](#overview)
|
|
8
|
-
- [
|
|
9
|
-
- [
|
|
10
|
-
- [
|
|
11
|
-
- [
|
|
12
|
-
- [
|
|
13
|
-
- [
|
|
14
|
-
- [
|
|
15
|
-
|
|
16
|
-
- [
|
|
17
|
-
- [
|
|
18
|
-
- [
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
|
|
22
|
-
- [
|
|
23
|
-
- [MetricsAggregator Utility](#metricsaggregator-utility)
|
|
24
|
-
- [Workflow Execution Patterns](#workflow-execution-patterns)
|
|
25
|
-
- [Sequential and Concurrent Phases](#sequential-and-concurrent-phases)
|
|
26
|
-
- [Mixed Execution Mode](#mixed-execution-mode)
|
|
8
|
+
- [Core Concepts](#core-concepts)
|
|
9
|
+
- [Core Modules](#core-modules)
|
|
10
|
+
- [stableRequest](#stablerequest)
|
|
11
|
+
- [stableFunction](#stablefunction)
|
|
12
|
+
- [stableApiGateway](#stableapigateway)
|
|
13
|
+
- [stableWorkflow](#stableworkflow)
|
|
14
|
+
- [stableWorkflowGraph](#stableworkflowgraph)
|
|
15
|
+
- [Resilience Mechanisms](#resilience-mechanisms)
|
|
16
|
+
- [Retry Strategies](#retry-strategies)
|
|
17
|
+
- [Circuit Breaker](#circuit-breaker)
|
|
18
|
+
- [Caching](#caching)
|
|
19
|
+
- [Rate Limiting](#rate-limiting)
|
|
20
|
+
- [Concurrency Limiting](#concurrency-limiting)
|
|
21
|
+
- [Workflow Patterns](#workflow-patterns)
|
|
22
|
+
- [Sequential & Concurrent Phases](#sequential--concurrent-phases)
|
|
27
23
|
- [Non-Linear Workflows](#non-linear-workflows)
|
|
28
24
|
- [Branched Workflows](#branched-workflows)
|
|
29
|
-
- [
|
|
25
|
+
- [Graph-Based Workflows](#graph-based-workflows)
|
|
26
|
+
- [Configuration & State](#configuration--state)
|
|
30
27
|
- [Config Cascading](#config-cascading)
|
|
31
|
-
- [
|
|
32
|
-
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
28
|
+
- [Shared & State Buffers](#shared--state-buffers)
|
|
29
|
+
- [Hooks & Observability](#hooks--observability)
|
|
30
|
+
- [Pre-Execution Hooks](#pre-execution-hooks)
|
|
31
|
+
- [Analysis Hooks](#analysis-hooks)
|
|
32
|
+
- [Handler Hooks](#handler-hooks)
|
|
33
|
+
- [Decision Hooks](#decision-hooks)
|
|
34
|
+
- [Metrics & Logging](#metrics--logging)
|
|
35
|
+
- [Advanced Features](#advanced-features)
|
|
35
36
|
- [Trial Mode](#trial-mode)
|
|
36
|
-
- [
|
|
37
|
-
- [
|
|
37
|
+
- [State Persistence](#state-persistence)
|
|
38
|
+
- [Mixed Request & Function Phases](#mixed-request--function-phases)
|
|
39
|
+
- [Best Practices](#best-practices)
|
|
40
|
+
|
|
41
|
+
---
|
|
38
42
|
|
|
39
43
|
## Overview
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
**@emmvish/stable-request** evolved from a focused library for resilient API calls to a comprehensive execution framework. Originally addressing API integration challenges, it expanded to include:
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
- **
|
|
46
|
-
|
|
47
|
-
- **Performance Optimization**: Response caching, rate limiting, and concurrency control to maximize efficiency
|
|
48
|
-
- **Type Safety**: Full TypeScript support with 40+ exported types
|
|
47
|
+
1. **Batch orchestration** via `stableApiGateway` for processing groups of mixed requests/functions
|
|
48
|
+
2. **Phased workflows** via `stableWorkflow` for array-based multi-phase execution with dynamic control flow
|
|
49
|
+
3. **Graph-based workflows** via `stableWorkflowGraph` for DAG execution with higher parallelism
|
|
50
|
+
4. **Generic function execution** via `stableFunction`, inheriting all resilience guards
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
All four execution modes support the same resilience stack: retries, jitter, circuit breaking, caching, rate/concurrency limits, config cascading, shared buffers, trial mode, comprehensive hooks, and metrics. This uniformity makes it trivial to compose requests and functions in any topology.
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
- **Orchestrate complex API workflows** with dependencies between steps
|
|
54
|
-
- **Handle unreliable APIs** with intelligent retry and fallback mechanisms
|
|
55
|
-
- **Prevent cascade failures** when downstream services fail
|
|
56
|
-
- **Optimize performance** by caching responses and controlling request rates
|
|
57
|
-
- **Monitor and debug** complex request flows in production
|
|
58
|
-
- **Implement conditional logic** based on API responses (branching, looping)
|
|
54
|
+
---
|
|
59
55
|
|
|
60
|
-
|
|
56
|
+
## Core Concepts
|
|
61
57
|
|
|
62
|
-
|
|
58
|
+
### Resilience as Default
|
|
63
59
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
Every execution—whether a single request, a pure function, or an entire workflow—inherits built-in resilience:
|
|
61
|
+
|
|
62
|
+
- **Retries** with configurable backoff strategies (FIXED, LINEAR, EXPONENTIAL)
|
|
63
|
+
- **Jitter** to prevent thundering herd
|
|
64
|
+
- **Circuit breaker** to fail fast and protect downstream systems
|
|
65
|
+
- **Caching** for idempotent read operations
|
|
66
|
+
- **Rate & concurrency limits** to respect external constraints
|
|
67
|
+
|
|
68
|
+
### Type Safety
|
|
69
|
+
|
|
70
|
+
All examples in this guide use TypeScript generics for type-safe request/response data and function arguments/returns. Analyzers validate shapes at runtime; TypeScript ensures compile-time safety.
|
|
71
|
+
|
|
72
|
+
### Config Cascading
|
|
73
|
+
|
|
74
|
+
Global defaults → group overrides → phase overrides → branch overrides → item overrides. Lower levels always win, preventing repetition while maintaining expressiveness.
|
|
75
|
+
|
|
76
|
+
### Shared State
|
|
77
|
+
|
|
78
|
+
Workflows and gateways support `sharedBuffer` for passing computed state across phases/branches/items without global state.
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
---
|
|
69
81
|
|
|
70
|
-
##
|
|
82
|
+
## Core Modules
|
|
71
83
|
|
|
72
|
-
###
|
|
84
|
+
### stableRequest
|
|
73
85
|
|
|
74
|
-
|
|
86
|
+
Single API call with resilience, type-safe request and response types.
|
|
75
87
|
|
|
76
88
|
```typescript
|
|
77
|
-
import { stableRequest,
|
|
89
|
+
import { stableRequest, REQUEST_METHODS, VALID_REQUEST_PROTOCOLS } from '@emmvish/stable-request';
|
|
90
|
+
|
|
91
|
+
interface GetUserRequest {
|
|
92
|
+
// Empty for GET requests with no body
|
|
93
|
+
}
|
|
78
94
|
|
|
79
|
-
|
|
95
|
+
interface User {
|
|
96
|
+
id: number;
|
|
97
|
+
name: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const result = await stableRequest<GetUserRequest, User>({
|
|
80
101
|
reqData: {
|
|
102
|
+
method: REQUEST_METHODS.GET,
|
|
103
|
+
protocol: VALID_REQUEST_PROTOCOLS.HTTPS,
|
|
81
104
|
hostname: 'api.example.com',
|
|
82
|
-
path: '/users/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
105
|
+
path: '/users/1'
|
|
106
|
+
},
|
|
107
|
+
resReq: true,
|
|
108
|
+
attempts: 3,
|
|
109
|
+
wait: 500,
|
|
110
|
+
jitter: 100,
|
|
111
|
+
cache: { enabled: true, ttl: 5000 },
|
|
112
|
+
rateLimit: { maxRequests: 10, windowMs: 1000 },
|
|
113
|
+
maxConcurrentRequests: 5,
|
|
114
|
+
responseAnalyzer: ({ data }) => {
|
|
115
|
+
return typeof data === 'object' && data !== null && 'id' in data;
|
|
116
|
+
},
|
|
117
|
+
handleSuccessfulAttemptData: ({ successfulAttemptData }) => {
|
|
118
|
+
console.log(`User loaded: ${successfulAttemptData.data.name}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (result.success) {
|
|
123
|
+
console.log(result.data.name, result.metrics.totalAttempts);
|
|
124
|
+
} else {
|
|
125
|
+
console.error(result.error);
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Key responsibilities:**
|
|
130
|
+
- Execute a single HTTP request with automatic retry and backoff
|
|
131
|
+
- Validate response shape via analyzer; retry if invalid
|
|
132
|
+
- Cache successful responses with TTL
|
|
133
|
+
- Apply rate and concurrency limits
|
|
134
|
+
- Throw or gracefully suppress errors via finalErrorAnalyzer
|
|
135
|
+
- Collect attempt metrics and infra dashboards (circuit breaker, cache, rate limiter state)
|
|
136
|
+
|
|
137
|
+
### stableFunction
|
|
138
|
+
|
|
139
|
+
Generic async/sync function execution with identical resilience.
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { stableFunction, RETRY_STRATEGIES } from '@emmvish/stable-request';
|
|
143
|
+
|
|
144
|
+
type ComputeArgs = [number, number];
|
|
145
|
+
type ComputeResult = number;
|
|
146
|
+
|
|
147
|
+
const multiply = (a: number, b: number) => a * b;
|
|
148
|
+
|
|
149
|
+
const result = await stableFunction<ComputeArgs, ComputeResult>({
|
|
150
|
+
fn: multiply,
|
|
151
|
+
args: [5, 3],
|
|
152
|
+
returnResult: true,
|
|
153
|
+
attempts: 2,
|
|
154
|
+
wait: 100,
|
|
88
155
|
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
89
|
-
|
|
156
|
+
responseAnalyzer: ({ data }) => data > 0,
|
|
157
|
+
cache: { enabled: true, ttl: 10000 }
|
|
90
158
|
});
|
|
91
159
|
|
|
92
|
-
|
|
160
|
+
if (result.success) {
|
|
161
|
+
console.log('Result:', result.data); // 15
|
|
162
|
+
}
|
|
93
163
|
```
|
|
94
164
|
|
|
95
|
-
|
|
165
|
+
**Key responsibilities:**
|
|
166
|
+
- Execute any async or sync function with typed arguments and return
|
|
167
|
+
- Support argument-based cache key generation
|
|
168
|
+
- Retry on error or analyzer rejection
|
|
169
|
+
- Enforce success criteria via analyzer
|
|
170
|
+
- Optionally suppress exceptions
|
|
96
171
|
|
|
97
|
-
|
|
172
|
+
### stableApiGateway
|
|
173
|
+
|
|
174
|
+
Batch orchestration of mixed requests and functions.
|
|
98
175
|
|
|
99
176
|
```typescript
|
|
100
|
-
import {
|
|
177
|
+
import {
|
|
178
|
+
stableApiGateway,
|
|
179
|
+
REQUEST_METHODS,
|
|
180
|
+
VALID_REQUEST_PROTOCOLS,
|
|
181
|
+
RequestOrFunction
|
|
182
|
+
} from '@emmvish/stable-request';
|
|
183
|
+
import type { API_GATEWAY_ITEM } from '@emmvish/stable-request';
|
|
101
184
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
185
|
+
// Define request types
|
|
186
|
+
interface ApiRequestData {
|
|
187
|
+
filters?: Record<string, any>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface ApiResponse {
|
|
191
|
+
id: number;
|
|
192
|
+
value: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Define function types
|
|
196
|
+
type TransformArgs = [ApiResponse[], number];
|
|
197
|
+
type TransformResult = {
|
|
198
|
+
transformed: ApiResponse[];
|
|
199
|
+
count: number;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
type ValidateArgs = [TransformResult];
|
|
203
|
+
type ValidateResult = boolean;
|
|
204
|
+
|
|
205
|
+
const items: API_GATEWAY_ITEM<ApiRequestData, ApiResponse, TransformArgs | ValidateArgs, TransformResult | ValidateResult>[] = [
|
|
206
|
+
{
|
|
207
|
+
type: RequestOrFunction.REQUEST,
|
|
208
|
+
request: {
|
|
209
|
+
id: 'fetch-data',
|
|
210
|
+
requestOptions: {
|
|
211
|
+
reqData: {
|
|
212
|
+
method: REQUEST_METHODS.GET,
|
|
213
|
+
protocol: VALID_REQUEST_PROTOCOLS.HTTPS,
|
|
214
|
+
hostname: 'api.example.com',
|
|
215
|
+
path: '/data'
|
|
216
|
+
},
|
|
217
|
+
resReq: true,
|
|
218
|
+
attempts: 3
|
|
219
|
+
}
|
|
220
|
+
}
|
|
109
221
|
},
|
|
110
|
-
{
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
222
|
+
{
|
|
223
|
+
type: RequestOrFunction.FUNCTION,
|
|
224
|
+
function: {
|
|
225
|
+
id: 'transform-data',
|
|
226
|
+
functionOptions: {
|
|
227
|
+
fn: (data: ApiResponse[], threshold: number): TransformResult => ({
|
|
228
|
+
transformed: data.filter(item => item.id > threshold),
|
|
229
|
+
count: data.length
|
|
230
|
+
}),
|
|
231
|
+
args: [[], 10] as TransformArgs,
|
|
232
|
+
returnResult: true,
|
|
233
|
+
attempts: 2,
|
|
234
|
+
cache: { enabled: true, ttl: 5000 }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
116
237
|
},
|
|
117
|
-
{
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
238
|
+
{
|
|
239
|
+
type: RequestOrFunction.FUNCTION,
|
|
240
|
+
function: {
|
|
241
|
+
id: 'validate-result',
|
|
242
|
+
functionOptions: {
|
|
243
|
+
fn: (result: TransformResult): ValidateResult => result.count > 0,
|
|
244
|
+
args: [{ transformed: [], count: 0 }] as ValidateArgs,
|
|
245
|
+
returnResult: true
|
|
246
|
+
}
|
|
247
|
+
}
|
|
123
248
|
}
|
|
124
249
|
];
|
|
125
250
|
|
|
126
|
-
const
|
|
127
|
-
concurrentExecution: true,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
commonWait: 500
|
|
251
|
+
const responses = await stableApiGateway<ApiRequestData, ApiResponse>(items, {
|
|
252
|
+
concurrentExecution: true,
|
|
253
|
+
stopOnFirstError: false,
|
|
254
|
+
sharedBuffer: {},
|
|
255
|
+
commonAttempts: 2,
|
|
256
|
+
commonWait: 300,
|
|
257
|
+
maxConcurrentRequests: 3
|
|
134
258
|
});
|
|
135
259
|
|
|
136
|
-
|
|
137
|
-
console.log(
|
|
260
|
+
responses.forEach((resp, i) => {
|
|
261
|
+
console.log(`Item ${i}: success=${resp.success}`);
|
|
138
262
|
});
|
|
139
263
|
```
|
|
140
264
|
|
|
141
|
-
|
|
265
|
+
**Key responsibilities:**
|
|
266
|
+
- Execute a batch of requests and functions concurrently or sequentially
|
|
267
|
+
- Apply global, group-level, and item-level config overrides
|
|
268
|
+
- Maintain shared buffer across items for state passing
|
|
269
|
+
- Stop on first error or continue despite failures
|
|
270
|
+
- Collect per-item and aggregate metrics
|
|
271
|
+
- Support request grouping with group-specific config
|
|
272
|
+
|
|
273
|
+
### stableWorkflow
|
|
142
274
|
|
|
143
|
-
|
|
275
|
+
Phased array-based workflows with sequential/concurrent phases, mixed items, and non-linear control flow.
|
|
144
276
|
|
|
145
277
|
```typescript
|
|
146
|
-
import { stableWorkflow, PHASE_DECISION_ACTIONS,
|
|
278
|
+
import { stableWorkflow, PHASE_DECISION_ACTIONS, RequestOrFunction } from '@emmvish/stable-request';
|
|
279
|
+
import type { STABLE_WORKFLOW_PHASE, API_GATEWAY_ITEM } from '@emmvish/stable-request';
|
|
280
|
+
|
|
281
|
+
// Define types for requests
|
|
282
|
+
interface FetchRequestData {}
|
|
283
|
+
interface FetchResponse {
|
|
284
|
+
users: Array<{ id: number; name: string }>;
|
|
285
|
+
posts: Array<{ id: number; title: string }>;
|
|
286
|
+
}
|
|
147
287
|
|
|
148
|
-
|
|
288
|
+
// Define types for functions
|
|
289
|
+
type ProcessArgs = [FetchResponse];
|
|
290
|
+
type ProcessResult = {
|
|
291
|
+
merged: Array<{ userId: number; userName: string; postTitle: string }>;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
type AuditArgs = [ProcessResult, string];
|
|
295
|
+
type AuditResult = { logged: boolean; timestamp: string };
|
|
296
|
+
|
|
297
|
+
const phases: STABLE_WORKFLOW_PHASE<FetchRequestData, FetchResponse, ProcessArgs | AuditArgs, ProcessResult | AuditResult>[] = [
|
|
149
298
|
{
|
|
150
|
-
id: '
|
|
299
|
+
id: 'fetch-data',
|
|
151
300
|
requests: [
|
|
152
|
-
{
|
|
153
|
-
id: '
|
|
154
|
-
requestOptions: {
|
|
155
|
-
reqData: {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
301
|
+
{
|
|
302
|
+
id: 'get-users-posts',
|
|
303
|
+
requestOptions: {
|
|
304
|
+
reqData: {
|
|
305
|
+
hostname: 'api.example.com',
|
|
306
|
+
path: '/users-and-posts'
|
|
307
|
+
},
|
|
308
|
+
resReq: true,
|
|
309
|
+
attempts: 3
|
|
310
|
+
}
|
|
162
311
|
}
|
|
163
312
|
]
|
|
164
313
|
},
|
|
165
314
|
{
|
|
166
|
-
id: '
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
{
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
315
|
+
id: 'process-and-audit',
|
|
316
|
+
markConcurrentPhase: true,
|
|
317
|
+
items: [
|
|
318
|
+
{
|
|
319
|
+
type: RequestOrFunction.FUNCTION,
|
|
320
|
+
function: {
|
|
321
|
+
id: 'process-data',
|
|
322
|
+
functionOptions: {
|
|
323
|
+
fn: (data: FetchResponse): ProcessResult => ({
|
|
324
|
+
merged: data.users.map((user, idx) => ({
|
|
325
|
+
userId: user.id,
|
|
326
|
+
userName: user.name,
|
|
327
|
+
postTitle: data.posts[idx]?.title || 'No post'
|
|
328
|
+
}))
|
|
329
|
+
}),
|
|
330
|
+
args: [{ users: [], posts: [] }] as ProcessArgs,
|
|
331
|
+
returnResult: true
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
type: RequestOrFunction.FUNCTION,
|
|
337
|
+
function: {
|
|
338
|
+
id: 'audit-processing',
|
|
339
|
+
functionOptions: {
|
|
340
|
+
fn: async (result: ProcessResult, auditId: string): Promise<AuditResult> => {
|
|
341
|
+
console.log(`Audit ${auditId}:`, result);
|
|
342
|
+
return { logged: true, timestamp: new Date().toISOString() };
|
|
343
|
+
},
|
|
344
|
+
args: [{ merged: [] }, 'audit-123'] as AuditArgs,
|
|
345
|
+
returnResult: true
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
],
|
|
350
|
+
phaseDecisionHook: async ({ phaseResult, sharedBuffer }) => {
|
|
351
|
+
if (!phaseResult.success) {
|
|
352
|
+
return { action: PHASE_DECISION_ACTIONS.TERMINATE };
|
|
353
|
+
}
|
|
354
|
+
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
355
|
+
}
|
|
173
356
|
},
|
|
174
357
|
{
|
|
175
|
-
id: '
|
|
358
|
+
id: 'finalize',
|
|
176
359
|
requests: [
|
|
177
|
-
{
|
|
178
|
-
id: '
|
|
179
|
-
requestOptions: {
|
|
180
|
-
reqData: {
|
|
181
|
-
|
|
182
|
-
|
|
360
|
+
{
|
|
361
|
+
id: 'store-result',
|
|
362
|
+
requestOptions: {
|
|
363
|
+
reqData: {
|
|
364
|
+
hostname: 'api.example.com',
|
|
365
|
+
path: '/store',
|
|
366
|
+
method: 'POST'
|
|
367
|
+
},
|
|
368
|
+
resReq: false
|
|
369
|
+
}
|
|
183
370
|
}
|
|
184
371
|
]
|
|
185
372
|
}
|
|
186
373
|
];
|
|
187
374
|
|
|
188
375
|
const result = await stableWorkflow(phases, {
|
|
189
|
-
workflowId: '
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
376
|
+
workflowId: 'data-pipeline',
|
|
377
|
+
concurrentPhaseExecution: false, // Phases sequential
|
|
378
|
+
enableNonLinearExecution: true,
|
|
379
|
+
sharedBuffer: { userId: '123' },
|
|
380
|
+
commonAttempts: 2,
|
|
381
|
+
commonWait: 200,
|
|
382
|
+
handlePhaseCompletion: ({ phaseResult, workflowId }) => {
|
|
383
|
+
console.log(`Phase ${phaseResult.phaseId} complete in workflow ${workflowId}`);
|
|
384
|
+
}
|
|
194
385
|
});
|
|
195
386
|
|
|
196
|
-
console.log(`Workflow
|
|
197
|
-
console.log(`Total requests: ${result.totalRequests}`);
|
|
198
|
-
console.log(`Successful: ${result.successfulRequests}`);
|
|
199
|
-
console.log(`Failed: ${result.failedRequests}`);
|
|
200
|
-
console.log(`Execution time: ${result.executionTime}ms`);
|
|
387
|
+
console.log(`Workflow succeeded: ${result.success}, phases: ${result.totalPhases}`);
|
|
201
388
|
```
|
|
202
389
|
|
|
203
|
-
|
|
390
|
+
**Key responsibilities:**
|
|
391
|
+
- Execute phases sequentially or concurrently
|
|
392
|
+
- Support mixed requests and functions per phase
|
|
393
|
+
- Enable non-linear flow (CONTINUE, SKIP, REPLAY, JUMP, TERMINATE)
|
|
394
|
+
- Maintain shared buffer across all phases
|
|
395
|
+
- Apply phase-level and request-level config cascading
|
|
396
|
+
- Support branching with parallel/sequential branches
|
|
397
|
+
- Collect per-phase metrics and workflow aggregates
|
|
398
|
+
|
|
399
|
+
### stableWorkflowGraph
|
|
204
400
|
|
|
205
|
-
|
|
401
|
+
DAG-based execution for higher parallelism and explicit phase dependencies.
|
|
206
402
|
|
|
207
403
|
```typescript
|
|
208
|
-
import { stableWorkflowGraph, WorkflowGraphBuilder
|
|
404
|
+
import { stableWorkflowGraph, WorkflowGraphBuilder } from '@emmvish/stable-request';
|
|
209
405
|
|
|
210
406
|
const graph = new WorkflowGraphBuilder()
|
|
211
|
-
|
|
212
|
-
.addPhase('validate-order', {
|
|
407
|
+
.addPhase('fetch-posts', {
|
|
213
408
|
requests: [{
|
|
214
|
-
id: '
|
|
409
|
+
id: 'get-posts',
|
|
215
410
|
requestOptions: {
|
|
216
|
-
reqData: { hostname: 'api.example.com', path: '/
|
|
217
|
-
resReq: true
|
|
218
|
-
logAllSuccessfulAttempts: true
|
|
411
|
+
reqData: { hostname: 'api.example.com', path: '/posts' },
|
|
412
|
+
resReq: true
|
|
219
413
|
}
|
|
220
414
|
}]
|
|
221
415
|
})
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
.addPhase('check-inventory', {
|
|
232
|
-
requests: [{ id: 'inventory', requestOptions: { reqData: { path: '/inventory' }, resReq: true } }]
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
.addPhase('process-payment', {
|
|
236
|
-
requests: [{ id: 'payment', requestOptions: { reqData: { path: '/payment' }, resReq: true } }]
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
// Add merge point to synchronize parallel paths
|
|
240
|
-
.addMergePoint('processing-complete', ['check-inventory', 'process-payment'])
|
|
241
|
-
|
|
242
|
-
.addPhase('fulfill-order', {
|
|
243
|
-
requests: [{ id: 'fulfill', requestOptions: { reqData: { path: '/fulfill' }, resReq: true } }]
|
|
416
|
+
.addPhase('fetch-users', {
|
|
417
|
+
requests: [{
|
|
418
|
+
id: 'get-users',
|
|
419
|
+
requestOptions: {
|
|
420
|
+
reqData: { hostname: 'api.example.com', path: '/users' },
|
|
421
|
+
resReq: true
|
|
422
|
+
}
|
|
423
|
+
}]
|
|
244
424
|
})
|
|
245
|
-
|
|
246
|
-
.addPhase('
|
|
247
|
-
|
|
425
|
+
.addParallelGroup('fetch-all', ['fetch-posts', 'fetch-users'])
|
|
426
|
+
.addPhase('aggregate', {
|
|
427
|
+
functions: [{
|
|
428
|
+
id: 'combine',
|
|
429
|
+
functionOptions: {
|
|
430
|
+
fn: () => ({ posts: [], users: [] }),
|
|
431
|
+
args: [],
|
|
432
|
+
returnResult: true
|
|
433
|
+
}
|
|
434
|
+
}]
|
|
248
435
|
})
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
.
|
|
252
|
-
.connect('check-validation', 'process-order', { condition: { type: WorkflowEdgeConditionTypes.SUCCESS } })
|
|
253
|
-
.connect('check-validation', 'reject-order', { condition: { type: WorkflowEdgeConditionTypes.FAILURE } })
|
|
254
|
-
.connect('process-order', 'check-inventory', { condition: { type: WorkflowEdgeConditionTypes.ALWAYS } })
|
|
255
|
-
.connect('process-order', 'process-payment', { condition: { type: WorkflowEdgeConditionTypes.ALWAYS } })
|
|
256
|
-
.connect('check-inventory', 'processing-complete', { condition: { type: WorkflowEdgeConditionTypes.ALWAYS } })
|
|
257
|
-
.connect('process-payment', 'processing-complete', { condition: { type: WorkflowEdgeConditionTypes.ALWAYS } })
|
|
258
|
-
.connect('processing-complete', 'fulfill-order', { condition: { type: WorkflowEdgeConditionTypes.ALWAYS } })
|
|
259
|
-
|
|
260
|
-
.setEntryPoint('validate-order')
|
|
436
|
+
.addMergePoint('sync', ['fetch-all'])
|
|
437
|
+
.connectSequence('fetch-all', 'sync', 'aggregate')
|
|
438
|
+
.setEntryPoint('fetch-all')
|
|
261
439
|
.build();
|
|
262
440
|
|
|
263
441
|
const result = await stableWorkflowGraph(graph, {
|
|
264
|
-
workflowId: '
|
|
265
|
-
sharedBuffer: {},
|
|
266
|
-
validateGraph: true, // Enforce DAG constraints
|
|
267
|
-
logPhaseResults: true
|
|
442
|
+
workflowId: 'data-aggregation'
|
|
268
443
|
});
|
|
269
444
|
|
|
270
|
-
console.log(`Graph workflow
|
|
271
|
-
console.log(`Phases executed: ${result.completedPhases}/${result.totalPhases}`);
|
|
272
|
-
console.log(`Execution path:`, result.executionHistory.map(h => h.phaseId));
|
|
445
|
+
console.log(`Graph workflow success: ${result.success}`);
|
|
273
446
|
```
|
|
274
447
|
|
|
275
|
-
|
|
448
|
+
**Key responsibilities:**
|
|
449
|
+
- Define phases as DAG nodes with explicit dependency edges
|
|
450
|
+
- Execute independent phases in parallel automatically
|
|
451
|
+
- Support parallel groups, merge points, and conditional routing
|
|
452
|
+
- Validate graph structure (cycle detection, reachability, orphan detection)
|
|
453
|
+
- Provide deterministic execution order
|
|
454
|
+
- Offer higher parallelism than phased workflows for complex topologies
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
## Resilience Mechanisms
|
|
459
|
+
|
|
460
|
+
### Retry Strategies
|
|
276
461
|
|
|
277
|
-
|
|
462
|
+
When a request or function fails and is retryable, retry with configurable backoff.
|
|
278
463
|
|
|
279
|
-
|
|
464
|
+
#### FIXED Strategy
|
|
465
|
+
|
|
466
|
+
Constant wait between retries.
|
|
280
467
|
|
|
281
468
|
```typescript
|
|
282
469
|
import { stableRequest, RETRY_STRATEGIES } from '@emmvish/stable-request';
|
|
283
470
|
|
|
284
|
-
|
|
285
|
-
|
|
471
|
+
interface DataRequest {}
|
|
472
|
+
interface DataResponse { data: any; }
|
|
473
|
+
|
|
474
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
286
475
|
reqData: { hostname: 'api.example.com', path: '/data' },
|
|
287
|
-
|
|
288
|
-
|
|
476
|
+
resReq: true,
|
|
477
|
+
attempts: 4,
|
|
478
|
+
wait: 500,
|
|
289
479
|
retryStrategy: RETRY_STRATEGIES.FIXED
|
|
480
|
+
// Retries at: 500ms, 1000ms, 1500ms
|
|
290
481
|
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
#### LINEAR Strategy
|
|
485
|
+
|
|
486
|
+
Wait increases linearly with attempt number.
|
|
291
487
|
|
|
292
|
-
|
|
293
|
-
await stableRequest({
|
|
488
|
+
```typescript
|
|
489
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
294
490
|
reqData: { hostname: 'api.example.com', path: '/data' },
|
|
295
|
-
|
|
296
|
-
|
|
491
|
+
resReq: true,
|
|
492
|
+
attempts: 4,
|
|
493
|
+
wait: 100,
|
|
297
494
|
retryStrategy: RETRY_STRATEGIES.LINEAR
|
|
495
|
+
// Retries at: 100ms, 200ms, 300ms (wait * attempt)
|
|
298
496
|
});
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
#### EXPONENTIAL Strategy
|
|
299
500
|
|
|
300
|
-
|
|
301
|
-
|
|
501
|
+
Wait increases exponentially; useful for heavily loaded services.
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
302
505
|
reqData: { hostname: 'api.example.com', path: '/data' },
|
|
303
|
-
|
|
304
|
-
|
|
506
|
+
resReq: true,
|
|
507
|
+
attempts: 4,
|
|
508
|
+
wait: 100,
|
|
509
|
+
maxAllowedWait: 10000,
|
|
305
510
|
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL
|
|
511
|
+
// Retries at: 100ms, 200ms, 400ms (wait * 2^(attempt-1))
|
|
512
|
+
// Capped at maxAllowedWait
|
|
306
513
|
});
|
|
307
514
|
```
|
|
308
515
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
- Maximum allowed wait time to prevent excessive delays
|
|
313
|
-
- Per-request or workflow-level configuration
|
|
516
|
+
#### Jitter
|
|
517
|
+
|
|
518
|
+
Add random milliseconds to prevent synchronization.
|
|
314
519
|
|
|
315
|
-
**Custom Response Validation**:
|
|
316
520
|
```typescript
|
|
317
|
-
await stableRequest({
|
|
318
|
-
reqData: { hostname: 'api.example.com', path: '/
|
|
521
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
522
|
+
reqData: { hostname: 'api.example.com', path: '/data' },
|
|
319
523
|
resReq: true,
|
|
320
|
-
attempts:
|
|
321
|
-
wait:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return data.status === 'completed';
|
|
325
|
-
}
|
|
524
|
+
attempts: 3,
|
|
525
|
+
wait: 500,
|
|
526
|
+
jitter: 200, // Add 0-200ms randomness
|
|
527
|
+
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL
|
|
326
528
|
});
|
|
327
529
|
```
|
|
328
530
|
|
|
329
|
-
|
|
531
|
+
#### Perform All Attempts
|
|
330
532
|
|
|
331
|
-
|
|
533
|
+
Collect all outcomes instead of failing on first error.
|
|
332
534
|
|
|
333
535
|
```typescript
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
reqData: { hostname: 'unreliable-api.example.com', path: '/data' },
|
|
536
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
537
|
+
reqData: { hostname: 'api.example.com', path: '/data' },
|
|
538
|
+
resReq: true,
|
|
338
539
|
attempts: 3,
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
minimumRequests: 10, // Minimum requests before evaluation
|
|
342
|
-
recoveryTimeoutMs: 60000, // Wait 60s before trying again (half-open)
|
|
343
|
-
successThresholdPercentage: 20, // Close after 20% successes in half-open
|
|
344
|
-
trackIndividualAttempts: false // Track at request level (not attempt level)
|
|
345
|
-
}
|
|
540
|
+
performAllAttempts: true
|
|
541
|
+
// All 3 attempts execute; check result.successfulAttempts
|
|
346
542
|
});
|
|
347
543
|
```
|
|
348
544
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
- **HALF_OPEN**: Testing if service recovered, limited requests allowed
|
|
545
|
+
### Circuit Breaker
|
|
546
|
+
|
|
547
|
+
Prevent cascading failures by failing fast when a dependency becomes unhealthy.
|
|
353
548
|
|
|
354
|
-
**Workflow-Level Circuit Breakers**:
|
|
355
549
|
```typescript
|
|
356
|
-
import { CircuitBreaker } from '@emmvish/stable-request';
|
|
550
|
+
import { stableApiGateway, CircuitBreaker } from '@emmvish/stable-request';
|
|
357
551
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
minimumRequests: 10, // Minimum 10 requests before evaluation
|
|
361
|
-
recoveryTimeoutMs: 120000, // 120s timeout in open state
|
|
362
|
-
successThresholdPercentage: 50 // 50% success rate closes circuit
|
|
363
|
-
});
|
|
552
|
+
interface FlakyRequest {}
|
|
553
|
+
interface FlakyResponse { status: string; }
|
|
364
554
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
555
|
+
const breaker = new CircuitBreaker({
|
|
556
|
+
failureThresholdPercentage: 50,
|
|
557
|
+
minimumRequests: 10,
|
|
558
|
+
recoveryTimeoutMs: 30000,
|
|
559
|
+
successThresholdPercentage: 80,
|
|
560
|
+
halfOpenMaxRequests: 5
|
|
368
561
|
});
|
|
369
562
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
### Response Caching
|
|
376
|
-
|
|
377
|
-
Reduce redundant API calls and improve performance with intelligent caching:
|
|
563
|
+
const requests = [
|
|
564
|
+
{ id: 'req-1', requestOptions: { reqData: { path: '/flaky' }, resReq: true } },
|
|
565
|
+
{ id: 'req-2', requestOptions: { reqData: { path: '/flaky' }, resReq: true } }
|
|
566
|
+
];
|
|
378
567
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
reqData: { hostname: 'api.example.com', path: '/static-data' },
|
|
382
|
-
resReq: true,
|
|
383
|
-
cache: {
|
|
384
|
-
enabled: true,
|
|
385
|
-
ttl: 300000, // Cache for 5 minutes
|
|
386
|
-
key: 'custom-cache-key' // Optional: custom cache key
|
|
387
|
-
}
|
|
568
|
+
const responses = await stableApiGateway<FlakyRequest, FlakyResponse>(requests, {
|
|
569
|
+
circuitBreaker: breaker
|
|
388
570
|
});
|
|
389
571
|
|
|
390
|
-
//
|
|
572
|
+
// Circuit breaker states:
|
|
573
|
+
// CLOSED: Normal operation (accept all requests)
|
|
574
|
+
// OPEN: Too many failures; reject immediately
|
|
575
|
+
// HALF_OPEN: Testing recovery; allow limited requests
|
|
391
576
|
```
|
|
392
577
|
|
|
393
|
-
**
|
|
394
|
-
```typescript
|
|
395
|
-
import { getGlobalCacheManager, resetGlobalCacheManager } from '@emmvish/stable-request';
|
|
578
|
+
**State Transitions:**
|
|
396
579
|
|
|
397
|
-
|
|
580
|
+
- **CLOSED → OPEN:** Failure rate exceeds threshold after minimum requests
|
|
581
|
+
- **OPEN → HALF_OPEN:** Recovery timeout elapsed; attempt recovery
|
|
582
|
+
- **HALF_OPEN → CLOSED:** Success rate exceeds recovery threshold
|
|
583
|
+
- **HALF_OPEN → OPEN:** Success rate below recovery threshold; reopen
|
|
398
584
|
|
|
399
|
-
|
|
400
|
-
const stats = cacheManager.getStats();
|
|
401
|
-
console.log(stats);
|
|
402
|
-
// { size: 42, validEntries: 38, expiredEntries: 4 }
|
|
585
|
+
### Caching
|
|
403
586
|
|
|
404
|
-
|
|
405
|
-
cacheManager.clearAll();
|
|
587
|
+
Cache responses to avoid redundant calls.
|
|
406
588
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
589
|
+
```typescript
|
|
590
|
+
import { stableRequest, CacheManager } from '@emmvish/stable-request';
|
|
591
|
+
|
|
592
|
+
interface UserRequest {}
|
|
593
|
+
interface UserResponse {
|
|
594
|
+
id: number;
|
|
595
|
+
name: string;
|
|
596
|
+
email: string;
|
|
597
|
+
}
|
|
410
598
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
- Manual cache inspection and clearing
|
|
416
|
-
- Per-request cache configuration
|
|
599
|
+
const cache = new CacheManager({
|
|
600
|
+
enabled: true,
|
|
601
|
+
ttl: 5000 // 5 seconds
|
|
602
|
+
});
|
|
417
603
|
|
|
418
|
-
|
|
604
|
+
// First call: cache miss, hits API
|
|
605
|
+
const result1 = await stableRequest<UserRequest, UserResponse>({
|
|
606
|
+
reqData: { hostname: 'api.example.com', path: '/user/1' },
|
|
607
|
+
resReq: true,
|
|
608
|
+
cache
|
|
609
|
+
});
|
|
419
610
|
|
|
420
|
-
|
|
611
|
+
// Second call within 5s: cache hit, returns cached response
|
|
612
|
+
const result2 = await stableRequest<UserRequest, UserResponse>({
|
|
613
|
+
reqData: { hostname: 'api.example.com', path: '/user/1' },
|
|
614
|
+
resReq: true,
|
|
615
|
+
cache
|
|
616
|
+
});
|
|
421
617
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
rateLimit: {
|
|
428
|
-
maxRequests: 100, // 100 requests
|
|
429
|
-
windowMs: 60000 // per 60 seconds
|
|
430
|
-
},
|
|
431
|
-
|
|
432
|
-
// Concurrency limiting
|
|
433
|
-
maxConcurrentRequests: 5 // Max 5 parallel requests
|
|
618
|
+
// Respects Cache-Control headers if enabled
|
|
619
|
+
const cache2 = new CacheManager({
|
|
620
|
+
enabled: true,
|
|
621
|
+
ttl: 60000,
|
|
622
|
+
respectCacheControl: true // Uses max-age, no-cache, no-store
|
|
434
623
|
});
|
|
435
624
|
```
|
|
436
625
|
|
|
437
|
-
**
|
|
438
|
-
```typescript
|
|
439
|
-
const phases = [
|
|
440
|
-
{
|
|
441
|
-
id: 'bulk-import',
|
|
442
|
-
maxConcurrentRequests: 10, // Override workflow limit
|
|
443
|
-
rateLimit: {
|
|
444
|
-
maxRequests: 50,
|
|
445
|
-
windowMs: 10000
|
|
446
|
-
},
|
|
447
|
-
requests: [...]
|
|
448
|
-
}
|
|
449
|
-
];
|
|
450
|
-
```
|
|
626
|
+
**Function Caching:**
|
|
451
627
|
|
|
452
|
-
|
|
453
|
-
```typescript
|
|
454
|
-
import { RateLimiter } from '@emmvish/stable-request';
|
|
628
|
+
Arguments become cache key; identical args hit cache.
|
|
455
629
|
|
|
456
|
-
|
|
630
|
+
```typescript
|
|
631
|
+
import { stableFunction } from '@emmvish/stable-request';
|
|
457
632
|
|
|
458
|
-
const
|
|
459
|
-
console.log(state);
|
|
460
|
-
// { availableTokens: 1000, queueLength: 0, maxRequests: 1000, windowMs: 3600000 }
|
|
461
|
-
```
|
|
633
|
+
const expensive = (x: number) => x * x * x; // Cubic calculation
|
|
462
634
|
|
|
463
|
-
|
|
635
|
+
const result1 = await stableFunction({
|
|
636
|
+
fn: expensive,
|
|
637
|
+
args: [5],
|
|
638
|
+
returnResult: true,
|
|
639
|
+
cache: { enabled: true, ttl: 10000 }
|
|
640
|
+
});
|
|
464
641
|
|
|
465
|
-
|
|
642
|
+
const result2 = await stableFunction({
|
|
643
|
+
fn: expensive,
|
|
644
|
+
args: [5], // Same args → cache hit
|
|
645
|
+
returnResult: true,
|
|
646
|
+
cache: { enabled: true, ttl: 10000 }
|
|
647
|
+
});
|
|
648
|
+
```
|
|
466
649
|
|
|
467
|
-
###
|
|
650
|
+
### Rate Limiting
|
|
468
651
|
|
|
469
|
-
|
|
652
|
+
Enforce max requests per time window.
|
|
470
653
|
|
|
471
654
|
```typescript
|
|
472
|
-
import {
|
|
655
|
+
import { stableApiGateway } from '@emmvish/stable-request';
|
|
473
656
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
resReq: true,
|
|
480
|
-
attempts: 3,
|
|
481
|
-
wait: 1000,
|
|
482
|
-
logAllErrors: true
|
|
483
|
-
});
|
|
657
|
+
interface ItemRequest {}
|
|
658
|
+
interface ItemResponse {
|
|
659
|
+
id: number;
|
|
660
|
+
data: any;
|
|
661
|
+
}
|
|
484
662
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
errorLogs: result.errorLogs, // All failed attempts
|
|
491
|
-
successfulAttempts: result.successfulAttempts, // All successful attempts
|
|
492
|
-
metrics: {
|
|
493
|
-
totalAttempts: result.metrics.totalAttempts, // 3
|
|
494
|
-
successfulAttempts: result.metrics.successfulAttempts, // 1
|
|
495
|
-
failedAttempts: result.metrics.failedAttempts, // 2
|
|
496
|
-
totalExecutionTime: result.metrics.totalExecutionTime, // ms
|
|
497
|
-
averageAttemptTime: result.metrics.averageAttemptTime, // ms
|
|
498
|
-
infrastructureMetrics: {
|
|
499
|
-
circuitBreaker: result.metrics.infrastructureMetrics?.circuitBreaker,
|
|
500
|
-
cache: result.metrics.infrastructureMetrics?.cache
|
|
501
|
-
}
|
|
663
|
+
const requests = Array.from({ length: 20 }, (_, i) => ({
|
|
664
|
+
id: `req-${i}`,
|
|
665
|
+
requestOptions: {
|
|
666
|
+
reqData: { path: `/item/${i}` },
|
|
667
|
+
resReq: true
|
|
502
668
|
}
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
// Error logs provide detailed attempt information
|
|
506
|
-
result.errorLogs?.forEach(log => {
|
|
507
|
-
console.log({
|
|
508
|
-
attempt: log.attempt, // "1/3"
|
|
509
|
-
timestamp: log.timestamp,
|
|
510
|
-
error: log.error,
|
|
511
|
-
statusCode: log.statusCode,
|
|
512
|
-
type: log.type, // "HTTP_ERROR" | "INVALID_CONTENT"
|
|
513
|
-
isRetryable: log.isRetryable,
|
|
514
|
-
executionTime: log.executionTime
|
|
515
|
-
});
|
|
516
|
-
});
|
|
669
|
+
}));
|
|
517
670
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
statusCode: attempt.statusCode
|
|
526
|
-
});
|
|
671
|
+
const responses = await stableApiGateway<ItemRequest, ItemResponse>(requests, {
|
|
672
|
+
concurrentExecution: true,
|
|
673
|
+
rateLimit: {
|
|
674
|
+
maxRequests: 5,
|
|
675
|
+
windowMs: 1000 // 5 requests per second
|
|
676
|
+
}
|
|
677
|
+
// Requests queued until window allows; prevents overwhelming API
|
|
527
678
|
});
|
|
528
679
|
```
|
|
529
680
|
|
|
530
|
-
|
|
531
|
-
- `success`: Boolean indicating if request succeeded
|
|
532
|
-
- `data`: Response data (if `resReq: true`)
|
|
533
|
-
- `error`: Error message (if request failed)
|
|
534
|
-
- `errorLogs`: Array of all failed attempt details
|
|
535
|
-
- `successfulAttempts`: Array of all successful attempt details
|
|
536
|
-
- `metrics`: Computed execution metrics and infrastructure statistics
|
|
681
|
+
### Concurrency Limiting
|
|
537
682
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
`stableApiGateway` provides aggregated metrics for batch requests:
|
|
683
|
+
Limit concurrent in-flight requests.
|
|
541
684
|
|
|
542
685
|
```typescript
|
|
543
686
|
import { stableApiGateway } from '@emmvish/stable-request';
|
|
544
687
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
688
|
+
interface ItemRequest {}
|
|
689
|
+
interface ItemResponse {
|
|
690
|
+
id: number;
|
|
691
|
+
data: any;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const requests = Array.from({ length: 50 }, (_, i) => ({
|
|
695
|
+
id: `req-${i}`,
|
|
696
|
+
requestOptions: {
|
|
697
|
+
reqData: { path: `/item/${i}` },
|
|
698
|
+
resReq: true,
|
|
699
|
+
attempts: 1
|
|
700
|
+
}
|
|
701
|
+
}));
|
|
551
702
|
|
|
552
|
-
const
|
|
703
|
+
const responses = await stableApiGateway<ItemRequest, ItemResponse>(requests, {
|
|
553
704
|
concurrentExecution: true,
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
circuitBreaker: { failureThresholdPercentage: 50, minimumRequests: 5 },
|
|
557
|
-
rateLimit: { maxRequests: 100, windowMs: 60000 },
|
|
558
|
-
maxConcurrentRequests: 5
|
|
705
|
+
maxConcurrentRequests: 5 // Only 5 requests in-flight at a time
|
|
706
|
+
// Others queued and executed as slots free
|
|
559
707
|
});
|
|
708
|
+
```
|
|
560
709
|
|
|
561
|
-
|
|
562
|
-
console.log('Gateway Metrics:', {
|
|
563
|
-
totalRequests: results.metrics.totalRequests, // 4
|
|
564
|
-
successfulRequests: results.metrics.successfulRequests, // 3
|
|
565
|
-
failedRequests: results.metrics.failedRequests, // 1
|
|
566
|
-
successRate: results.metrics.successRate, // 75%
|
|
567
|
-
failureRate: results.metrics.failureRate // 25%
|
|
568
|
-
});
|
|
710
|
+
---
|
|
569
711
|
|
|
570
|
-
|
|
571
|
-
results.metrics.requestGroups?.forEach(group => {
|
|
572
|
-
console.log(`Group ${group.groupId}:`, {
|
|
573
|
-
totalRequests: group.totalRequests,
|
|
574
|
-
successfulRequests: group.successfulRequests,
|
|
575
|
-
failedRequests: group.failedRequests,
|
|
576
|
-
successRate: group.successRate, // %
|
|
577
|
-
failureRate: group.failureRate, // %
|
|
578
|
-
requestIds: group.requestIds // Array of request IDs
|
|
579
|
-
});
|
|
580
|
-
});
|
|
712
|
+
## Workflow Patterns
|
|
581
713
|
|
|
582
|
-
|
|
583
|
-
if (results.metrics.infrastructureMetrics) {
|
|
584
|
-
const infra = results.metrics.infrastructureMetrics;
|
|
585
|
-
|
|
586
|
-
// Circuit Breaker metrics
|
|
587
|
-
if (infra.circuitBreaker) {
|
|
588
|
-
console.log('Circuit Breaker:', {
|
|
589
|
-
state: infra.circuitBreaker.state, // CLOSED | OPEN | HALF_OPEN
|
|
590
|
-
isHealthy: infra.circuitBreaker.isHealthy,
|
|
591
|
-
totalRequests: infra.circuitBreaker.totalRequests,
|
|
592
|
-
failurePercentage: infra.circuitBreaker.failurePercentage,
|
|
593
|
-
openCount: infra.circuitBreaker.openCount,
|
|
594
|
-
recoveryAttempts: infra.circuitBreaker.recoveryAttempts
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Cache metrics
|
|
599
|
-
if (infra.cache) {
|
|
600
|
-
console.log('Cache:', {
|
|
601
|
-
hitRate: infra.cache.hitRate, // %
|
|
602
|
-
currentSize: infra.cache.currentSize,
|
|
603
|
-
networkRequestsSaved: infra.cache.networkRequestsSaved,
|
|
604
|
-
cacheEfficiency: infra.cache.cacheEfficiency // %
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Rate Limiter metrics
|
|
609
|
-
if (infra.rateLimiter) {
|
|
610
|
-
console.log('Rate Limiter:', {
|
|
611
|
-
throttledRequests: infra.rateLimiter.throttledRequests,
|
|
612
|
-
throttleRate: infra.rateLimiter.throttleRate, // %
|
|
613
|
-
peakRequestRate: infra.rateLimiter.peakRequestRate
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Concurrency Limiter metrics
|
|
618
|
-
if (infra.concurrencyLimiter) {
|
|
619
|
-
console.log('Concurrency:', {
|
|
620
|
-
peakConcurrency: infra.concurrencyLimiter.peakConcurrency,
|
|
621
|
-
utilizationPercentage: infra.concurrencyLimiter.utilizationPercentage,
|
|
622
|
-
averageQueueWaitTime: infra.concurrencyLimiter.averageQueueWaitTime
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
```
|
|
714
|
+
### Sequential & Concurrent Phases
|
|
627
715
|
|
|
628
|
-
|
|
716
|
+
#### Sequential (Default)
|
|
629
717
|
|
|
630
|
-
|
|
718
|
+
Each phase waits for the previous to complete.
|
|
631
719
|
|
|
632
720
|
```typescript
|
|
633
|
-
import { stableWorkflow
|
|
721
|
+
import { stableWorkflow } from '@emmvish/stable-request';
|
|
722
|
+
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
634
723
|
|
|
635
|
-
const phases = [
|
|
724
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
636
725
|
{
|
|
637
|
-
id: '
|
|
638
|
-
requests: [
|
|
726
|
+
id: 'phase-1',
|
|
727
|
+
requests: [{ id: 'r1', requestOptions: { reqData: { path: '/p1' }, resReq: true } }]
|
|
639
728
|
},
|
|
640
729
|
{
|
|
641
|
-
id: '
|
|
642
|
-
|
|
643
|
-
requests: [/* ... */]
|
|
730
|
+
id: 'phase-2',
|
|
731
|
+
requests: [{ id: 'r2', requestOptions: { reqData: { path: '/p2' }, resReq: true } }]
|
|
644
732
|
},
|
|
645
733
|
{
|
|
646
|
-
id: '
|
|
647
|
-
requests: [
|
|
734
|
+
id: 'phase-3',
|
|
735
|
+
requests: [{ id: 'r3', requestOptions: { reqData: { path: '/p3' }, resReq: true } }]
|
|
648
736
|
}
|
|
649
737
|
];
|
|
650
738
|
|
|
651
739
|
const result = await stableWorkflow(phases, {
|
|
652
|
-
workflowId: '
|
|
653
|
-
|
|
654
|
-
commonRequestData: { hostname: 'api.example.com' },
|
|
655
|
-
logPhaseResults: true
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
// Workflow-level metrics
|
|
659
|
-
console.log('Workflow Metrics:', {
|
|
660
|
-
workflowId: result.metrics.workflowId,
|
|
661
|
-
success: result.metrics.success,
|
|
662
|
-
executionTime: result.metrics.executionTime, // Total time in ms
|
|
663
|
-
|
|
664
|
-
// Phase statistics
|
|
665
|
-
totalPhases: result.metrics.totalPhases,
|
|
666
|
-
completedPhases: result.metrics.completedPhases,
|
|
667
|
-
skippedPhases: result.metrics.skippedPhases,
|
|
668
|
-
failedPhases: result.metrics.failedPhases,
|
|
669
|
-
phaseCompletionRate: result.metrics.phaseCompletionRate, // %
|
|
670
|
-
averagePhaseExecutionTime: result.metrics.averagePhaseExecutionTime, // ms
|
|
671
|
-
|
|
672
|
-
// Request statistics
|
|
673
|
-
totalRequests: result.metrics.totalRequests,
|
|
674
|
-
successfulRequests: result.metrics.successfulRequests,
|
|
675
|
-
failedRequests: result.metrics.failedRequests,
|
|
676
|
-
requestSuccessRate: result.metrics.requestSuccessRate, // %
|
|
677
|
-
requestFailureRate: result.metrics.requestFailureRate, // %
|
|
678
|
-
|
|
679
|
-
// Performance
|
|
680
|
-
throughput: result.metrics.throughput, // requests/second
|
|
681
|
-
totalPhaseReplays: result.metrics.totalPhaseReplays,
|
|
682
|
-
totalPhaseSkips: result.metrics.totalPhaseSkips,
|
|
683
|
-
|
|
684
|
-
// Branch metrics (if using branch execution)
|
|
685
|
-
totalBranches: result.metrics.totalBranches,
|
|
686
|
-
completedBranches: result.metrics.completedBranches,
|
|
687
|
-
failedBranches: result.metrics.failedBranches,
|
|
688
|
-
branchSuccessRate: result.metrics.branchSuccessRate // %
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
// Request group metrics aggregated across entire workflow
|
|
692
|
-
result.requestGroupMetrics?.forEach(group => {
|
|
693
|
-
console.log(`Request Group ${group.groupId}:`, {
|
|
694
|
-
totalRequests: group.totalRequests,
|
|
695
|
-
successRate: group.successRate, // %
|
|
696
|
-
requestIds: group.requestIds
|
|
697
|
-
});
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
// Per-phase metrics
|
|
701
|
-
result.phases.forEach(phase => {
|
|
702
|
-
console.log(`Phase ${phase.phaseId}:`, {
|
|
703
|
-
executionTime: phase.metrics?.executionTime,
|
|
704
|
-
totalRequests: phase.metrics?.totalRequests,
|
|
705
|
-
successfulRequests: phase.metrics?.successfulRequests,
|
|
706
|
-
requestSuccessRate: phase.metrics?.requestSuccessRate, // %
|
|
707
|
-
hasDecision: phase.metrics?.hasDecision,
|
|
708
|
-
decisionAction: phase.metrics?.decisionAction // CONTINUE | JUMP | REPLAY | etc.
|
|
709
|
-
});
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
// Branch metrics (for branched workflows)
|
|
713
|
-
result.branches?.forEach(branch => {
|
|
714
|
-
console.log(`Branch ${branch.branchId}:`, {
|
|
715
|
-
success: branch.metrics?.success,
|
|
716
|
-
executionTime: branch.metrics?.executionTime,
|
|
717
|
-
totalPhases: branch.metrics?.totalPhases,
|
|
718
|
-
completedPhases: branch.metrics?.completedPhases,
|
|
719
|
-
totalRequests: branch.metrics?.totalRequests,
|
|
720
|
-
requestSuccessRate: branch.metrics?.requestSuccessRate // %
|
|
721
|
-
});
|
|
740
|
+
workflowId: 'sequential-phases',
|
|
741
|
+
concurrentPhaseExecution: false // Phase-1 → Phase-2 → Phase-3
|
|
722
742
|
});
|
|
723
743
|
```
|
|
724
744
|
|
|
725
|
-
|
|
745
|
+
#### Concurrent Phases
|
|
726
746
|
|
|
727
|
-
|
|
747
|
+
Multiple phases run in parallel.
|
|
728
748
|
|
|
729
749
|
```typescript
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
//
|
|
748
|
-
const cbMetrics = MetricsAggregator.extractCircuitBreakerMetrics(circuitBreaker);
|
|
749
|
-
|
|
750
|
-
// Extract cache metrics
|
|
751
|
-
const cacheMetrics = MetricsAggregator.extractCacheMetrics(cacheManager);
|
|
752
|
-
|
|
753
|
-
// Extract rate limiter metrics
|
|
754
|
-
const rateLimiterMetrics = MetricsAggregator.extractRateLimiterMetrics(rateLimiter);
|
|
755
|
-
|
|
756
|
-
// Extract concurrency limiter metrics
|
|
757
|
-
const concurrencyMetrics = MetricsAggregator.extractConcurrencyLimiterMetrics(limiter);
|
|
758
|
-
|
|
759
|
-
// Aggregate all system metrics
|
|
760
|
-
const systemMetrics = MetricsAggregator.aggregateSystemMetrics(
|
|
761
|
-
workflowResult,
|
|
762
|
-
circuitBreaker,
|
|
763
|
-
cacheManager,
|
|
764
|
-
rateLimiter,
|
|
765
|
-
concurrencyLimiter
|
|
766
|
-
);
|
|
767
|
-
|
|
768
|
-
console.log('Complete System View:', {
|
|
769
|
-
workflow: systemMetrics.workflow,
|
|
770
|
-
phases: systemMetrics.phases,
|
|
771
|
-
branches: systemMetrics.branches,
|
|
772
|
-
requestGroups: systemMetrics.requestGroups,
|
|
773
|
-
requests: systemMetrics.requests,
|
|
774
|
-
circuitBreaker: systemMetrics.circuitBreaker,
|
|
775
|
-
cache: systemMetrics.cache,
|
|
776
|
-
rateLimiter: systemMetrics.rateLimiter,
|
|
777
|
-
concurrencyLimiter: systemMetrics.concurrencyLimiter
|
|
750
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
751
|
+
{
|
|
752
|
+
id: 'fetch-users',
|
|
753
|
+
requests: [{ id: 'get-users', requestOptions: { reqData: { path: '/users' }, resReq: true } }]
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
id: 'fetch-posts',
|
|
757
|
+
requests: [{ id: 'get-posts', requestOptions: { reqData: { path: '/posts' }, resReq: true } }]
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
id: 'fetch-comments',
|
|
761
|
+
requests: [{ id: 'get-comments', requestOptions: { reqData: { path: '/comments' }, resReq: true } }]
|
|
762
|
+
}
|
|
763
|
+
];
|
|
764
|
+
|
|
765
|
+
const result = await stableWorkflow(phases, {
|
|
766
|
+
workflowId: 'parallel-phases',
|
|
767
|
+
concurrentPhaseExecution: true // All 3 phases in parallel
|
|
778
768
|
});
|
|
779
769
|
```
|
|
780
770
|
|
|
781
|
-
|
|
782
|
-
- `WorkflowMetrics`: Complete workflow statistics
|
|
783
|
-
- `BranchMetrics`: Branch execution metrics
|
|
784
|
-
- `PhaseMetrics`: Individual phase metrics
|
|
785
|
-
- `RequestGroupMetrics`: Grouped request statistics
|
|
786
|
-
- `RequestMetrics`: Individual request metrics
|
|
787
|
-
- `CircuitBreakerDashboardMetrics`: Circuit breaker state and performance
|
|
788
|
-
- `CacheDashboardMetrics`: Cache hit rates and efficiency
|
|
789
|
-
- `RateLimiterDashboardMetrics`: Throttling and rate limit statistics
|
|
790
|
-
- `ConcurrencyLimiterDashboardMetrics`: Concurrency and queue metrics
|
|
791
|
-
- `SystemMetrics`: Complete system-wide aggregation
|
|
792
|
-
|
|
793
|
-
## Workflow Execution Patterns
|
|
794
|
-
|
|
795
|
-
### Sequential and Concurrent Phases
|
|
771
|
+
#### Mixed Phases
|
|
796
772
|
|
|
797
|
-
|
|
773
|
+
Combine sequential and concurrent phases in one workflow.
|
|
798
774
|
|
|
799
|
-
**Sequential Phases (Default)**:
|
|
800
775
|
```typescript
|
|
801
|
-
const phases = [
|
|
802
|
-
{
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
{
|
|
817
|
-
|
|
776
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
777
|
+
{
|
|
778
|
+
id: 'init', // Sequential
|
|
779
|
+
requests: [{ id: 'setup', requestOptions: { reqData: { path: '/init' }, resReq: true } }]
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
id: 'fetch-a',
|
|
783
|
+
markConcurrentPhase: true, // Concurrent with next
|
|
784
|
+
requests: [{ id: 'data-a', requestOptions: { reqData: { path: '/a' }, resReq: true } }]
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
id: 'fetch-b',
|
|
788
|
+
markConcurrentPhase: true, // Concurrent with fetch-a
|
|
789
|
+
requests: [{ id: 'data-b', requestOptions: { reqData: { path: '/b' }, resReq: true } }]
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
id: 'finalize', // Sequential after fetch-a/b complete
|
|
793
|
+
requests: [{ id: 'done', requestOptions: { reqData: { path: '/finalize' }, resReq: true } }]
|
|
794
|
+
}
|
|
818
795
|
];
|
|
819
796
|
|
|
820
|
-
await stableWorkflow(phases, {
|
|
821
|
-
concurrentPhaseExecution:
|
|
822
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
797
|
+
const result = await stableWorkflow(phases, {
|
|
798
|
+
concurrentPhaseExecution: false // Respects markConcurrentPhase per phase
|
|
823
799
|
});
|
|
824
800
|
```
|
|
825
801
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
{ id: 'users', requestOptions: { reqData: { path: '/users' }, resReq: true } },
|
|
834
|
-
{ id: 'products', requestOptions: { reqData: { path: '/products' }, resReq: true } },
|
|
835
|
-
{ id: 'orders', requestOptions: { reqData: { path: '/orders' }, resReq: true } }
|
|
836
|
-
]
|
|
837
|
-
}
|
|
838
|
-
];
|
|
839
|
-
```
|
|
802
|
+
### Non-Linear Workflows
|
|
803
|
+
|
|
804
|
+
Use decision hooks to dynamically control phase flow.
|
|
805
|
+
|
|
806
|
+
#### CONTINUE
|
|
807
|
+
|
|
808
|
+
Standard flow to next sequential phase.
|
|
840
809
|
|
|
841
|
-
**Stop on First Error**:
|
|
842
810
|
```typescript
|
|
843
|
-
const phases = [
|
|
811
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
812
|
+
{
|
|
813
|
+
id: 'check-status',
|
|
814
|
+
requests: [{ id: 'api', requestOptions: { reqData: { path: '/status' }, resReq: true } }],
|
|
815
|
+
phaseDecisionHook: async ({ phaseResult, sharedBuffer }) => {
|
|
816
|
+
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
817
|
+
}
|
|
818
|
+
},
|
|
844
819
|
{
|
|
845
|
-
id: '
|
|
846
|
-
|
|
847
|
-
requests: [...]
|
|
820
|
+
id: 'process', // Executes after check-status
|
|
821
|
+
requests: [{ id: 'process-data', requestOptions: { reqData: { path: '/process' }, resReq: true } }]
|
|
848
822
|
}
|
|
849
823
|
];
|
|
850
824
|
|
|
851
|
-
await stableWorkflow(phases, {
|
|
852
|
-
|
|
853
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
825
|
+
const result = await stableWorkflow(phases, {
|
|
826
|
+
enableNonLinearExecution: true
|
|
854
827
|
});
|
|
855
828
|
```
|
|
856
829
|
|
|
857
|
-
|
|
830
|
+
#### SKIP
|
|
858
831
|
|
|
859
|
-
|
|
832
|
+
Skip the next phase; execute the one after.
|
|
860
833
|
|
|
861
834
|
```typescript
|
|
862
|
-
const phases = [
|
|
863
|
-
{
|
|
864
|
-
id: '
|
|
865
|
-
requests: [{ id: '
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
markConcurrentPhase: true, // This phase runs concurrently...
|
|
870
|
-
requests: [{ id: 'profile', requestOptions: {...} }]
|
|
835
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
836
|
+
{
|
|
837
|
+
id: 'phase-1',
|
|
838
|
+
requests: [{ id: 'r1', requestOptions: { reqData: { path: '/p1' }, resReq: true } }],
|
|
839
|
+
phaseDecisionHook: async () => ({
|
|
840
|
+
action: PHASE_DECISION_ACTIONS.SKIP
|
|
841
|
+
})
|
|
871
842
|
},
|
|
872
|
-
{
|
|
873
|
-
id: '
|
|
874
|
-
|
|
875
|
-
requests: [{ id: 'orders', requestOptions: {...} }]
|
|
843
|
+
{
|
|
844
|
+
id: 'phase-2', // Skipped
|
|
845
|
+
requests: [{ id: 'r2', requestOptions: { reqData: { path: '/p2' }, resReq: true } }]
|
|
876
846
|
},
|
|
877
|
-
{
|
|
878
|
-
id: '
|
|
879
|
-
requests: [{ id: '
|
|
847
|
+
{
|
|
848
|
+
id: 'phase-3', // Executes
|
|
849
|
+
requests: [{ id: 'r3', requestOptions: { reqData: { path: '/p3' }, resReq: true } }]
|
|
880
850
|
}
|
|
881
851
|
];
|
|
882
852
|
|
|
883
|
-
await stableWorkflow(phases, {
|
|
884
|
-
|
|
885
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
853
|
+
const result = await stableWorkflow(phases, {
|
|
854
|
+
enableNonLinearExecution: true
|
|
886
855
|
});
|
|
887
|
-
```
|
|
888
856
|
|
|
889
|
-
|
|
857
|
+
// Execution: phase-1 → phase-3
|
|
858
|
+
```
|
|
890
859
|
|
|
891
|
-
|
|
860
|
+
#### JUMP
|
|
892
861
|
|
|
893
|
-
|
|
862
|
+
Jump to a specific phase by ID.
|
|
894
863
|
|
|
895
864
|
```typescript
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const phases = [
|
|
865
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
899
866
|
{
|
|
900
|
-
id: '
|
|
901
|
-
requests: [
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if (isValid) {
|
|
908
|
-
// Jump directly to success phase
|
|
909
|
-
return {
|
|
910
|
-
action: PHASE_DECISION_ACTIONS.JUMP,
|
|
911
|
-
targetPhaseId: 'success-flow'
|
|
912
|
-
};
|
|
913
|
-
} else {
|
|
914
|
-
// Continue to retry logic
|
|
915
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
916
|
-
}
|
|
917
|
-
}
|
|
867
|
+
id: 'phase-1',
|
|
868
|
+
requests: [{ id: 'r1', requestOptions: { reqData: { path: '/p1' }, resReq: true } }],
|
|
869
|
+
phaseDecisionHook: async () => ({
|
|
870
|
+
action: PHASE_DECISION_ACTIONS.JUMP,
|
|
871
|
+
targetPhaseId: 'recovery'
|
|
872
|
+
})
|
|
918
873
|
},
|
|
919
874
|
{
|
|
920
|
-
id: '
|
|
921
|
-
|
|
922
|
-
maxReplayCount: 3,
|
|
923
|
-
requests: [
|
|
924
|
-
{ id: 'retry', requestOptions: { reqData: { path: '/retry-validate' }, resReq: true } }
|
|
925
|
-
],
|
|
926
|
-
phaseDecisionHook: async ({ phaseResult, executionHistory }) => {
|
|
927
|
-
const replayCount = executionHistory.filter(
|
|
928
|
-
h => h.phaseId === 'retry-validation'
|
|
929
|
-
).length;
|
|
930
|
-
|
|
931
|
-
const success = phaseResult.responses[0]?.data?.success;
|
|
932
|
-
|
|
933
|
-
if (success) {
|
|
934
|
-
return { action: PHASE_DECISION_ACTIONS.JUMP, targetPhaseId: 'success-flow' };
|
|
935
|
-
} else if (replayCount < 3) {
|
|
936
|
-
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
937
|
-
} else {
|
|
938
|
-
return { action: PHASE_DECISION_ACTIONS.TERMINATE, metadata: { reason: 'Max retries exceeded' } };
|
|
939
|
-
}
|
|
940
|
-
}
|
|
875
|
+
id: 'phase-2', // Skipped
|
|
876
|
+
requests: [{ id: 'r2', requestOptions: { reqData: { path: '/p2' }, resReq: true } }]
|
|
941
877
|
},
|
|
942
878
|
{
|
|
943
|
-
id: '
|
|
944
|
-
requests: [
|
|
945
|
-
{ id: 'finalize', requestOptions: { reqData: { path: '/finalize' }, resReq: true } }
|
|
946
|
-
]
|
|
879
|
+
id: 'recovery',
|
|
880
|
+
requests: [{ id: 'recover', requestOptions: { reqData: { path: '/recovery' }, resReq: true } }]
|
|
947
881
|
}
|
|
948
882
|
];
|
|
949
883
|
|
|
950
884
|
const result = await stableWorkflow(phases, {
|
|
951
|
-
enableNonLinearExecution: true
|
|
952
|
-
workflowId: 'adaptive-validation',
|
|
953
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
885
|
+
enableNonLinearExecution: true
|
|
954
886
|
});
|
|
955
887
|
|
|
956
|
-
|
|
957
|
-
// Array of execution records showing which phases ran and why
|
|
958
|
-
```
|
|
959
|
-
|
|
960
|
-
**Phase Decision Actions**:
|
|
961
|
-
- **CONTINUE**: Proceed to next sequential phase (default)
|
|
962
|
-
- **JUMP**: Skip to a specific phase by ID
|
|
963
|
-
- **SKIP**: Skip upcoming phases until a target phase (or end)
|
|
964
|
-
- **REPLAY**: Re-execute the current phase (requires `allowReplay: true`)
|
|
965
|
-
- **TERMINATE**: Stop the entire workflow immediately
|
|
966
|
-
|
|
967
|
-
**Decision Hook Context**:
|
|
968
|
-
```typescript
|
|
969
|
-
phaseDecisionHook: async ({
|
|
970
|
-
phaseResult, // Current phase execution result
|
|
971
|
-
executionHistory, // Array of all executed phases
|
|
972
|
-
sharedBuffer, // Cross-phase shared state
|
|
973
|
-
concurrentResults // Results from concurrent phases (mixed execution)
|
|
974
|
-
}) => {
|
|
975
|
-
// Your decision logic
|
|
976
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
977
|
-
}
|
|
978
|
-
```
|
|
979
|
-
|
|
980
|
-
**Replay Limits**:
|
|
981
|
-
```typescript
|
|
982
|
-
{
|
|
983
|
-
id: 'polling-phase',
|
|
984
|
-
allowReplay: true,
|
|
985
|
-
maxReplayCount: 10, // Maximum 10 replays
|
|
986
|
-
requests: [...],
|
|
987
|
-
phaseDecisionHook: async ({ phaseResult }) => {
|
|
988
|
-
if (phaseResult.responses[0]?.data?.ready) {
|
|
989
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
990
|
-
}
|
|
991
|
-
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
992
|
-
}
|
|
993
|
-
}
|
|
888
|
+
// Execution: phase-1 → recovery
|
|
994
889
|
```
|
|
995
890
|
|
|
996
|
-
|
|
891
|
+
#### REPLAY
|
|
997
892
|
|
|
998
|
-
|
|
893
|
+
Re-execute current phase; useful for polling.
|
|
999
894
|
|
|
1000
|
-
**Adding Phases Dynamically**:
|
|
1001
895
|
```typescript
|
|
1002
|
-
const phases = [
|
|
896
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1003
897
|
{
|
|
1004
|
-
id: '
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
]
|
|
1018
|
-
};
|
|
898
|
+
id: 'wait-for-job',
|
|
899
|
+
allowReplay: true,
|
|
900
|
+
maxReplayCount: 5,
|
|
901
|
+
requests: [
|
|
902
|
+
{
|
|
903
|
+
id: 'check-job',
|
|
904
|
+
requestOptions: { reqData: { path: '/job/status' }, resReq: true, attempts: 1 }
|
|
905
|
+
}
|
|
906
|
+
],
|
|
907
|
+
phaseDecisionHook: async ({ phaseResult, executionHistory }) => {
|
|
908
|
+
const lastResponse = phaseResult.responses?.[0];
|
|
909
|
+
if ((lastResponse as any)?.data?.status === 'pending' && executionHistory.length < 5) {
|
|
910
|
+
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
1019
911
|
}
|
|
1020
912
|
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1021
913
|
}
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
id: 'process-result',
|
|
917
|
+
requests: [{ id: 'process', requestOptions: { reqData: { path: '/process' }, resReq: true } }]
|
|
1022
918
|
}
|
|
1023
919
|
];
|
|
1024
920
|
|
|
1025
|
-
await stableWorkflow(phases, {
|
|
921
|
+
const result = await stableWorkflow(phases, {
|
|
1026
922
|
enableNonLinearExecution: true,
|
|
1027
|
-
|
|
923
|
+
maxWorkflowIterations: 100
|
|
1028
924
|
});
|
|
925
|
+
|
|
926
|
+
// Polls up to 5 times before continuing
|
|
1029
927
|
```
|
|
1030
928
|
|
|
1031
|
-
|
|
929
|
+
#### TERMINATE
|
|
930
|
+
|
|
931
|
+
Stop workflow early.
|
|
932
|
+
|
|
1032
933
|
```typescript
|
|
1033
|
-
const
|
|
934
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1034
935
|
{
|
|
1035
|
-
id: '
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (requiresAudit) {
|
|
1041
|
-
return {
|
|
1042
|
-
action: PHASE_DECISION_ACTIONS.CONTINUE,
|
|
1043
|
-
addBranches: [
|
|
1044
|
-
{
|
|
1045
|
-
id: 'audit-branch',
|
|
1046
|
-
phases: [{ id: 'audit', requests: [...] }]
|
|
1047
|
-
}
|
|
1048
|
-
]
|
|
1049
|
-
};
|
|
936
|
+
id: 'validate',
|
|
937
|
+
requests: [{ id: 'validate-input', requestOptions: { reqData: { path: '/validate' }, resReq: true } }],
|
|
938
|
+
phaseDecisionHook: async ({ phaseResult, sharedBuffer }) => {
|
|
939
|
+
if (!phaseResult.success) {
|
|
940
|
+
return { action: PHASE_DECISION_ACTIONS.TERMINATE };
|
|
1050
941
|
}
|
|
1051
942
|
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1052
943
|
}
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
id: 'phase-2', // Won't execute if validation fails
|
|
947
|
+
requests: [{ id: 'r2', requestOptions: { reqData: { path: '/p2' }, resReq: true } }]
|
|
1053
948
|
}
|
|
1054
949
|
];
|
|
1055
950
|
|
|
1056
|
-
await stableWorkflow(
|
|
1057
|
-
|
|
1058
|
-
branches,
|
|
1059
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
951
|
+
const result = await stableWorkflow(phases, {
|
|
952
|
+
enableNonLinearExecution: true
|
|
1060
953
|
});
|
|
1061
|
-
```
|
|
1062
954
|
|
|
1063
|
-
|
|
1064
|
-
```typescript
|
|
1065
|
-
branchDecisionHook: async ({ branchResults }) => {
|
|
1066
|
-
return {
|
|
1067
|
-
action: PHASE_DECISION_ACTIONS.CONTINUE,
|
|
1068
|
-
addPhases: [
|
|
1069
|
-
{ id: 'extra-phase', requests: [...] } // Branch re-executes with new phases
|
|
1070
|
-
]
|
|
1071
|
-
};
|
|
1072
|
-
}
|
|
955
|
+
console.log(result.terminatedEarly); // true if TERMINATE triggered
|
|
1073
956
|
```
|
|
1074
957
|
|
|
1075
958
|
### Branched Workflows
|
|
1076
959
|
|
|
1077
|
-
Execute multiple independent
|
|
960
|
+
Execute multiple independent branches with shared state.
|
|
1078
961
|
|
|
1079
962
|
```typescript
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
phases: [
|
|
1085
|
-
{ id: 'fetch-user', requests: [...] },
|
|
1086
|
-
{ id: 'update-user', requests: [...] }
|
|
1087
|
-
]
|
|
1088
|
-
},
|
|
963
|
+
import { stableWorkflow } from '@emmvish/stable-request';
|
|
964
|
+
import type { STABLE_WORKFLOW_BRANCH } from '@emmvish/stable-request';
|
|
965
|
+
|
|
966
|
+
const branches: STABLE_WORKFLOW_BRANCH[] = [
|
|
1089
967
|
{
|
|
1090
|
-
id: '
|
|
1091
|
-
markConcurrentBranch: true, // Execute in parallel
|
|
968
|
+
id: 'branch-payment',
|
|
1092
969
|
phases: [
|
|
1093
|
-
{
|
|
1094
|
-
|
|
970
|
+
{
|
|
971
|
+
id: 'process-payment',
|
|
972
|
+
requests: [
|
|
973
|
+
{
|
|
974
|
+
id: 'charge-card',
|
|
975
|
+
requestOptions: {
|
|
976
|
+
reqData: { path: '/payment/charge' },
|
|
977
|
+
resReq: true
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
]
|
|
981
|
+
}
|
|
1095
982
|
]
|
|
1096
983
|
},
|
|
1097
984
|
{
|
|
1098
|
-
id: '
|
|
985
|
+
id: 'branch-notification',
|
|
1099
986
|
phases: [
|
|
1100
|
-
{
|
|
1101
|
-
|
|
987
|
+
{
|
|
988
|
+
id: 'send-email',
|
|
989
|
+
requests: [
|
|
990
|
+
{
|
|
991
|
+
id: 'send',
|
|
992
|
+
requestOptions: {
|
|
993
|
+
reqData: { path: '/notify/email' },
|
|
994
|
+
resReq: false
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
]
|
|
998
|
+
}
|
|
1102
999
|
]
|
|
1103
1000
|
}
|
|
1104
1001
|
];
|
|
1105
1002
|
|
|
1106
|
-
const result = await stableWorkflow([], {
|
|
1003
|
+
const result = await stableWorkflow([], {
|
|
1004
|
+
workflowId: 'checkout',
|
|
1107
1005
|
enableBranchExecution: true,
|
|
1108
1006
|
branches,
|
|
1109
|
-
|
|
1110
|
-
|
|
1007
|
+
sharedBuffer: { orderId: '12345' },
|
|
1008
|
+
markConcurrentBranch: true // Branches run in parallel
|
|
1111
1009
|
});
|
|
1112
1010
|
|
|
1113
|
-
|
|
1114
|
-
console.log(result.branchExecutionHistory); // Branch-level execution history
|
|
1011
|
+
// Both branches access/modify sharedBuffer
|
|
1115
1012
|
```
|
|
1116
1013
|
|
|
1117
|
-
|
|
1118
|
-
```typescript
|
|
1119
|
-
const branches = [
|
|
1120
|
-
{
|
|
1121
|
-
id: 'high-priority-branch',
|
|
1122
|
-
markConcurrentBranch: false,
|
|
1123
|
-
commonConfig: { // Branch-level config overrides
|
|
1124
|
-
commonAttempts: 5,
|
|
1125
|
-
commonWait: 2000,
|
|
1126
|
-
commonCache: { enabled: true, ttl: 120000 }
|
|
1127
|
-
},
|
|
1128
|
-
phases: [...]
|
|
1129
|
-
}
|
|
1130
|
-
];
|
|
1131
|
-
```
|
|
1014
|
+
### Graph-Based Workflows with Mixed Items
|
|
1132
1015
|
|
|
1133
|
-
|
|
1134
|
-
- Each branch has independent phase execution
|
|
1135
|
-
- Branches share the workflow's `sharedBuffer`
|
|
1136
|
-
- Branch decision hooks can terminate the entire workflow
|
|
1137
|
-
- Supports all execution patterns (mixed, non-linear) within branches
|
|
1016
|
+
For complex topologies with explicit dependencies, use DAG execution mixing requests and functions.
|
|
1138
1017
|
|
|
1139
|
-
**Branch Decision Hooks**:
|
|
1140
1018
|
```typescript
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
id: 'conditional-branch',
|
|
1144
|
-
branchDecisionHook: async ({ branchResult, sharedBuffer }) => {
|
|
1145
|
-
if (branchResult.failedRequests > 0) {
|
|
1146
|
-
return {
|
|
1147
|
-
action: PHASE_DECISION_ACTIONS.TERMINATE,
|
|
1148
|
-
metadata: { reason: 'Critical branch failed' }
|
|
1149
|
-
};
|
|
1150
|
-
}
|
|
1151
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1152
|
-
},
|
|
1153
|
-
phases: [...]
|
|
1154
|
-
}
|
|
1155
|
-
];
|
|
1156
|
-
```
|
|
1157
|
-
|
|
1158
|
-
## Advanced Capabilities
|
|
1159
|
-
|
|
1160
|
-
### Config Cascading
|
|
1019
|
+
import { stableWorkflowGraph, WorkflowGraphBuilder, RequestOrFunction } from '@emmvish/stable-request';
|
|
1020
|
+
import type { API_GATEWAY_ITEM } from '@emmvish/stable-request';
|
|
1161
1021
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
await stableWorkflow(phases, {
|
|
1166
|
-
// Workflow-level config (lowest priority)
|
|
1167
|
-
commonAttempts: 3,
|
|
1168
|
-
commonWait: 1000,
|
|
1169
|
-
commonRetryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
1170
|
-
commonCache: { enabled: true, ttl: 60000 },
|
|
1171
|
-
commonRequestData: {
|
|
1172
|
-
hostname: 'api.example.com',
|
|
1173
|
-
headers: { 'X-API-Version': 'v2' }
|
|
1174
|
-
},
|
|
1175
|
-
|
|
1176
|
-
branches: [{
|
|
1177
|
-
id: 'my-branch',
|
|
1178
|
-
commonConfig: {
|
|
1179
|
-
// Branch-level config (overrides workflow)
|
|
1180
|
-
commonAttempts: 5,
|
|
1181
|
-
commonWait: 500
|
|
1182
|
-
},
|
|
1183
|
-
phases: [{
|
|
1184
|
-
id: 'my-phase',
|
|
1185
|
-
commonConfig: {
|
|
1186
|
-
// Phase-level config (overrides branch and workflow)
|
|
1187
|
-
commonAttempts: 1,
|
|
1188
|
-
commonCache: { enabled: false }
|
|
1189
|
-
},
|
|
1190
|
-
requests: [{
|
|
1191
|
-
id: 'my-request',
|
|
1192
|
-
requestOptions: {
|
|
1193
|
-
// Request-level config (highest priority)
|
|
1194
|
-
reqData: { path: '/critical' },
|
|
1195
|
-
attempts: 10,
|
|
1196
|
-
wait: 100,
|
|
1197
|
-
cache: { enabled: true, ttl: 300000 }
|
|
1198
|
-
}
|
|
1199
|
-
}]
|
|
1200
|
-
}]
|
|
1201
|
-
}]
|
|
1202
|
-
});
|
|
1203
|
-
```
|
|
1022
|
+
// Request types
|
|
1023
|
+
interface PostsRequest {}
|
|
1024
|
+
interface PostsResponse { posts: Array<{ id: number; title: string }> };
|
|
1204
1025
|
|
|
1205
|
-
|
|
1026
|
+
interface UsersRequest {}
|
|
1027
|
+
interface UsersResponse { users: Array<{ id: number; name: string }> };
|
|
1206
1028
|
|
|
1207
|
-
|
|
1029
|
+
// Function types
|
|
1030
|
+
type AggregateArgs = [PostsResponse, UsersResponse];
|
|
1031
|
+
type AggregateResult = {
|
|
1032
|
+
combined: Array<{ userId: number; userName: string; postCount: number }>;
|
|
1033
|
+
};
|
|
1208
1034
|
|
|
1209
|
-
|
|
1035
|
+
type AnalyzeArgs = [AggregateResult];
|
|
1036
|
+
type AnalyzeResult = { totalPosts: number; activeUsers: number };
|
|
1210
1037
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1038
|
+
const graph = new WorkflowGraphBuilder<
|
|
1039
|
+
PostsRequest | UsersRequest,
|
|
1040
|
+
PostsResponse | UsersResponse,
|
|
1041
|
+
AggregateArgs | AnalyzeArgs,
|
|
1042
|
+
AggregateResult | AnalyzeResult
|
|
1043
|
+
>()
|
|
1044
|
+
.addPhase('fetch-posts', {
|
|
1045
|
+
requests: [{
|
|
1046
|
+
id: 'get-posts',
|
|
1047
|
+
requestOptions: {
|
|
1048
|
+
reqData: { path: '/posts' },
|
|
1049
|
+
resReq: true
|
|
1050
|
+
}
|
|
1051
|
+
}]
|
|
1052
|
+
})
|
|
1053
|
+
.addPhase('fetch-users', {
|
|
1054
|
+
requests: [{
|
|
1055
|
+
id: 'get-users',
|
|
1056
|
+
requestOptions: {
|
|
1057
|
+
reqData: { path: '/users' },
|
|
1058
|
+
resReq: true
|
|
1059
|
+
}
|
|
1060
|
+
}]
|
|
1061
|
+
})
|
|
1062
|
+
.addParallelGroup('fetch-all', ['fetch-posts', 'fetch-users'])
|
|
1063
|
+
.addPhase('aggregate', {
|
|
1064
|
+
functions: [{
|
|
1065
|
+
id: 'combine-data',
|
|
1066
|
+
functionOptions: {
|
|
1067
|
+
fn: (posts: PostsResponse, users: UsersResponse): AggregateResult => ({
|
|
1068
|
+
combined: users.users.map(user => ({
|
|
1069
|
+
userId: user.id,
|
|
1070
|
+
userName: user.name,
|
|
1071
|
+
postCount: posts.posts.filter(p => p.id === user.id).length
|
|
1072
|
+
}))
|
|
1073
|
+
}),
|
|
1074
|
+
args: [{ posts: [] }, { users: [] }] as AggregateArgs,
|
|
1075
|
+
returnResult: true
|
|
1076
|
+
}
|
|
1077
|
+
}]
|
|
1078
|
+
})
|
|
1079
|
+
.addPhase('analyze', {
|
|
1080
|
+
functions: [{
|
|
1081
|
+
id: 'analyze-data',
|
|
1082
|
+
functionOptions: {
|
|
1083
|
+
fn: (aggregated: AggregateResult): AnalyzeResult => ({
|
|
1084
|
+
totalPosts: aggregated.combined.reduce((sum, u) => sum + u.postCount, 0),
|
|
1085
|
+
activeUsers: aggregated.combined.filter(u => u.postCount > 0).length
|
|
1086
|
+
}),
|
|
1087
|
+
args: [{ combined: [] }] as AnalyzeArgs,
|
|
1088
|
+
returnResult: true
|
|
1089
|
+
}
|
|
1090
|
+
}]
|
|
1091
|
+
})
|
|
1092
|
+
.addMergePoint('sync', ['fetch-all'])
|
|
1093
|
+
.connectSequence('fetch-all', 'sync', 'aggregate', 'analyze')
|
|
1094
|
+
.setEntryPoint('fetch-all')
|
|
1095
|
+
.build();
|
|
1229
1096
|
|
|
1230
|
-
await
|
|
1231
|
-
|
|
1232
|
-
commonAttempts: 1, // Default: 1 attempt
|
|
1233
|
-
|
|
1234
|
-
requestGroups: [
|
|
1235
|
-
{
|
|
1236
|
-
groupId: 'critical',
|
|
1237
|
-
commonAttempts: 5, // Critical requests: 5 attempts
|
|
1238
|
-
commonWait: 2000,
|
|
1239
|
-
commonRetryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
1240
|
-
commonFinalErrorAnalyzer: async () => false // Never suppress errors
|
|
1241
|
-
},
|
|
1242
|
-
{
|
|
1243
|
-
groupId: 'optional',
|
|
1244
|
-
commonAttempts: 2, // Optional requests: 2 attempts
|
|
1245
|
-
commonWait: 500,
|
|
1246
|
-
commonFinalErrorAnalyzer: async () => true // Suppress errors (return false)
|
|
1247
|
-
}
|
|
1248
|
-
]
|
|
1097
|
+
const result = await stableWorkflowGraph(graph, {
|
|
1098
|
+
workflowId: 'data-aggregation'
|
|
1249
1099
|
});
|
|
1100
|
+
|
|
1101
|
+
console.log(`Graph workflow success: ${result.success}`);
|
|
1250
1102
|
```
|
|
1251
1103
|
|
|
1252
|
-
**
|
|
1253
|
-
-
|
|
1254
|
-
-
|
|
1255
|
-
-
|
|
1104
|
+
**Key responsibilities:**
|
|
1105
|
+
- Define phases as DAG nodes with explicit dependency edges
|
|
1106
|
+
- Execute independent phases in parallel automatically
|
|
1107
|
+
- Support parallel groups, merge points, and conditional routing
|
|
1108
|
+
- Validate graph structure (cycle detection, reachability, orphan detection)
|
|
1109
|
+
- Provide deterministic execution order
|
|
1110
|
+
- Offer higher parallelism than phased workflows for complex topologies
|
|
1111
|
+
|
|
1112
|
+
---
|
|
1256
1113
|
|
|
1257
|
-
|
|
1114
|
+
## Workflow Patterns
|
|
1258
1115
|
|
|
1259
|
-
|
|
1116
|
+
Execute multiple phases concurrently within a group.
|
|
1260
1117
|
|
|
1261
|
-
**Shared Buffer**:
|
|
1262
1118
|
```typescript
|
|
1263
|
-
|
|
1264
|
-
authToken: null,
|
|
1265
|
-
userId: null,
|
|
1266
|
-
metrics: []
|
|
1267
|
-
};
|
|
1119
|
+
import { stableWorkflowGraph, WorkflowGraphBuilder } from '@emmvish/stable-request';
|
|
1268
1120
|
|
|
1269
|
-
const
|
|
1270
|
-
{
|
|
1271
|
-
id: 'auth',
|
|
1121
|
+
const graph = new WorkflowGraphBuilder()
|
|
1122
|
+
.addPhase('fetch-users', {
|
|
1272
1123
|
requests: [{
|
|
1273
|
-
id: '
|
|
1274
|
-
requestOptions: {
|
|
1275
|
-
reqData: { path: '/login', method: REQUEST_METHODS.POST },
|
|
1276
|
-
resReq: true,
|
|
1277
|
-
preExecution: {
|
|
1278
|
-
preExecutionHook: ({ commonBuffer }) => {
|
|
1279
|
-
// Write to buffer after response
|
|
1280
|
-
return {};
|
|
1281
|
-
},
|
|
1282
|
-
preExecutionHookParams: {},
|
|
1283
|
-
applyPreExecutionConfigOverride: false,
|
|
1284
|
-
continueOnPreExecutionHookFailure: false
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1124
|
+
id: 'users',
|
|
1125
|
+
requestOptions: { reqData: { path: '/users' }, resReq: true }
|
|
1287
1126
|
}]
|
|
1288
|
-
}
|
|
1289
|
-
{
|
|
1290
|
-
id: 'fetch-data',
|
|
1127
|
+
})
|
|
1128
|
+
.addPhase('fetch-posts', {
|
|
1291
1129
|
requests: [{
|
|
1292
|
-
id: '
|
|
1293
|
-
requestOptions: {
|
|
1294
|
-
reqData: { path: '/profile' },
|
|
1295
|
-
resReq: true,
|
|
1296
|
-
preExecution: {
|
|
1297
|
-
preExecutionHook: ({ commonBuffer }) => {
|
|
1298
|
-
// Use token from buffer
|
|
1299
|
-
return {
|
|
1300
|
-
reqData: {
|
|
1301
|
-
headers: {
|
|
1302
|
-
'Authorization': `Bearer ${commonBuffer.authToken}`
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
};
|
|
1306
|
-
},
|
|
1307
|
-
applyPreExecutionConfigOverride: true // Apply returned config
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1130
|
+
id: 'posts',
|
|
1131
|
+
requestOptions: { reqData: { path: '/posts' }, resReq: true }
|
|
1310
1132
|
}]
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1133
|
+
})
|
|
1134
|
+
.addPhase('fetch-comments', {
|
|
1135
|
+
requests: [{
|
|
1136
|
+
id: 'comments',
|
|
1137
|
+
requestOptions: { reqData: { path: '/comments' }, resReq: true }
|
|
1138
|
+
}]
|
|
1139
|
+
})
|
|
1140
|
+
.addParallelGroup('data-fetch', ['fetch-users', 'fetch-posts', 'fetch-comments'])
|
|
1141
|
+
.setEntryPoint('data-fetch')
|
|
1142
|
+
.build();
|
|
1313
1143
|
|
|
1314
|
-
await
|
|
1315
|
-
|
|
1316
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
1144
|
+
const result = await stableWorkflowGraph(graph, {
|
|
1145
|
+
workflowId: 'data-aggregation'
|
|
1317
1146
|
});
|
|
1318
1147
|
|
|
1319
|
-
|
|
1148
|
+
// All 3 phases run concurrently
|
|
1320
1149
|
```
|
|
1321
1150
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
- Conditional request configuration (skip, modify, enhance)
|
|
1326
|
-
- Cross-phase state management
|
|
1151
|
+
#### Merge Points
|
|
1152
|
+
|
|
1153
|
+
Synchronize multiple predecessor phases.
|
|
1327
1154
|
|
|
1328
|
-
**Hook Failure Handling**:
|
|
1329
1155
|
```typescript
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1156
|
+
const graph = new WorkflowGraphBuilder()
|
|
1157
|
+
.addPhase('fetch-a', {
|
|
1158
|
+
requests: [{ id: 'a', requestOptions: { reqData: { path: '/a' }, resReq: true } }]
|
|
1159
|
+
})
|
|
1160
|
+
.addPhase('fetch-b', {
|
|
1161
|
+
requests: [{ id: 'b', requestOptions: { reqData: { path: '/b' }, resReq: true } }]
|
|
1162
|
+
})
|
|
1163
|
+
.addMergePoint('sync', ['fetch-a', 'fetch-b'])
|
|
1164
|
+
.addPhase('aggregate', {
|
|
1165
|
+
functions: [{
|
|
1166
|
+
id: 'combine',
|
|
1167
|
+
functionOptions: {
|
|
1168
|
+
fn: () => 'combined',
|
|
1169
|
+
args: [],
|
|
1170
|
+
returnResult: true
|
|
1171
|
+
}
|
|
1172
|
+
}]
|
|
1173
|
+
})
|
|
1174
|
+
.connectSequence('fetch-a', 'sync')
|
|
1175
|
+
.connectSequence('fetch-b', 'sync')
|
|
1176
|
+
.connectSequence('sync', 'aggregate')
|
|
1177
|
+
.setEntryPoint('fetch-a')
|
|
1178
|
+
.build();
|
|
1179
|
+
|
|
1180
|
+
const result = await stableWorkflowGraph(graph, {
|
|
1181
|
+
workflowId: 'parallel-sync'
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
// fetch-a and fetch-b run in parallel
|
|
1185
|
+
// aggregate waits for both to complete
|
|
1340
1186
|
```
|
|
1341
1187
|
|
|
1342
|
-
|
|
1188
|
+
#### Linear Helper
|
|
1343
1189
|
|
|
1344
|
-
|
|
1190
|
+
Convenience function for sequential phase chains.
|
|
1345
1191
|
|
|
1346
1192
|
```typescript
|
|
1193
|
+
import { createLinearWorkflowGraph } from '@emmvish/stable-request';
|
|
1194
|
+
|
|
1347
1195
|
const phases = [
|
|
1348
1196
|
{
|
|
1349
|
-
id: '
|
|
1350
|
-
requests: [
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1197
|
+
id: 'init',
|
|
1198
|
+
requests: [{ id: 'setup', requestOptions: { reqData: { path: '/init' }, resReq: true } }]
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
id: 'process',
|
|
1202
|
+
requests: [{ id: 'do-work', requestOptions: { reqData: { path: '/work' }, resReq: true } }]
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
id: 'finalize',
|
|
1206
|
+
requests: [{ id: 'cleanup', requestOptions: { reqData: { path: '/cleanup' }, resReq: true } }]
|
|
1358
1207
|
}
|
|
1359
1208
|
];
|
|
1209
|
+
|
|
1210
|
+
const graph = createLinearWorkflowGraph(phases);
|
|
1211
|
+
|
|
1212
|
+
const result = await stableWorkflowGraph(graph, {
|
|
1213
|
+
workflowId: 'linear-workflow'
|
|
1214
|
+
});
|
|
1360
1215
|
```
|
|
1361
1216
|
|
|
1362
|
-
|
|
1217
|
+
---
|
|
1218
|
+
|
|
1219
|
+
## Configuration & State
|
|
1220
|
+
|
|
1221
|
+
### Config Cascading
|
|
1363
1222
|
|
|
1364
|
-
|
|
1223
|
+
Define defaults globally; override at group, phase, branch, or item level.
|
|
1365
1224
|
|
|
1366
1225
|
```typescript
|
|
1367
|
-
|
|
1226
|
+
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1227
|
+
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1228
|
+
|
|
1229
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1368
1230
|
{
|
|
1369
|
-
id: '
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1231
|
+
id: 'phase-1',
|
|
1232
|
+
attempts: 5, // Override global attempts for this phase
|
|
1233
|
+
wait: 1000,
|
|
1234
|
+
requests: [
|
|
1235
|
+
{
|
|
1236
|
+
id: 'req-1',
|
|
1237
|
+
requestOptions: {
|
|
1238
|
+
reqData: { path: '/data' },
|
|
1239
|
+
resReq: true,
|
|
1240
|
+
attempts: 2 // Override phase attempts for this item
|
|
1377
1241
|
}
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
}
|
|
1242
|
+
}
|
|
1243
|
+
]
|
|
1381
1244
|
}
|
|
1382
1245
|
];
|
|
1246
|
+
|
|
1247
|
+
const result = await stableWorkflow(phases, {
|
|
1248
|
+
workflowId: 'cascade-demo',
|
|
1249
|
+
commonAttempts: 1, // Global default
|
|
1250
|
+
commonWait: 500,
|
|
1251
|
+
retryStrategy: 'LINEAR' // Global default
|
|
1252
|
+
// Final config per item: merge common → phase → request
|
|
1253
|
+
});
|
|
1383
1254
|
```
|
|
1384
1255
|
|
|
1385
|
-
|
|
1256
|
+
Hierarchy: global → group → phase → branch → item. Lower levels override.
|
|
1386
1257
|
|
|
1387
|
-
|
|
1258
|
+
### Shared & State Buffers
|
|
1388
1259
|
|
|
1389
|
-
|
|
1390
|
-
The persistence function operates in two modes:
|
|
1391
|
-
- **LOAD Mode**: When `buffer` is empty/null, return the stored state
|
|
1392
|
-
- **STORE Mode**: When `buffer` contains data, save it to your storage
|
|
1260
|
+
Pass mutable state across phases, branches, and items.
|
|
1393
1261
|
|
|
1394
|
-
|
|
1262
|
+
#### Shared Buffer (Workflow/Gateway)
|
|
1395
1263
|
|
|
1396
1264
|
```typescript
|
|
1397
|
-
import
|
|
1398
|
-
|
|
1399
|
-
const redis = new Redis();
|
|
1265
|
+
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1266
|
+
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1400
1267
|
|
|
1401
|
-
const
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
// STORE MODE: Save with metadata
|
|
1416
|
-
const stateWithMeta = {
|
|
1417
|
-
...buffer,
|
|
1418
|
-
_meta: {
|
|
1419
|
-
timestamp: new Date().toISOString(),
|
|
1420
|
-
version: (buffer._meta?.version || 0) + 1
|
|
1268
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1269
|
+
{
|
|
1270
|
+
id: 'fetch',
|
|
1271
|
+
requests: [
|
|
1272
|
+
{
|
|
1273
|
+
id: 'user-data',
|
|
1274
|
+
requestOptions: {
|
|
1275
|
+
reqData: { path: '/users/1' },
|
|
1276
|
+
resReq: true,
|
|
1277
|
+
handleSuccessfulAttemptData: ({ successfulAttemptData, stableRequestOptions }) => {
|
|
1278
|
+
// Mutate shared buffer
|
|
1279
|
+
const sharedBuffer = (stableRequestOptions as any).sharedBuffer;
|
|
1280
|
+
sharedBuffer.userId = (successfulAttemptData.data as any).id;
|
|
1281
|
+
}
|
|
1421
1282
|
}
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1283
|
+
}
|
|
1284
|
+
]
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
id: 'use-shared-data',
|
|
1288
|
+
requests: [
|
|
1289
|
+
{
|
|
1290
|
+
id: 'dependent-call',
|
|
1291
|
+
requestOptions: {
|
|
1292
|
+
reqData: { path: '/user-posts' },
|
|
1293
|
+
resReq: true,
|
|
1294
|
+
preExecution: {
|
|
1295
|
+
preExecutionHook: async ({ stableRequestOptions, commonBuffer }) => {
|
|
1296
|
+
const sharedBuffer = (stableRequestOptions as any).sharedBuffer;
|
|
1297
|
+
console.log(`Using userId: ${sharedBuffer.userId}`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
]
|
|
1434
1303
|
}
|
|
1435
|
-
|
|
1436
|
-
return {};
|
|
1437
|
-
};
|
|
1304
|
+
];
|
|
1438
1305
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
commonStatePersistence: {
|
|
1443
|
-
persistenceFunction: persistToRedis,
|
|
1444
|
-
persistenceParams: {
|
|
1445
|
-
ttl: 3600,
|
|
1446
|
-
enableLocking: true // Enable distributed locking
|
|
1447
|
-
},
|
|
1448
|
-
loadBeforeHooks: true,
|
|
1449
|
-
storeAfterHooks: true
|
|
1450
|
-
},
|
|
1451
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
1306
|
+
const result = await stableWorkflow(phases, {
|
|
1307
|
+
workflowId: 'shared-state-demo',
|
|
1308
|
+
sharedBuffer: {} // Mutable across phases
|
|
1452
1309
|
});
|
|
1453
1310
|
```
|
|
1454
1311
|
|
|
1455
|
-
|
|
1312
|
+
#### Common Buffer (Request Level)
|
|
1456
1313
|
|
|
1457
1314
|
```typescript
|
|
1458
|
-
|
|
1459
|
-
const { workflowId } = executionContext;
|
|
1460
|
-
const checkpointKey = `checkpoint:${workflowId}`;
|
|
1461
|
-
|
|
1462
|
-
if (buffer && Object.keys(buffer).length > 0) {
|
|
1463
|
-
// STORE: Save checkpoint with completed phases
|
|
1464
|
-
const existing = JSON.parse(await redis.get(checkpointKey) || '{}');
|
|
1465
|
-
const checkpoint = {
|
|
1466
|
-
...existing,
|
|
1467
|
-
completedPhases: [...new Set([
|
|
1468
|
-
...(existing.completedPhases || []),
|
|
1469
|
-
...(buffer.completedPhases || [])
|
|
1470
|
-
])],
|
|
1471
|
-
progress: buffer.progress || existing.progress || 0,
|
|
1472
|
-
lastUpdated: new Date().toISOString()
|
|
1473
|
-
};
|
|
1474
|
-
await redis.setex(checkpointKey, 7200, JSON.stringify(checkpoint));
|
|
1475
|
-
} else {
|
|
1476
|
-
// LOAD: Return checkpoint data
|
|
1477
|
-
const data = await redis.get(checkpointKey);
|
|
1478
|
-
return data ? JSON.parse(data) : { completedPhases: [] };
|
|
1479
|
-
}
|
|
1480
|
-
return {};
|
|
1481
|
-
};
|
|
1315
|
+
import { stableRequest } from '@emmvish/stable-request';
|
|
1482
1316
|
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
skipToPhaseId: 'phase-2'
|
|
1494
|
-
};
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
if (phaseResult.success) {
|
|
1498
|
-
sharedBuffer.completedPhases = [
|
|
1499
|
-
...(sharedBuffer.completedPhases || []),
|
|
1500
|
-
'phase-1'
|
|
1501
|
-
];
|
|
1502
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1503
|
-
}
|
|
1504
|
-
return { action: PHASE_DECISION_ACTIONS.TERMINATE };
|
|
1317
|
+
const commonBuffer = { transactionId: null };
|
|
1318
|
+
|
|
1319
|
+
const result = await stableRequest({
|
|
1320
|
+
reqData: { path: '/transaction/start' },
|
|
1321
|
+
resReq: true,
|
|
1322
|
+
commonBuffer,
|
|
1323
|
+
preExecution: {
|
|
1324
|
+
preExecutionHook: async ({ commonBuffer, stableRequestOptions }) => {
|
|
1325
|
+
// commonBuffer writable here
|
|
1326
|
+
commonBuffer.userId = '123';
|
|
1505
1327
|
}
|
|
1506
1328
|
},
|
|
1507
|
-
{
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
phaseDecisionHook: async ({ phaseResult, sharedBuffer }) => {
|
|
1511
|
-
if (sharedBuffer.completedPhases?.includes('phase-2')) {
|
|
1512
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1513
|
-
}
|
|
1514
|
-
if (phaseResult.success) {
|
|
1515
|
-
sharedBuffer.completedPhases = [
|
|
1516
|
-
...(sharedBuffer.completedPhases || []),
|
|
1517
|
-
'phase-2'
|
|
1518
|
-
];
|
|
1519
|
-
}
|
|
1520
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1521
|
-
}
|
|
1329
|
+
handleSuccessfulAttemptData: ({ successfulAttemptData }) => {
|
|
1330
|
+
// commonBuffer readable in handlers
|
|
1331
|
+
console.log(`Transaction for user ${commonBuffer.userId} done`);
|
|
1522
1332
|
}
|
|
1523
|
-
];
|
|
1524
|
-
|
|
1525
|
-
await stableWorkflow(phases, {
|
|
1526
|
-
workflowId: 'resumable-workflow-789',
|
|
1527
|
-
enableNonLinearExecution: true,
|
|
1528
|
-
sharedBuffer: { completedPhases: [] },
|
|
1529
|
-
commonStatePersistence: {
|
|
1530
|
-
persistenceFunction: createCheckpoint,
|
|
1531
|
-
persistenceParams: { ttl: 7200 },
|
|
1532
|
-
loadBeforeHooks: true,
|
|
1533
|
-
storeAfterHooks: true
|
|
1534
|
-
},
|
|
1535
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
1536
1333
|
});
|
|
1537
1334
|
```
|
|
1538
1335
|
|
|
1539
|
-
|
|
1336
|
+
---
|
|
1540
1337
|
|
|
1541
|
-
|
|
1338
|
+
## Hooks & Observability
|
|
1339
|
+
|
|
1340
|
+
### Pre-Execution Hooks
|
|
1341
|
+
|
|
1342
|
+
Modify config or state before execution.
|
|
1542
1343
|
|
|
1543
|
-
**Request-Level Hooks**:
|
|
1544
1344
|
```typescript
|
|
1545
|
-
|
|
1546
|
-
|
|
1345
|
+
import { stableRequest } from '@emmvish/stable-request';
|
|
1346
|
+
|
|
1347
|
+
interface SecureRequest {}
|
|
1348
|
+
interface SecureResponse {
|
|
1349
|
+
data: any;
|
|
1350
|
+
token?: string;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const result = await stableRequest<SecureRequest, SecureResponse>({
|
|
1354
|
+
reqData: { path: '/secure-data' },
|
|
1547
1355
|
resReq: true,
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
console.log('Request succeeded:', successfulAttemptData);
|
|
1565
|
-
},
|
|
1566
|
-
|
|
1567
|
-
// Analyze final error after all retries
|
|
1568
|
-
finalErrorAnalyzer: async ({ error, reqData }) => {
|
|
1569
|
-
console.error('All retries exhausted:', error);
|
|
1570
|
-
return error.message.includes('404'); // true = return false instead of throw
|
|
1571
|
-
},
|
|
1572
|
-
|
|
1573
|
-
// Pass custom parameters to hooks
|
|
1574
|
-
hookParams: {
|
|
1575
|
-
responseAnalyzerParams: { expectedFormat: 'json' },
|
|
1576
|
-
handleErrorsParams: { alertChannel: 'slack' }
|
|
1577
|
-
},
|
|
1578
|
-
|
|
1579
|
-
logAllErrors: true,
|
|
1580
|
-
logAllSuccessfulAttempts: true
|
|
1356
|
+
preExecution: {
|
|
1357
|
+
preExecutionHook: async ({ inputParams, commonBuffer, stableRequestOptions }) => {
|
|
1358
|
+
// Dynamically fetch auth token
|
|
1359
|
+
const token = await getAuthToken();
|
|
1360
|
+
|
|
1361
|
+
// Return partial config override
|
|
1362
|
+
return {
|
|
1363
|
+
reqData: {
|
|
1364
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
},
|
|
1368
|
+
preExecutionHookParams: { context: 'auth-fetch' },
|
|
1369
|
+
applyPreExecutionConfigOverride: true,
|
|
1370
|
+
continueOnPreExecutionHookFailure: false
|
|
1371
|
+
}
|
|
1581
1372
|
});
|
|
1582
1373
|
```
|
|
1583
1374
|
|
|
1584
|
-
|
|
1375
|
+
### Analysis Hooks
|
|
1376
|
+
|
|
1377
|
+
Validate responses and errors.
|
|
1378
|
+
|
|
1379
|
+
#### Response Analyzer
|
|
1380
|
+
|
|
1585
1381
|
```typescript
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
// Monitor non-linear execution decisions
|
|
1605
|
-
handlePhaseDecision: async ({ decision, phaseResult }) => {
|
|
1606
|
-
console.log(`Phase decision: ${decision.action}`);
|
|
1607
|
-
if (decision.targetPhaseId) {
|
|
1608
|
-
console.log(`Target: ${decision.targetPhaseId}`);
|
|
1609
|
-
}
|
|
1610
|
-
},
|
|
1611
|
-
|
|
1612
|
-
// Monitor branch completion
|
|
1613
|
-
handleBranchCompletion: async ({ workflowId, branchResult }) => {
|
|
1614
|
-
console.log(`Branch ${branchResult.branchId} completed`);
|
|
1615
|
-
},
|
|
1616
|
-
|
|
1617
|
-
// Monitor branch decisions
|
|
1618
|
-
handleBranchDecision: async ({ workflowId, branchId, branchResults, success }) => {
|
|
1619
|
-
console.log(`Branch ID: ${branchId}`);
|
|
1620
|
-
},
|
|
1621
|
-
|
|
1622
|
-
// Pass parameters to workflow hooks
|
|
1623
|
-
workflowHookParams: {
|
|
1624
|
-
handlePhaseCompletionParams: { environment: 'production' },
|
|
1625
|
-
handlePhaseErrorParams: { severity: 'high' }
|
|
1626
|
-
},
|
|
1627
|
-
|
|
1628
|
-
logPhaseResults: true,
|
|
1629
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
1382
|
+
import { stableRequest } from '@emmvish/stable-request';
|
|
1383
|
+
|
|
1384
|
+
interface ResourceRequest {}
|
|
1385
|
+
interface ApiResponse {
|
|
1386
|
+
id: number;
|
|
1387
|
+
status: 'active' | 'inactive';
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const result = await stableRequest<ResourceRequest, ApiResponse>({
|
|
1391
|
+
reqData: { path: '/resource' },
|
|
1392
|
+
resReq: true,
|
|
1393
|
+
responseAnalyzer: ({ data, reqData, trialMode }) => {
|
|
1394
|
+
// Return true to accept, false to retry
|
|
1395
|
+
if (!data || typeof data !== 'object') return false;
|
|
1396
|
+
if (!('id' in data)) return false;
|
|
1397
|
+
if ((data as any).status !== 'active') return false;
|
|
1398
|
+
return true;
|
|
1399
|
+
}
|
|
1630
1400
|
});
|
|
1631
1401
|
```
|
|
1632
1402
|
|
|
1633
|
-
|
|
1403
|
+
#### Error Analyzer
|
|
1404
|
+
|
|
1405
|
+
Decide whether to suppress error gracefully.
|
|
1406
|
+
|
|
1634
1407
|
```typescript
|
|
1635
|
-
|
|
1636
|
-
enableNonLinearExecution: true,
|
|
1637
|
-
workflowId: 'tracked-workflow',
|
|
1638
|
-
commonRequestData: { hostname: 'api.example.com' }
|
|
1639
|
-
});
|
|
1408
|
+
import { stableRequest } from '@emmvish/stable-request';
|
|
1640
1409
|
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
decision: record.decision,
|
|
1647
|
-
timestamp: record.timestamp,
|
|
1648
|
-
metadata: record.metadata
|
|
1649
|
-
});
|
|
1650
|
-
});
|
|
1410
|
+
interface FeatureRequest {}
|
|
1411
|
+
interface FeatureResponse {
|
|
1412
|
+
enabled: boolean;
|
|
1413
|
+
data?: any;
|
|
1414
|
+
}
|
|
1651
1415
|
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1416
|
+
const result = await stableRequest<FeatureRequest, FeatureResponse>({
|
|
1417
|
+
reqData: { path: '/optional-feature' },
|
|
1418
|
+
resReq: true,
|
|
1419
|
+
finalErrorAnalyzer: ({ error, reqData, trialMode }) => {
|
|
1420
|
+
// Return true to suppress error and return failure result
|
|
1421
|
+
// Return false to throw error
|
|
1422
|
+
if (error.code === 'ECONNREFUSED') {
|
|
1423
|
+
console.warn('Service unavailable, continuing with fallback');
|
|
1424
|
+
return true; // Suppress, don't throw
|
|
1425
|
+
}
|
|
1426
|
+
return false; // Throw
|
|
1427
|
+
}
|
|
1659
1428
|
});
|
|
1429
|
+
|
|
1430
|
+
if (result.success) {
|
|
1431
|
+
console.log('Got data:', result.data);
|
|
1432
|
+
} else {
|
|
1433
|
+
console.log('Service offline, but we continue');
|
|
1434
|
+
}
|
|
1660
1435
|
```
|
|
1661
1436
|
|
|
1662
|
-
###
|
|
1437
|
+
### Handler Hooks
|
|
1663
1438
|
|
|
1664
|
-
|
|
1439
|
+
Custom logging and processing.
|
|
1440
|
+
|
|
1441
|
+
#### Success Handler
|
|
1665
1442
|
|
|
1666
1443
|
```typescript
|
|
1667
|
-
|
|
1668
|
-
|
|
1444
|
+
import { stableRequest } from '@emmvish/stable-request';
|
|
1445
|
+
|
|
1446
|
+
interface DataRequest {}
|
|
1447
|
+
interface DataResponse {
|
|
1448
|
+
id: number;
|
|
1449
|
+
value: string;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
1453
|
+
reqData: { path: '/data' },
|
|
1669
1454
|
resReq: true,
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1455
|
+
logAllSuccessfulAttempts: true,
|
|
1456
|
+
handleSuccessfulAttemptData: ({
|
|
1457
|
+
successfulAttemptData,
|
|
1458
|
+
reqData,
|
|
1459
|
+
maxSerializableChars,
|
|
1460
|
+
executionContext
|
|
1461
|
+
}) => {
|
|
1462
|
+
// Custom logging, metrics, state updates
|
|
1463
|
+
console.log(
|
|
1464
|
+
`Success in context ${executionContext.workflowId}`,
|
|
1465
|
+
`data:`,
|
|
1466
|
+
successfulAttemptData.data
|
|
1467
|
+
);
|
|
1676
1468
|
}
|
|
1677
1469
|
});
|
|
1678
1470
|
```
|
|
1679
1471
|
|
|
1680
|
-
|
|
1681
|
-
- Test retry logic without hitting APIs
|
|
1682
|
-
- Simulate failure scenarios
|
|
1683
|
-
- Load testing with controlled failure rates
|
|
1684
|
-
- Development without backend dependencies
|
|
1472
|
+
#### Error Handler
|
|
1685
1473
|
|
|
1686
|
-
|
|
1474
|
+
```typescript
|
|
1475
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
1476
|
+
reqData: { path: '/data' },
|
|
1477
|
+
resReq: true,
|
|
1478
|
+
logAllErrors: true,
|
|
1479
|
+
handleErrors: ({ errorLog, reqData, executionContext }) => {
|
|
1480
|
+
// Custom error logging, alerting, retry logic
|
|
1481
|
+
console.error(
|
|
1482
|
+
`Error in ${executionContext.workflowId}:`,
|
|
1483
|
+
errorLog.errorMessage,
|
|
1484
|
+
`Retryable: ${errorLog.isRetryable}`
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
```
|
|
1687
1489
|
|
|
1688
|
-
|
|
1490
|
+
#### Phase Handlers (Workflow)
|
|
1689
1491
|
|
|
1690
1492
|
```typescript
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
requests: [
|
|
1696
|
-
{ id: 'users', requestOptions: { reqData: { path: '/source/users' }, resReq: true } },
|
|
1697
|
-
{ id: 'orders', requestOptions: { reqData: { path: '/source/orders' }, resReq: true } }
|
|
1698
|
-
]
|
|
1699
|
-
},
|
|
1700
|
-
{
|
|
1701
|
-
id: 'transform-data',
|
|
1702
|
-
requests: [
|
|
1703
|
-
{
|
|
1704
|
-
id: 'transform',
|
|
1705
|
-
requestOptions: {
|
|
1706
|
-
reqData: { path: '/transform', method: REQUEST_METHODS.POST },
|
|
1707
|
-
resReq: true
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
]
|
|
1711
|
-
},
|
|
1493
|
+
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1494
|
+
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1495
|
+
|
|
1496
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1712
1497
|
{
|
|
1713
|
-
id: '
|
|
1714
|
-
|
|
1715
|
-
requests: [
|
|
1716
|
-
{ id: 'upload-users', requestOptions: { reqData: { path: '/dest/users', method: REQUEST_METHODS.POST }, resReq: false } },
|
|
1717
|
-
{ id: 'upload-orders', requestOptions: { reqData: { path: '/dest/orders', method: REQUEST_METHODS.POST }, resReq: false } }
|
|
1718
|
-
]
|
|
1498
|
+
id: 'phase-1',
|
|
1499
|
+
requests: [{ id: 'r1', requestOptions: { reqData: { path: '/data' }, resReq: true } }]
|
|
1719
1500
|
}
|
|
1720
1501
|
];
|
|
1721
1502
|
|
|
1722
|
-
await stableWorkflow(
|
|
1723
|
-
workflowId: '
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1503
|
+
const result = await stableWorkflow(phases, {
|
|
1504
|
+
workflowId: 'wf-handlers',
|
|
1505
|
+
handlePhaseCompletion: ({ phaseResult, workflowId }) => {
|
|
1506
|
+
console.log(`Phase ${phaseResult.phaseId} complete in ${workflowId}`);
|
|
1507
|
+
},
|
|
1508
|
+
handlePhaseError: ({ phaseResult, error, workflowId }) => {
|
|
1509
|
+
console.error(`Phase ${phaseResult.phaseId} failed:`, error);
|
|
1510
|
+
},
|
|
1511
|
+
handlePhaseDecision: ({ decision, phaseResult }) => {
|
|
1512
|
+
console.log(`Phase decision: ${decision.action}`);
|
|
1513
|
+
}
|
|
1728
1514
|
});
|
|
1729
1515
|
```
|
|
1730
1516
|
|
|
1731
|
-
###
|
|
1517
|
+
### Decision Hooks
|
|
1518
|
+
|
|
1519
|
+
Dynamically determine workflow flow.
|
|
1732
1520
|
|
|
1733
1521
|
```typescript
|
|
1734
|
-
|
|
1522
|
+
import { stableWorkflow, PHASE_DECISION_ACTIONS } from '@emmvish/stable-request';
|
|
1523
|
+
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1524
|
+
|
|
1525
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1735
1526
|
{
|
|
1736
|
-
id: '
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
finalErrorAnalyzer: async ({ error }) => {
|
|
1742
|
-
// If primary fails, mark as handled (don't throw)
|
|
1743
|
-
return true;
|
|
1527
|
+
id: 'fetch-data',
|
|
1528
|
+
requests: [{ id: 'api', requestOptions: { reqData: { path: '/data' }, resReq: true } }],
|
|
1529
|
+
phaseDecisionHook: async ({ phaseResult, sharedBuffer, executionHistory }) => {
|
|
1530
|
+
if (!phaseResult.success) {
|
|
1531
|
+
return { action: PHASE_DECISION_ACTIONS.TERMINATE };
|
|
1744
1532
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
groupId: 'fallback',
|
|
1750
|
-
requestOptions: {
|
|
1751
|
-
reqData: { hostname: 'backup.api.com', path: '/data' },
|
|
1752
|
-
resReq: true
|
|
1533
|
+
if (phaseResult.responses[0].data?.needsRetry) {
|
|
1534
|
+
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
1535
|
+
}
|
|
1536
|
+
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1753
1537
|
}
|
|
1754
1538
|
}
|
|
1755
1539
|
];
|
|
1756
1540
|
|
|
1757
|
-
const
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1541
|
+
const result = await stableWorkflow(phases, {
|
|
1542
|
+
enableNonLinearExecution: true
|
|
1543
|
+
});
|
|
1544
|
+
```
|
|
1545
|
+
|
|
1546
|
+
### Metrics & Logging
|
|
1547
|
+
|
|
1548
|
+
Automatic metrics collection across all execution modes.
|
|
1549
|
+
|
|
1550
|
+
#### Request Metrics
|
|
1551
|
+
|
|
1552
|
+
```typescript
|
|
1553
|
+
import { stableRequest } from '@emmvish/stable-request';
|
|
1554
|
+
|
|
1555
|
+
interface DataRequest {}
|
|
1556
|
+
interface DataResponse { data: any; }
|
|
1557
|
+
|
|
1558
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
1559
|
+
reqData: { path: '/data' },
|
|
1560
|
+
resReq: true,
|
|
1561
|
+
attempts: 3
|
|
1763
1562
|
});
|
|
1563
|
+
|
|
1564
|
+
console.log(result.metrics); // {
|
|
1565
|
+
// totalAttempts: 2,
|
|
1566
|
+
// successfulAttempts: 1,
|
|
1567
|
+
// failedAttempts: 1,
|
|
1568
|
+
// totalExecutionTime: 450,
|
|
1569
|
+
// averageAttemptTime: 225,
|
|
1570
|
+
// infrastructureMetrics: {
|
|
1571
|
+
// circuitBreaker: { /* state, stats, config */ },
|
|
1572
|
+
// cache: { /* hits, misses, size */ },
|
|
1573
|
+
// rateLimiter: { /* limit, current rate */ },
|
|
1574
|
+
// concurrencyLimiter: { /* limit, in-flight */ }
|
|
1575
|
+
// }
|
|
1576
|
+
// }
|
|
1764
1577
|
```
|
|
1765
1578
|
|
|
1766
|
-
|
|
1579
|
+
#### Workflow Metrics
|
|
1767
1580
|
|
|
1768
1581
|
```typescript
|
|
1769
|
-
|
|
1582
|
+
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1583
|
+
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1584
|
+
|
|
1585
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1586
|
+
{ id: 'p1', requests: [{ id: 'r1', requestOptions: { reqData: { path: '/a' }, resReq: true } }] },
|
|
1587
|
+
{ id: 'p2', requests: [{ id: 'r2', requestOptions: { reqData: { path: '/b' }, resReq: true } }] }
|
|
1588
|
+
];
|
|
1589
|
+
|
|
1590
|
+
const result = await stableWorkflow(phases, {
|
|
1591
|
+
workflowId: 'wf-metrics'
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
console.log(result); // {
|
|
1595
|
+
// workflowId: 'wf-metrics',
|
|
1596
|
+
// success: true,
|
|
1597
|
+
// totalPhases: 2,
|
|
1598
|
+
// completedPhases: 2,
|
|
1599
|
+
// totalRequests: 2,
|
|
1600
|
+
// successfulRequests: 2,
|
|
1601
|
+
// failedRequests: 0,
|
|
1602
|
+
// workflowExecutionTime: 1200,
|
|
1603
|
+
// phases: [
|
|
1604
|
+
// { phaseId: 'p1', success: true, responses: [...], ... },
|
|
1605
|
+
// { phaseId: 'p2', success: true, responses: [...], ... }
|
|
1606
|
+
// ]
|
|
1607
|
+
// }
|
|
1608
|
+
```
|
|
1609
|
+
|
|
1610
|
+
#### Structured Error Logs
|
|
1611
|
+
|
|
1612
|
+
```typescript
|
|
1613
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
1614
|
+
reqData: { path: '/flaky' },
|
|
1615
|
+
resReq: true,
|
|
1616
|
+
attempts: 3,
|
|
1617
|
+
logAllErrors: true,
|
|
1618
|
+
handleErrors: ({ errorLog }) => {
|
|
1619
|
+
console.log(errorLog); // {
|
|
1620
|
+
// attempt: '1/3',
|
|
1621
|
+
// type: 'NetworkError',
|
|
1622
|
+
// error: 'ECONNREFUSED',
|
|
1623
|
+
// isRetryable: true,
|
|
1624
|
+
// timestamp: 1234567890
|
|
1625
|
+
// }
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
if (result.errorLogs) {
|
|
1630
|
+
console.log(`${result.errorLogs.length} errors logged`);
|
|
1631
|
+
}
|
|
1632
|
+
```
|
|
1633
|
+
|
|
1634
|
+
---
|
|
1635
|
+
|
|
1636
|
+
## Advanced Features
|
|
1637
|
+
|
|
1638
|
+
### Trial Mode
|
|
1639
|
+
|
|
1640
|
+
Dry-run workflows without side effects; simulate failures.
|
|
1641
|
+
|
|
1642
|
+
```typescript
|
|
1643
|
+
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1644
|
+
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1645
|
+
|
|
1646
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1770
1647
|
{
|
|
1771
|
-
id: '
|
|
1772
|
-
allowReplay: true,
|
|
1773
|
-
maxReplayCount: 20,
|
|
1648
|
+
id: 'process',
|
|
1774
1649
|
requests: [
|
|
1775
|
-
{
|
|
1776
|
-
id: '
|
|
1777
|
-
requestOptions: {
|
|
1778
|
-
reqData: { path: '/
|
|
1779
|
-
resReq: true
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
const attempts = executionHistory.filter(h => h.phaseId === 'poll-job-status').length;
|
|
1786
|
-
|
|
1787
|
-
if (status === 'completed') {
|
|
1788
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1789
|
-
} else if (status === 'failed') {
|
|
1790
|
-
return { action: PHASE_DECISION_ACTIONS.TERMINATE, metadata: { reason: 'Job failed' } };
|
|
1791
|
-
} else if (attempts < 20) {
|
|
1792
|
-
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
1793
|
-
} else {
|
|
1794
|
-
return { action: PHASE_DECISION_ACTIONS.TERMINATE, metadata: { reason: 'Timeout' } };
|
|
1650
|
+
{
|
|
1651
|
+
id: 'api-call',
|
|
1652
|
+
requestOptions: {
|
|
1653
|
+
reqData: { path: '/payment/charge' },
|
|
1654
|
+
resReq: true,
|
|
1655
|
+
trialMode: {
|
|
1656
|
+
enabled: true,
|
|
1657
|
+
requestFailureProbability: 0.3 // 30% simulated failure rate
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1795
1660
|
}
|
|
1796
|
-
}
|
|
1797
|
-
},
|
|
1798
|
-
{
|
|
1799
|
-
id: 'process-results',
|
|
1800
|
-
requests: [
|
|
1801
|
-
{ id: 'fetch-results', requestOptions: { reqData: { path: '/job/results' }, resReq: true } }
|
|
1802
1661
|
]
|
|
1803
1662
|
}
|
|
1804
1663
|
];
|
|
1805
1664
|
|
|
1806
|
-
await stableWorkflow(
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1665
|
+
const result = await stableWorkflow(phases, {
|
|
1666
|
+
workflowId: 'payment-trial',
|
|
1667
|
+
trialMode: {
|
|
1668
|
+
enabled: true,
|
|
1669
|
+
functionFailureProbability: 0.2
|
|
1670
|
+
}
|
|
1810
1671
|
});
|
|
1672
|
+
|
|
1673
|
+
// Requests/functions execute but failures are simulated
|
|
1674
|
+
// Real API calls happen; real side effects occur only if enabled
|
|
1675
|
+
// Useful for testing retry logic, decision hooks, workflow topology
|
|
1811
1676
|
```
|
|
1812
1677
|
|
|
1813
|
-
###
|
|
1678
|
+
### State Persistence
|
|
1679
|
+
|
|
1680
|
+
Persist state across retry attempts for distributed tracing.
|
|
1814
1681
|
|
|
1815
1682
|
```typescript
|
|
1816
|
-
import {
|
|
1683
|
+
import { stableRequest } from '@emmvish/stable-request';
|
|
1817
1684
|
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
minimumRequests: 5, // Minimum 5 requests before evaluation
|
|
1821
|
-
recoveryTimeoutMs: 30000, // 30s timeout in open state
|
|
1822
|
-
successThresholdPercentage: 40 // 40% success rate closes circuit
|
|
1823
|
-
});
|
|
1685
|
+
interface DataRequest {}
|
|
1686
|
+
interface DataResponse { data: any; }
|
|
1824
1687
|
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
}
|
|
1843
|
-
} catch (error) {
|
|
1844
|
-
console.error('Webhook permanently failed:', error);
|
|
1688
|
+
const result = await stableRequest<DataRequest, DataResponse>({
|
|
1689
|
+
reqData: { path: '/data' },
|
|
1690
|
+
resReq: true,
|
|
1691
|
+
attempts: 3,
|
|
1692
|
+
statePersistence: {
|
|
1693
|
+
save: async (state, executionContext) => {
|
|
1694
|
+
// Save state to database or distributed cache
|
|
1695
|
+
await saveToDatabase({
|
|
1696
|
+
key: `${executionContext.workflowId}:${executionContext.requestId}`,
|
|
1697
|
+
state
|
|
1698
|
+
});
|
|
1699
|
+
},
|
|
1700
|
+
load: async (executionContext) => {
|
|
1701
|
+
// Load state for recovery
|
|
1702
|
+
return await loadFromDatabase(
|
|
1703
|
+
`${executionContext.workflowId}:${executionContext.requestId}`
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1845
1706
|
}
|
|
1846
|
-
}
|
|
1707
|
+
});
|
|
1847
1708
|
```
|
|
1848
1709
|
|
|
1849
|
-
###
|
|
1710
|
+
### Mixed Request & Function Phases
|
|
1711
|
+
|
|
1712
|
+
Combine API calls and computations in single phases with full type safety.
|
|
1850
1713
|
|
|
1851
1714
|
```typescript
|
|
1852
|
-
import
|
|
1853
|
-
import {
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1715
|
+
import { stableWorkflow, RequestOrFunction } from '@emmvish/stable-request';
|
|
1716
|
+
import type { STABLE_WORKFLOW_PHASE, API_GATEWAY_ITEM } from '@emmvish/stable-request';
|
|
1717
|
+
|
|
1718
|
+
// Request types
|
|
1719
|
+
interface ProductRequest {}\ninterface ProductResponse {
|
|
1720
|
+
id: number;
|
|
1721
|
+
name: string;
|
|
1722
|
+
price: number;
|
|
1723
|
+
}
|
|
1859
1724
|
|
|
1860
|
-
|
|
1725
|
+
interface InventoryRequest {}
|
|
1726
|
+
interface InventoryResponse {
|
|
1727
|
+
productId: number;
|
|
1728
|
+
stock: number;
|
|
1729
|
+
}
|
|
1861
1730
|
|
|
1862
|
-
//
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
])],
|
|
1877
|
-
lastPhase: phaseId,
|
|
1878
|
-
updatedAt: new Date().toISOString()
|
|
1879
|
-
};
|
|
1880
|
-
await redis.setex(key, 86400, JSON.stringify(checkpoint));
|
|
1881
|
-
console.log(`💾 Checkpoint: ${checkpoint.recordsProcessed}/${checkpoint.totalRecords} records`);
|
|
1882
|
-
} else {
|
|
1883
|
-
// Load checkpoint
|
|
1884
|
-
const data = await redis.get(key);
|
|
1885
|
-
return data ? JSON.parse(data) : {
|
|
1886
|
-
completedPhases: [],
|
|
1887
|
-
recordsProcessed: 0,
|
|
1888
|
-
totalRecords: 0
|
|
1889
|
-
};
|
|
1890
|
-
}
|
|
1891
|
-
return {};
|
|
1731
|
+
// Function types
|
|
1732
|
+
type EnrichArgs = [ProductResponse[], InventoryResponse[]];
|
|
1733
|
+
type EnrichResult = Array<{
|
|
1734
|
+
id: number;
|
|
1735
|
+
name: string;
|
|
1736
|
+
price: number;
|
|
1737
|
+
stock: number;
|
|
1738
|
+
inStock: boolean;
|
|
1739
|
+
}>;
|
|
1740
|
+
|
|
1741
|
+
type CalculateArgs = [EnrichResult];
|
|
1742
|
+
type CalculateResult = {
|
|
1743
|
+
totalValue: number;
|
|
1744
|
+
lowStockItems: number;
|
|
1892
1745
|
};
|
|
1893
1746
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
action: PHASE_DECISION_ACTIONS.SKIP,
|
|
1914
|
-
skipToPhaseId: 'transform'
|
|
1915
|
-
};
|
|
1747
|
+
type NotifyArgs = [CalculateResult, string];
|
|
1748
|
+
type NotifyResult = { notified: boolean };
|
|
1749
|
+
|
|
1750
|
+
const phase: STABLE_WORKFLOW_PHASE<
|
|
1751
|
+
ProductRequest | InventoryRequest,
|
|
1752
|
+
ProductResponse | InventoryResponse,
|
|
1753
|
+
EnrichArgs | CalculateArgs | NotifyArgs,
|
|
1754
|
+
EnrichResult | CalculateResult | NotifyResult
|
|
1755
|
+
> = {
|
|
1756
|
+
id: 'mixed-phase',
|
|
1757
|
+
items: [
|
|
1758
|
+
{
|
|
1759
|
+
type: RequestOrFunction.REQUEST,
|
|
1760
|
+
request: {
|
|
1761
|
+
id: 'fetch-products',
|
|
1762
|
+
requestOptions: {
|
|
1763
|
+
reqData: { path: '/products' },
|
|
1764
|
+
resReq: true
|
|
1765
|
+
}
|
|
1916
1766
|
}
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1767
|
+
},
|
|
1768
|
+
{
|
|
1769
|
+
type: RequestOrFunction.REQUEST,
|
|
1770
|
+
request: {
|
|
1771
|
+
id: 'fetch-inventory',
|
|
1772
|
+
requestOptions: {
|
|
1773
|
+
reqData: { path: '/inventory' },
|
|
1774
|
+
resReq: true
|
|
1775
|
+
}
|
|
1924
1776
|
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
},
|
|
1941
|
-
resReq: true,
|
|
1942
|
-
preExecution: {
|
|
1943
|
-
preExecutionHook: ({ commonBuffer }) => {
|
|
1944
|
-
// Process in batches
|
|
1945
|
-
const batchSize = 100;
|
|
1946
|
-
const processed = commonBuffer.recordsProcessed || 0;
|
|
1947
|
-
const batch = commonBuffer.extractedData.slice(
|
|
1948
|
-
processed,
|
|
1949
|
-
processed + batchSize
|
|
1950
|
-
);
|
|
1951
|
-
return {
|
|
1952
|
-
reqData: { body: { records: batch } }
|
|
1953
|
-
};
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
type: RequestOrFunction.FUNCTION,
|
|
1780
|
+
function: {
|
|
1781
|
+
id: 'enrich-products',
|
|
1782
|
+
functionOptions: {
|
|
1783
|
+
fn: (products: ProductResponse[], inventory: InventoryResponse[]): EnrichResult => {
|
|
1784
|
+
return products.map(product => {
|
|
1785
|
+
const inv = inventory.find(i => i.productId === product.id);
|
|
1786
|
+
return {
|
|
1787
|
+
...product,
|
|
1788
|
+
stock: inv?.stock || 0,
|
|
1789
|
+
inStock: (inv?.stock || 0) > 0
|
|
1790
|
+
};
|
|
1791
|
+
});
|
|
1954
1792
|
},
|
|
1955
|
-
|
|
1793
|
+
args: [[], []] as EnrichArgs,
|
|
1794
|
+
returnResult: true,
|
|
1795
|
+
cache: { enabled: true, ttl: 30000 }
|
|
1956
1796
|
}
|
|
1957
1797
|
}
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1798
|
+
},
|
|
1799
|
+
{
|
|
1800
|
+
type: RequestOrFunction.FUNCTION,
|
|
1801
|
+
function: {
|
|
1802
|
+
id: 'calculate-metrics',
|
|
1803
|
+
functionOptions: {
|
|
1804
|
+
fn: (enriched: EnrichResult): CalculateResult => ({
|
|
1805
|
+
totalValue: enriched.reduce((sum, p) => sum + (p.price * p.stock), 0),
|
|
1806
|
+
lowStockItems: enriched.filter(p => p.stock < 10 && p.stock > 0).length
|
|
1807
|
+
}),
|
|
1808
|
+
args: [[]] as CalculateArgs,
|
|
1809
|
+
returnResult: true
|
|
1810
|
+
}
|
|
1965
1811
|
}
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1812
|
+
},
|
|
1813
|
+
{
|
|
1814
|
+
type: RequestOrFunction.FUNCTION,
|
|
1815
|
+
function: {
|
|
1816
|
+
id: 'notify-if-needed',
|
|
1817
|
+
functionOptions: {
|
|
1818
|
+
fn: async (metrics: CalculateResult, channel: string): Promise<NotifyResult> => {
|
|
1819
|
+
if (metrics.lowStockItems > 5) {
|
|
1820
|
+
console.log(`Sending alert to ${channel}: ${metrics.lowStockItems} items low`);
|
|
1821
|
+
return { notified: true };
|
|
1822
|
+
}
|
|
1823
|
+
return { notified: false };
|
|
1824
|
+
},
|
|
1825
|
+
args: [{ totalValue: 0, lowStockItems: 0 }, 'slack'] as NotifyArgs,
|
|
1826
|
+
returnResult: true,
|
|
1827
|
+
attempts: 3,
|
|
1828
|
+
wait: 1000
|
|
1978
1829
|
}
|
|
1979
|
-
|
|
1980
|
-
// All records transformed
|
|
1981
|
-
sharedBuffer.completedPhases = [
|
|
1982
|
-
...(sharedBuffer.completedPhases || []),
|
|
1983
|
-
'transform'
|
|
1984
|
-
];
|
|
1985
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1986
1830
|
}
|
|
1987
|
-
|
|
1988
|
-
return { action: PHASE_DECISION_ACTIONS.TERMINATE };
|
|
1989
1831
|
}
|
|
1990
|
-
|
|
1832
|
+
]
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
const result = await stableWorkflow([phase], {
|
|
1836
|
+
workflowId: 'mixed-execution',
|
|
1837
|
+
sharedBuffer: {}
|
|
1838
|
+
});
|
|
1839
|
+
```
|
|
1840
|
+
|
|
1841
|
+
---
|
|
1842
|
+
|
|
1843
|
+
## Best Practices
|
|
1844
|
+
|
|
1845
|
+
### 1. Start Conservative, Override When Needed
|
|
1846
|
+
|
|
1847
|
+
Define global defaults; override only where necessary.
|
|
1848
|
+
|
|
1849
|
+
```typescript
|
|
1850
|
+
await stableWorkflow(phases, {
|
|
1851
|
+
// Global defaults (conservative)
|
|
1852
|
+
commonAttempts: 3,
|
|
1853
|
+
commonWait: 500,
|
|
1854
|
+
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
1855
|
+
|
|
1856
|
+
// Override for specific phase
|
|
1857
|
+
phases: [
|
|
1858
|
+
{
|
|
1859
|
+
id: 'fast-phase',
|
|
1860
|
+
attempts: 1, // Override: no retries
|
|
1861
|
+
requests: [...]
|
|
1862
|
+
}
|
|
1863
|
+
]
|
|
1864
|
+
});
|
|
1865
|
+
```
|
|
1866
|
+
|
|
1867
|
+
### 2. Validate Responses
|
|
1868
|
+
|
|
1869
|
+
Use analyzers to ensure data shape and freshness.
|
|
1870
|
+
|
|
1871
|
+
```typescript
|
|
1872
|
+
interface DataRequest {}
|
|
1873
|
+
interface ApiResponse {
|
|
1874
|
+
id: number;
|
|
1875
|
+
lastUpdated: string;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const result = await stableRequest<DataRequest, ApiResponse>({
|
|
1879
|
+
reqData: { path: '/data' },
|
|
1880
|
+
resReq: true,
|
|
1881
|
+
responseAnalyzer: ({ data }) => {
|
|
1882
|
+
if (!data || typeof data !== 'object') return false;
|
|
1883
|
+
if (!('id' in data && 'lastUpdated' in data)) return false;
|
|
1884
|
+
const age = Date.now() - new Date((data as any).lastUpdated).getTime();
|
|
1885
|
+
if (age > 60000) return false; // Data older than 1 minute
|
|
1886
|
+
return true;
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
```
|
|
1890
|
+
|
|
1891
|
+
### 3. Cache Idempotent Reads Aggressively
|
|
1892
|
+
|
|
1893
|
+
Reduce latency and load on dependencies.
|
|
1894
|
+
|
|
1895
|
+
```typescript
|
|
1896
|
+
interface UserRequest {}
|
|
1897
|
+
interface UserResponse {
|
|
1898
|
+
id: number;
|
|
1899
|
+
name: string;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
const userCache = new CacheManager({
|
|
1903
|
+
enabled: true,
|
|
1904
|
+
ttl: 30000, // 30 seconds
|
|
1905
|
+
respectCacheControl: true
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
await stableRequest<UserRequest, UserResponse>({
|
|
1909
|
+
reqData: { path: '/users/1' },
|
|
1910
|
+
resReq: true,
|
|
1911
|
+
cache: userCache
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
await stableRequest<UserRequest, UserResponse>({
|
|
1915
|
+
reqData: { path: '/users/1' },
|
|
1916
|
+
resReq: true,
|
|
1917
|
+
cache: userCache // Cached within 30s
|
|
1918
|
+
});
|
|
1919
|
+
```
|
|
1920
|
+
|
|
1921
|
+
### 4. Use Circuit Breaker for Unstable Services
|
|
1922
|
+
|
|
1923
|
+
Protect against cascading failures.
|
|
1924
|
+
|
|
1925
|
+
```typescript
|
|
1926
|
+
interface ServiceRequest {}
|
|
1927
|
+
interface ServiceResponse { status: string; data: any; }
|
|
1928
|
+
|
|
1929
|
+
const unstabledServiceBreaker = new CircuitBreaker({
|
|
1930
|
+
failureThresholdPercentage: 40,
|
|
1931
|
+
minimumRequests: 5,
|
|
1932
|
+
recoveryTimeoutMs: 30000,
|
|
1933
|
+
successThresholdPercentage: 80
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
await stableApiGateway<ServiceRequest, ServiceResponse>(requests, {
|
|
1937
|
+
circuitBreaker: unstabledServiceBreaker
|
|
1938
|
+
});
|
|
1939
|
+
```
|
|
1940
|
+
|
|
1941
|
+
### 5. Apply Rate & Concurrency Limits
|
|
1942
|
+
|
|
1943
|
+
Respect external quotas and capacity.
|
|
1944
|
+
|
|
1945
|
+
```typescript
|
|
1946
|
+
interface ApiRequest {}
|
|
1947
|
+
interface ApiResponse { result: any; }
|
|
1948
|
+
|
|
1949
|
+
// API allows 100 req/second, use 80% headroom
|
|
1950
|
+
const rateLimit = { maxRequests: 80, windowMs: 1000 };
|
|
1951
|
+
|
|
1952
|
+
// Database connection pool has 10 slots, use 5
|
|
1953
|
+
const maxConcurrent = 5;
|
|
1954
|
+
|
|
1955
|
+
await stableApiGateway<ApiRequest, ApiResponse>(requests, {
|
|
1956
|
+
rateLimit,
|
|
1957
|
+
maxConcurrentRequests: maxConcurrent
|
|
1958
|
+
});
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
### 6. Use Shared Buffers for Cross-Phase Coordination
|
|
1962
|
+
|
|
1963
|
+
Avoid global state; pass computed data cleanly.
|
|
1964
|
+
|
|
1965
|
+
```typescript
|
|
1966
|
+
const sharedBuffer = {};
|
|
1967
|
+
|
|
1968
|
+
await stableWorkflow(phases, {
|
|
1969
|
+
sharedBuffer,
|
|
1970
|
+
// Phase 1 writes userId to sharedBuffer
|
|
1971
|
+
// Phase 2 reads userId from sharedBuffer
|
|
1972
|
+
// Phase 3 uses both
|
|
1973
|
+
});
|
|
1974
|
+
```
|
|
1975
|
+
|
|
1976
|
+
### 7. Log Selectively with Max Serialization Cap
|
|
1977
|
+
|
|
1978
|
+
Prevent noisy logs from large payloads.
|
|
1979
|
+
|
|
1980
|
+
```typescript
|
|
1981
|
+
interface DataRequest {}
|
|
1982
|
+
interface DataResponse { data: any; }
|
|
1983
|
+
|
|
1984
|
+
await stableRequest<DataRequest, DataResponse>({
|
|
1985
|
+
reqData: { path: '/data' },
|
|
1986
|
+
resReq: true,
|
|
1987
|
+
maxSerializableChars: 500, // Truncate logs to 500 chars
|
|
1988
|
+
handleSuccessfulAttemptData: ({ successfulAttemptData, maxSerializableChars }) => {
|
|
1989
|
+
console.log(safelyStringify(successfulAttemptData, maxSerializableChars));
|
|
1990
|
+
}
|
|
1991
|
+
});
|
|
1992
|
+
```
|
|
1993
|
+
|
|
1994
|
+
### 8. Use Non-Linear Workflows for Polling
|
|
1995
|
+
|
|
1996
|
+
REPLAY action simplifies polling logic.
|
|
1997
|
+
|
|
1998
|
+
```typescript
|
|
1999
|
+
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1991
2000
|
{
|
|
1992
|
-
id: '
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
path: '/
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2001
|
+
id: 'wait-for-job',
|
|
2002
|
+
allowReplay: true,
|
|
2003
|
+
maxReplayCount: 10,
|
|
2004
|
+
requests: [
|
|
2005
|
+
{
|
|
2006
|
+
id: 'check-status',
|
|
2007
|
+
requestOptions: {
|
|
2008
|
+
reqData: { path: '/jobs/123' },
|
|
2009
|
+
resReq: true,
|
|
2010
|
+
attempts: 1
|
|
2011
|
+
}
|
|
2003
2012
|
}
|
|
2004
|
-
|
|
2005
|
-
phaseDecisionHook: async ({ phaseResult
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
'load'
|
|
2010
|
-
];
|
|
2013
|
+
],
|
|
2014
|
+
phaseDecisionHook: async ({ phaseResult }) => {
|
|
2015
|
+
const status = (phaseResult.responses[0].data as any)?.status;
|
|
2016
|
+
if (status === 'pending') {
|
|
2017
|
+
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
2011
2018
|
}
|
|
2012
2019
|
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
2013
2020
|
}
|
|
2014
2021
|
}
|
|
2015
2022
|
];
|
|
2016
2023
|
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
workflowId: 'data-migration-2024-01-08',
|
|
2020
|
-
enableNonLinearExecution: true,
|
|
2021
|
-
sharedBuffer: {
|
|
2022
|
-
completedPhases: [],
|
|
2023
|
-
recordsProcessed: 0,
|
|
2024
|
-
totalRecords: 0
|
|
2025
|
-
},
|
|
2026
|
-
commonStatePersistence: {
|
|
2027
|
-
persistenceFunction: createCheckpoint,
|
|
2028
|
-
loadBeforeHooks: true,
|
|
2029
|
-
storeAfterHooks: true
|
|
2030
|
-
},
|
|
2031
|
-
commonAttempts: 3,
|
|
2032
|
-
commonWait: 2000,
|
|
2033
|
-
stopOnFirstPhaseError: true,
|
|
2034
|
-
logPhaseResults: true
|
|
2024
|
+
await stableWorkflow(phases, {
|
|
2025
|
+
enableNonLinearExecution: true
|
|
2035
2026
|
});
|
|
2027
|
+
```
|
|
2028
|
+
|
|
2029
|
+
### 9. Use Graph Workflows for Complex Parallelism
|
|
2030
|
+
|
|
2031
|
+
DAGs make dependencies explicit and enable maximum parallelism.
|
|
2036
2032
|
|
|
2037
|
-
|
|
2038
|
-
|
|
2033
|
+
```typescript
|
|
2034
|
+
// Clearer than 6 phases with conditional concurrency markers
|
|
2035
|
+
const graph = new WorkflowGraphBuilder()
|
|
2036
|
+
.addParallelGroup('fetch', ['fetch-users', 'fetch-posts', 'fetch-comments'])
|
|
2037
|
+
.addMergePoint('sync', ['fetch'])
|
|
2038
|
+
.addPhase('aggregate', {...})
|
|
2039
|
+
.connectSequence('fetch', 'sync', 'aggregate')
|
|
2040
|
+
.build();
|
|
2039
2041
|
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
+
await stableWorkflowGraph(graph);
|
|
2043
|
+
```
|
|
2044
|
+
|
|
2045
|
+
### 10. Prefer Dry-Run (Trial Mode) Before Production
|
|
2046
|
+
|
|
2047
|
+
Test workflows and retry logic safely.
|
|
2048
|
+
|
|
2049
|
+
```typescript
|
|
2050
|
+
await stableWorkflow(phases, {
|
|
2051
|
+
workflowId: 'payment-pipeline',
|
|
2052
|
+
trialMode: { enabled: true }, // Dry-run before production
|
|
2053
|
+
handlePhaseCompletion: ({ phaseResult }) => {
|
|
2054
|
+
console.log(`Trial phase: ${phaseResult.phaseId}, success=${phaseResult.success}`);
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
// If satisfied, deploy with trialMode: { enabled: false }
|
|
2042
2059
|
```
|
|
2043
2060
|
|
|
2044
2061
|
---
|
|
2045
2062
|
|
|
2046
|
-
##
|
|
2063
|
+
## Summary
|
|
2064
|
+
|
|
2065
|
+
@emmvish/stable-request provides a unified, type-safe framework for resilient execution:
|
|
2066
|
+
|
|
2067
|
+
- **Single calls** via `stableRequest` (APIs) or `stableFunction` (pure functions)
|
|
2068
|
+
- **Batch orchestration** via `stableApiGateway` (concurrent/sequential mixed items)
|
|
2069
|
+
- **Phased workflows** via `stableWorkflow` (array-based, non-linear, branched)
|
|
2070
|
+
- **Graph workflows** via `stableWorkflowGraph` (DAG, explicit parallelism)
|
|
2071
|
+
|
|
2072
|
+
All modes inherit robust resilience (retries, jitter, circuit breaking, caching, rate/concurrency limits), config cascading, shared state, hooks, and metrics. Use together or independently; compose freely.
|
|
2047
2073
|
|
|
2048
|
-
|
|
2074
|
+
Build resilient, observable, type-safe systems with confidence.
|