@emmvish/stable-request 2.8.4 → 3.0.0
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/LICENSE +1 -1
- package/README.md +1153 -2319
- package/dist/constants/index.d.ts +0 -10
- package/dist/constants/index.d.ts.map +1 -1
- package/dist/constants/index.js +0 -113
- package/dist/constants/index.js.map +1 -1
- package/dist/core/index.d.ts +0 -5
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +0 -5
- package/dist/core/index.js.map +1 -1
- package/dist/core/stable-request.d.ts.map +1 -1
- package/dist/core/stable-request.js +22 -7
- package/dist/core/stable-request.js.map +1 -1
- package/dist/enums/index.d.ts +0 -37
- package/dist/enums/index.d.ts.map +1 -1
- package/dist/enums/index.js +0 -43
- package/dist/enums/index.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -3
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +100 -1135
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utilities/index.d.ts +0 -18
- package/dist/utilities/index.d.ts.map +1 -1
- package/dist/utilities/index.js +0 -18
- package/dist/utilities/index.js.map +1 -1
- package/dist/utilities/infrastructure-persistence.d.ts +0 -1
- package/dist/utilities/infrastructure-persistence.d.ts.map +1 -1
- package/dist/utilities/infrastructure-persistence.js +12 -15
- package/dist/utilities/infrastructure-persistence.js.map +1 -1
- package/dist/utilities/metrics-aggregator.d.ts +2 -13
- package/dist/utilities/metrics-aggregator.d.ts.map +1 -1
- package/dist/utilities/metrics-aggregator.js +9 -251
- package/dist/utilities/metrics-aggregator.js.map +1 -1
- package/dist/utilities/metrics-validator.d.ts +6 -76
- package/dist/utilities/metrics-validator.d.ts.map +1 -1
- package/dist/utilities/metrics-validator.js +12 -181
- package/dist/utilities/metrics-validator.js.map +1 -1
- package/dist/utilities/validate-trial-mode-probabilities.js +2 -2
- package/dist/utilities/validate-trial-mode-probabilities.js.map +1 -1
- package/package.json +20 -24
- package/dist/core/stable-api-gateway.d.ts +0 -4
- package/dist/core/stable-api-gateway.d.ts.map +0 -1
- package/dist/core/stable-api-gateway.js +0 -136
- package/dist/core/stable-api-gateway.js.map +0 -1
- package/dist/core/stable-function.d.ts +0 -11
- package/dist/core/stable-function.d.ts.map +0 -1
- package/dist/core/stable-function.js +0 -340
- package/dist/core/stable-function.js.map +0 -1
- package/dist/core/stable-scheduler.d.ts +0 -71
- package/dist/core/stable-scheduler.d.ts.map +0 -1
- package/dist/core/stable-scheduler.js +0 -768
- package/dist/core/stable-scheduler.js.map +0 -1
- package/dist/core/stable-workflow-graph.d.ts +0 -3
- package/dist/core/stable-workflow-graph.d.ts.map +0 -1
- package/dist/core/stable-workflow-graph.js +0 -5
- package/dist/core/stable-workflow-graph.js.map +0 -1
- package/dist/core/stable-workflow.d.ts +0 -3
- package/dist/core/stable-workflow.d.ts.map +0 -1
- package/dist/core/stable-workflow.js +0 -362
- package/dist/core/stable-workflow.js.map +0 -1
- package/dist/stable-runner/index.d.ts +0 -2
- package/dist/stable-runner/index.d.ts.map +0 -1
- package/dist/stable-runner/index.js +0 -324
- package/dist/stable-runner/index.js.map +0 -1
- package/dist/utilities/concurrency-limiter.d.ts +0 -46
- package/dist/utilities/concurrency-limiter.d.ts.map +0 -1
- package/dist/utilities/concurrency-limiter.js +0 -172
- package/dist/utilities/concurrency-limiter.js.map +0 -1
- package/dist/utilities/execute-branch-workflow.d.ts +0 -3
- package/dist/utilities/execute-branch-workflow.d.ts.map +0 -1
- package/dist/utilities/execute-branch-workflow.js +0 -730
- package/dist/utilities/execute-branch-workflow.js.map +0 -1
- package/dist/utilities/execute-concurrently.d.ts +0 -3
- package/dist/utilities/execute-concurrently.d.ts.map +0 -1
- package/dist/utilities/execute-concurrently.js +0 -258
- package/dist/utilities/execute-concurrently.js.map +0 -1
- package/dist/utilities/execute-gateway-item.d.ts +0 -6
- package/dist/utilities/execute-gateway-item.d.ts.map +0 -1
- package/dist/utilities/execute-gateway-item.js +0 -127
- package/dist/utilities/execute-gateway-item.js.map +0 -1
- package/dist/utilities/execute-non-linear-workflow.d.ts +0 -3
- package/dist/utilities/execute-non-linear-workflow.d.ts.map +0 -1
- package/dist/utilities/execute-non-linear-workflow.js +0 -483
- package/dist/utilities/execute-non-linear-workflow.js.map +0 -1
- package/dist/utilities/execute-phase.d.ts +0 -3
- package/dist/utilities/execute-phase.d.ts.map +0 -1
- package/dist/utilities/execute-phase.js +0 -129
- package/dist/utilities/execute-phase.js.map +0 -1
- package/dist/utilities/execute-sequentially.d.ts +0 -3
- package/dist/utilities/execute-sequentially.d.ts.map +0 -1
- package/dist/utilities/execute-sequentially.js +0 -49
- package/dist/utilities/execute-sequentially.js.map +0 -1
- package/dist/utilities/execute-with-timeout.d.ts +0 -6
- package/dist/utilities/execute-with-timeout.d.ts.map +0 -1
- package/dist/utilities/execute-with-timeout.js +0 -28
- package/dist/utilities/execute-with-timeout.js.map +0 -1
- package/dist/utilities/execute-workflow-graph.d.ts +0 -3
- package/dist/utilities/execute-workflow-graph.d.ts.map +0 -1
- package/dist/utilities/execute-workflow-graph.js +0 -429
- package/dist/utilities/execute-workflow-graph.js.map +0 -1
- package/dist/utilities/extract-common-request-config-options.d.ts +0 -3
- package/dist/utilities/extract-common-request-config-options.d.ts.map +0 -1
- package/dist/utilities/extract-common-request-config-options.js +0 -12
- package/dist/utilities/extract-common-request-config-options.js.map +0 -1
- package/dist/utilities/fn-exec.d.ts +0 -3
- package/dist/utilities/fn-exec.d.ts.map +0 -1
- package/dist/utilities/fn-exec.js +0 -66
- package/dist/utilities/fn-exec.js.map +0 -1
- package/dist/utilities/function-cache-manager.d.ts +0 -32
- package/dist/utilities/function-cache-manager.d.ts.map +0 -1
- package/dist/utilities/function-cache-manager.js +0 -172
- package/dist/utilities/function-cache-manager.js.map +0 -1
- package/dist/utilities/prepare-api-function-options.d.ts +0 -3
- package/dist/utilities/prepare-api-function-options.d.ts.map +0 -1
- package/dist/utilities/prepare-api-function-options.js +0 -51
- package/dist/utilities/prepare-api-function-options.js.map +0 -1
- package/dist/utilities/prepare-api-request-data.d.ts +0 -3
- package/dist/utilities/prepare-api-request-data.d.ts.map +0 -1
- package/dist/utilities/prepare-api-request-data.js +0 -15
- package/dist/utilities/prepare-api-request-data.js.map +0 -1
- package/dist/utilities/prepare-api-request-options.d.ts +0 -3
- package/dist/utilities/prepare-api-request-options.d.ts.map +0 -1
- package/dist/utilities/prepare-api-request-options.js +0 -22
- package/dist/utilities/prepare-api-request-options.js.map +0 -1
- package/dist/utilities/rate-limiter.d.ts +0 -49
- package/dist/utilities/rate-limiter.d.ts.map +0 -1
- package/dist/utilities/rate-limiter.js +0 -197
- package/dist/utilities/rate-limiter.js.map +0 -1
- package/dist/utilities/validate-workflow-graph.d.ts +0 -7
- package/dist/utilities/validate-workflow-graph.d.ts.map +0 -1
- package/dist/utilities/validate-workflow-graph.js +0 -235
- package/dist/utilities/validate-workflow-graph.js.map +0 -1
- package/dist/utilities/workflow-graph-builder.d.ts +0 -37
- package/dist/utilities/workflow-graph-builder.d.ts.map +0 -1
- package/dist/utilities/workflow-graph-builder.js +0 -225
- package/dist/utilities/workflow-graph-builder.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,2520 +1,1354 @@
|
|
|
1
1
|
# @emmvish/stable-request
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Table of Contents
|
|
6
|
-
|
|
7
|
-
- [Overview](#overview)
|
|
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
|
-
- [StableScheduler](#stablescheduler)
|
|
16
|
-
- [StableBuffer](#stablebuffer)
|
|
17
|
-
- [Stable Runner](#stable-runner)
|
|
18
|
-
- [Resilience Mechanisms](#resilience-mechanisms)
|
|
19
|
-
- [Retry Strategies](#retry-strategies)
|
|
20
|
-
- [Circuit Breaker](#circuit-breaker)
|
|
21
|
-
- [Caching](#caching)
|
|
22
|
-
- [Rate Limiting](#rate-limiting)
|
|
23
|
-
- [Concurrency Limiting](#concurrency-limiting)
|
|
24
|
-
- [Workflow Patterns](#workflow-patterns)
|
|
25
|
-
- [Sequential & Concurrent Phases](#sequential--concurrent-phases)
|
|
26
|
-
- [Non-Linear Workflows](#non-linear-workflows)
|
|
27
|
-
- [Branched Workflows](#branched-workflows)
|
|
28
|
-
- [Graph-based Workflow Patterns](#graph-based-workflow-patterns)
|
|
29
|
-
- [Graph-Based Workflows with Mixed Items](#graph-based-workflows-with-mixed-items)
|
|
30
|
-
- [Parallel Phase Execution](#parallel-phase-execution)
|
|
31
|
-
- [Merge Points](#merge-points)
|
|
32
|
-
- [Linear Helper](#linear-helper)
|
|
33
|
-
- [Branch Racing in Graphs](#branch-racing-in-graphs)
|
|
34
|
-
- [Configuration & State](#configuration--state)
|
|
35
|
-
- [Config Cascading](#config-cascading)
|
|
36
|
-
- [Shared & State Buffers](#shared--state-buffers)
|
|
37
|
-
- [Hooks & Observability](#hooks--observability)
|
|
38
|
-
- [Pre-Execution Hooks](#pre-execution-hooks)
|
|
39
|
-
- [Analysis Hooks](#analysis-hooks)
|
|
40
|
-
- [Handler Hooks](#handler-hooks)
|
|
41
|
-
- [Decision Hooks](#decision-hooks)
|
|
42
|
-
- [Metrics & Logging](#metrics--logging)
|
|
43
|
-
- [Advanced Features](#advanced-features)
|
|
44
|
-
- [Trial Mode](#trial-mode)
|
|
45
|
-
- [State Persistence](#state-persistence)
|
|
46
|
-
- [Mixed Request & Function Phases](#mixed-request--function-phases)
|
|
47
|
-
- [Best Practices](#best-practices)
|
|
3
|
+
> ⚠️ **Maintenance Mode Notice**: This library is now in maintenance mode. For the full-featured execution engine with workflows, schedulers, API gateways, and more, please use [**stable-infra**](https://npmjs.com/package/@emmvish/stable-infra) - the natural evolution of stable-request. If you wish to continue using stable-request for workflows / gateway / scheduling, then, refer to its docs in version 2.8.5.
|
|
48
4
|
|
|
49
|
-
|
|
5
|
+
A resilient HTTP request framework for Node.js with built-in intelligent retry strategies, circuit breakers, caching, state persistence, and comprehensive observability.
|
|
6
|
+
I created this framework when I was integrating with **unreliable, flaky APIs** and needed a simple solution for retrying the requests. While such libraries do exist already, I needed something more... an intelligent, fully customizable and stable framework that would not throw errors randomly, but rather, give me only the most important information on why my requests were failing or succeeding, with metrics and type-safety.
|
|
50
7
|
|
|
51
|
-
##
|
|
8
|
+
## 🚀 Try stable-infra Instead
|
|
52
9
|
|
|
53
|
-
|
|
10
|
+
**stable-request** has evolved into **stable-infra** - a complete execution infrastructure that includes:
|
|
54
11
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
12
|
+
- Everything in `stable-request` and more updates
|
|
13
|
+
- `stableWorkflow` - Multi-phase workflow orchestration with branching
|
|
14
|
+
- `stableApiGateway` - Batch request execution with grouping
|
|
15
|
+
- `stableFunction` - Resilient function execution with retries
|
|
16
|
+
- `stableWorkflowGraph` - DAG-based workflow execution
|
|
17
|
+
- `StableScheduler` - Job scheduling with cron, intervals, and timestamps
|
|
18
|
+
- `StableBuffer` - A safe shared-state buffer for all the stable modules
|
|
19
|
+
- `stableRunner` - CLI runner for all execution types
|
|
61
20
|
|
|
62
|
-
|
|
21
|
+
**[Get started with stable-infra →](https://npmjs.com/package/@emmvish/stable-infra)**
|
|
63
22
|
|
|
64
23
|
---
|
|
65
24
|
|
|
66
|
-
##
|
|
25
|
+
## Installation
|
|
67
26
|
|
|
68
|
-
|
|
27
|
+
```bash
|
|
28
|
+
npm install stable-request
|
|
29
|
+
```
|
|
69
30
|
|
|
70
|
-
|
|
31
|
+
## Why stable-request?
|
|
71
32
|
|
|
72
|
-
-
|
|
73
|
-
- **Jitter** to prevent thundering herd
|
|
74
|
-
- **Circuit breaker** to fail fast and protect downstream systems
|
|
75
|
-
- **Caching** for idempotent read operations
|
|
76
|
-
- **Rate & concurrency limits** to respect external constraints
|
|
77
|
-
- **Metrics guardrails** to validate execution against thresholds with automatic anomaly detection
|
|
33
|
+
Traditional retry libraries blindly retry on network failures or non-2xx HTTP status codes. But in the real world, **HTTP 200 doesn't always mean success**:
|
|
78
34
|
|
|
79
|
-
|
|
35
|
+
- ✅ An API returns `200 OK` with `{ "status": "pending" }` - you need to retry until it's `"completed"`
|
|
36
|
+
- ✅ A payment gateway returns `200 OK` but the transaction is still processing
|
|
37
|
+
- ✅ A search API returns `200 OK` with empty results due to eventual consistency
|
|
38
|
+
- ✅ An external API returns `200 OK` with `{ "error": "rate_limited" }` in the body
|
|
39
|
+
- ✅ You need to validate response data against business rules before accepting it
|
|
80
40
|
|
|
81
|
-
|
|
41
|
+
**stable-request** lets you inject business intelligence into every stage of the request lifecycle through **hooks** - making your HTTP requests truly resilient to both infrastructure and business-level failures.
|
|
82
42
|
|
|
83
|
-
|
|
43
|
+
## Features
|
|
84
44
|
|
|
85
|
-
|
|
45
|
+
### 🔄 Configurable Retry Strategies
|
|
86
46
|
|
|
87
|
-
|
|
47
|
+
Automatically retry failed requests with customizable backoff strategies:
|
|
88
48
|
|
|
89
|
-
|
|
49
|
+
```typescript
|
|
50
|
+
import { stableRequest, RETRY_STRATEGIES, REQUEST_METHODS } from 'stable-request';
|
|
51
|
+
import type { STABLE_REQUEST_RESULT } from 'stable-request';
|
|
90
52
|
|
|
91
|
-
|
|
53
|
+
interface ApiResponse {
|
|
54
|
+
data: string[];
|
|
55
|
+
total: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
(async () => {
|
|
59
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
60
|
+
reqData: {
|
|
61
|
+
hostname: 'api.example.com',
|
|
62
|
+
path: '/data',
|
|
63
|
+
method: REQUEST_METHODS.GET
|
|
64
|
+
},
|
|
65
|
+
resReq: true,
|
|
66
|
+
attempts: 5,
|
|
67
|
+
wait: 1000,
|
|
68
|
+
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL, // FIXED, LINEAR, or EXPONENTIAL
|
|
69
|
+
jitter: 0.2, // Add ±20% randomness to delays
|
|
70
|
+
maxAllowedWait: 30000 // Cap maximum wait time
|
|
71
|
+
});
|
|
92
72
|
|
|
93
|
-
|
|
73
|
+
if (result.success) {
|
|
74
|
+
console.log('Data:', result.data);
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
```
|
|
94
78
|
|
|
95
|
-
###
|
|
79
|
+
### ⚡ Circuit Breaker Pattern
|
|
96
80
|
|
|
97
|
-
|
|
81
|
+
Protect your services from cascading failures:
|
|
98
82
|
|
|
99
83
|
```typescript
|
|
100
|
-
import { stableRequest, REQUEST_METHODS
|
|
84
|
+
import { stableRequest, REQUEST_METHODS } from 'stable-request';
|
|
85
|
+
import type { STABLE_REQUEST_RESULT, CircuitBreakerConfig } from 'stable-request';
|
|
101
86
|
|
|
102
|
-
interface
|
|
103
|
-
|
|
87
|
+
interface ApiResponse {
|
|
88
|
+
status: string;
|
|
104
89
|
}
|
|
105
90
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
91
|
+
(async () => {
|
|
92
|
+
const circuitBreakerConfig: CircuitBreakerConfig = {
|
|
93
|
+
failureThresholdPercentage: 50, // Open circuit at 50% failure rate
|
|
94
|
+
minimumRequests: 10, // Minimum requests before evaluation
|
|
95
|
+
recoveryTimeoutMs: 30000, // Time before attempting recovery
|
|
96
|
+
halfOpenMaxRequests: 5, // Requests allowed in half-open state
|
|
97
|
+
trackIndividualAttempts: true // Track each retry attempt
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
101
|
+
reqData: {
|
|
102
|
+
hostname: 'api.example.com',
|
|
103
|
+
path: '/data',
|
|
104
|
+
method: REQUEST_METHODS.GET
|
|
105
|
+
},
|
|
106
|
+
resReq: true,
|
|
107
|
+
circuitBreaker: circuitBreakerConfig
|
|
108
|
+
});
|
|
109
|
+
})();
|
|
110
|
+
```
|
|
110
111
|
|
|
111
|
-
|
|
112
|
-
reqData: {
|
|
113
|
-
method: REQUEST_METHODS.GET,
|
|
114
|
-
protocol: VALID_REQUEST_PROTOCOLS.HTTPS,
|
|
115
|
-
hostname: 'api.example.com',
|
|
116
|
-
path: '/users/1'
|
|
117
|
-
},
|
|
118
|
-
resReq: true,
|
|
119
|
-
attempts: 3,
|
|
120
|
-
wait: 500,
|
|
121
|
-
jitter: 100,
|
|
122
|
-
cache: { enabled: true, ttl: 5000 },
|
|
123
|
-
rateLimit: { maxRequests: 10, windowMs: 1000 },
|
|
124
|
-
maxConcurrentRequests: 5,
|
|
125
|
-
responseAnalyzer: ({ data }) => {
|
|
126
|
-
return typeof data === 'object' && data !== null && 'id' in data;
|
|
127
|
-
},
|
|
128
|
-
handleSuccessfulAttemptData: ({ successfulAttemptData }) => {
|
|
129
|
-
console.log(`User loaded: ${successfulAttemptData.data.name}`);
|
|
130
|
-
}
|
|
131
|
-
});
|
|
112
|
+
### 💾 Intelligent Response Caching
|
|
132
113
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
114
|
+
Cache responses with full HTTP cache-control support:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
import { stableRequest, REQUEST_METHODS } from 'stable-request';
|
|
118
|
+
import type { STABLE_REQUEST_RESULT, CacheConfig } from 'stable-request';
|
|
119
|
+
|
|
120
|
+
interface ApiResponse {
|
|
121
|
+
items: { id: number; name: string }[];
|
|
137
122
|
}
|
|
138
|
-
```
|
|
139
123
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
124
|
+
(async () => {
|
|
125
|
+
const cacheConfig: CacheConfig = {
|
|
126
|
+
enabled: true,
|
|
127
|
+
ttl: 300000, // 5 minutes default TTL
|
|
128
|
+
maxSize: 100, // Maximum cache entries
|
|
129
|
+
respectCacheControl: true, // Honor HTTP cache headers
|
|
130
|
+
cacheableStatusCodes: [200, 203, 204, 206, 300, 301],
|
|
131
|
+
excludeMethods: [REQUEST_METHODS.POST, REQUEST_METHODS.PUT, REQUEST_METHODS.PATCH, REQUEST_METHODS.DELETE]
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
135
|
+
reqData: {
|
|
136
|
+
hostname: 'api.example.com',
|
|
137
|
+
path: '/data',
|
|
138
|
+
method: REQUEST_METHODS.GET
|
|
139
|
+
},
|
|
140
|
+
resReq: true,
|
|
141
|
+
cache: cacheConfig
|
|
142
|
+
});
|
|
143
|
+
})();
|
|
144
|
+
```
|
|
147
145
|
|
|
148
|
-
###
|
|
146
|
+
### 🔒 StableBuffer - Thread-Safe State Management
|
|
149
147
|
|
|
150
|
-
|
|
148
|
+
Manage shared state safely across concurrent operations:
|
|
151
149
|
|
|
152
150
|
```typescript
|
|
153
|
-
import {
|
|
154
|
-
|
|
155
|
-
type ComputeArgs = [number, number];
|
|
156
|
-
type ComputeResult = number;
|
|
157
|
-
|
|
158
|
-
const multiply = (a: number, b: number) => a * b;
|
|
159
|
-
|
|
160
|
-
const result = await stableFunction<ComputeArgs, ComputeResult>({
|
|
161
|
-
fn: multiply,
|
|
162
|
-
args: [5, 3],
|
|
163
|
-
returnResult: true,
|
|
164
|
-
attempts: 2,
|
|
165
|
-
wait: 100,
|
|
166
|
-
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
167
|
-
responseAnalyzer: ({ data }) => data > 0,
|
|
168
|
-
cache: { enabled: true, ttl: 10000 }
|
|
169
|
-
});
|
|
151
|
+
import { StableBuffer } from 'stable-request';
|
|
152
|
+
import type { StableBufferOptions, StableBufferTransactionLog, StableBufferState } from 'stable-request';
|
|
170
153
|
|
|
171
|
-
|
|
172
|
-
|
|
154
|
+
interface BufferState extends StableBufferState {
|
|
155
|
+
counter: number;
|
|
156
|
+
items: { id: number; timestamp: number }[];
|
|
173
157
|
}
|
|
174
|
-
```
|
|
175
158
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
159
|
+
(async () => {
|
|
160
|
+
const bufferOptions: StableBufferOptions = {
|
|
161
|
+
initialState: { counter: 0, items: [] } as BufferState,
|
|
162
|
+
transactionTimeoutMs: 5000,
|
|
163
|
+
logTransaction: async (log: StableBufferTransactionLog): Promise<void> => {
|
|
164
|
+
// Persist transaction logs for replay/audit
|
|
165
|
+
await saveToDatabase(log);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const buffer = new StableBuffer(bufferOptions);
|
|
170
|
+
|
|
171
|
+
// Safe concurrent updates
|
|
172
|
+
await buffer.run(async (state): Promise<void> => {
|
|
173
|
+
const typedState = state as BufferState;
|
|
174
|
+
typedState.counter += 1;
|
|
175
|
+
typedState.items.push({ id: typedState.counter, timestamp: Date.now() });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Read state (returns a clone)
|
|
179
|
+
const currentState = buffer.read() as BufferState;
|
|
180
|
+
})();
|
|
181
|
+
```
|
|
182
182
|
|
|
183
|
-
###
|
|
183
|
+
### 📜 Transaction Logs & State Replay
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
Replay transactions for recovery or auditing:
|
|
186
186
|
|
|
187
187
|
```typescript
|
|
188
|
-
import {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
import type { API_GATEWAY_ITEM } from '@emmvish/stable-request';
|
|
195
|
-
|
|
196
|
-
// Define request types
|
|
197
|
-
interface ApiRequestData {
|
|
198
|
-
filters?: Record<string, any>;
|
|
188
|
+
import { replayStableBufferTransactions } from 'stable-request';
|
|
189
|
+
import type { StableBufferTransactionLog, StableBufferReplayResult, StableBufferReplayHandler } from 'stable-request';
|
|
190
|
+
|
|
191
|
+
interface OrderState {
|
|
192
|
+
orders: string[];
|
|
193
|
+
inventory: Record<string, number>;
|
|
199
194
|
}
|
|
200
195
|
|
|
201
|
-
interface
|
|
202
|
-
|
|
203
|
-
value: string;
|
|
196
|
+
interface OrderHookParams {
|
|
197
|
+
orderId: string;
|
|
204
198
|
}
|
|
205
199
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
count: number;
|
|
211
|
-
};
|
|
200
|
+
interface InventoryHookParams {
|
|
201
|
+
sku: string;
|
|
202
|
+
quantity: number;
|
|
203
|
+
}
|
|
212
204
|
|
|
213
|
-
|
|
214
|
-
|
|
205
|
+
(async () => {
|
|
206
|
+
// Load saved transaction logs
|
|
207
|
+
const logs: StableBufferTransactionLog[] = await loadTransactionLogsFromDB();
|
|
215
208
|
|
|
216
|
-
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
},
|
|
228
|
-
resReq: true,
|
|
229
|
-
attempts: 3
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
type: RequestOrFunction.FUNCTION,
|
|
235
|
-
function: {
|
|
236
|
-
id: 'transform-data',
|
|
237
|
-
functionOptions: {
|
|
238
|
-
fn: (data: ApiResponse[], threshold: number): TransformResult => ({
|
|
239
|
-
transformed: data.filter(item => item.id > threshold),
|
|
240
|
-
count: data.length
|
|
241
|
-
}),
|
|
242
|
-
args: [[], 10] as TransformArgs,
|
|
243
|
-
returnResult: true,
|
|
244
|
-
attempts: 2,
|
|
245
|
-
cache: { enabled: true, ttl: 5000 }
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
},
|
|
249
|
-
{
|
|
250
|
-
type: RequestOrFunction.FUNCTION,
|
|
251
|
-
function: {
|
|
252
|
-
id: 'validate-result',
|
|
253
|
-
functionOptions: {
|
|
254
|
-
fn: (result: TransformResult): ValidateResult => result.count > 0,
|
|
255
|
-
args: [{ transformed: [], count: 0 }] as ValidateArgs,
|
|
256
|
-
returnResult: true
|
|
257
|
-
}
|
|
209
|
+
// Define replay handlers
|
|
210
|
+
const handlers: Record<string, StableBufferReplayHandler> = {
|
|
211
|
+
'processOrder': async (state, log): Promise<void> => {
|
|
212
|
+
const typedState = state as OrderState;
|
|
213
|
+
const params = log.hookParams as OrderHookParams;
|
|
214
|
+
typedState.orders.push(params.orderId);
|
|
215
|
+
},
|
|
216
|
+
'updateInventory': async (state, log): Promise<void> => {
|
|
217
|
+
const typedState = state as OrderState;
|
|
218
|
+
const params = log.hookParams as InventoryHookParams;
|
|
219
|
+
typedState.inventory[params.sku] -= params.quantity;
|
|
258
220
|
}
|
|
259
|
-
}
|
|
260
|
-
];
|
|
261
|
-
|
|
262
|
-
const responses = await stableApiGateway<ApiRequestData, ApiResponse>(items, {
|
|
263
|
-
concurrentExecution: true,
|
|
264
|
-
stopOnFirstError: false,
|
|
265
|
-
sharedBuffer: {},
|
|
266
|
-
commonAttempts: 2,
|
|
267
|
-
commonWait: 300,
|
|
268
|
-
maxConcurrentRequests: 3
|
|
269
|
-
});
|
|
221
|
+
};
|
|
270
222
|
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
223
|
+
// Replay with custom handlers
|
|
224
|
+
const result: StableBufferReplayResult = await replayStableBufferTransactions({
|
|
225
|
+
logs,
|
|
226
|
+
handlers,
|
|
227
|
+
initialState: { orders: [], inventory: {} } as OrderState,
|
|
228
|
+
dedupe: true, // Skip duplicate transactions
|
|
229
|
+
sort: true // Order by timestamp
|
|
230
|
+
});
|
|
275
231
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
console.log(`Execution time: ${responses.metrics.executionTime}ms`);
|
|
279
|
-
console.log(`Throughput: ${responses.metrics.throughput.toFixed(2)} req/s`);
|
|
280
|
-
console.log(`Average duration: ${responses.metrics.averageRequestDuration.toFixed(2)}ms`);
|
|
232
|
+
console.log('Replayed state:', result.buffer.read() as OrderState);
|
|
233
|
+
})();
|
|
281
234
|
```
|
|
282
235
|
|
|
283
|
-
|
|
236
|
+
### 💾 State Persistence
|
|
284
237
|
|
|
285
|
-
|
|
238
|
+
Persist and restore state across executions:
|
|
286
239
|
|
|
287
240
|
```typescript
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
enableRacing: true, // First successful item wins, others cancelled
|
|
291
|
-
maxConcurrentRequests: 10
|
|
292
|
-
});
|
|
241
|
+
import { stableRequest, StableBuffer, PersistenceStage, REQUEST_METHODS } from 'stable-request';
|
|
242
|
+
import type { STABLE_REQUEST_RESULT, StatePersistenceConfig, StatePersistenceOptions } from 'stable-request';
|
|
293
243
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
244
|
+
interface ApiResponse {
|
|
245
|
+
data: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface BufferState {
|
|
249
|
+
lastFetched: string | null;
|
|
250
|
+
}
|
|
297
251
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
252
|
+
(async () => {
|
|
253
|
+
const buffer = new StableBuffer({
|
|
254
|
+
initialState: { lastFetched: null } as BufferState
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const statePersistence: StatePersistenceConfig = {
|
|
258
|
+
persistenceFunction: async (options: StatePersistenceOptions): Promise<Record<string, any>> => {
|
|
259
|
+
const { executionContext, buffer, persistenceStage } = options;
|
|
260
|
+
if (persistenceStage === PersistenceStage.BEFORE_HOOK) {
|
|
261
|
+
return await loadStateFromDB(executionContext);
|
|
262
|
+
} else {
|
|
263
|
+
await saveStateToDB(executionContext, buffer);
|
|
264
|
+
return buffer;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
loadBeforeHooks: true,
|
|
268
|
+
storeAfterHooks: true
|
|
269
|
+
};
|
|
306
270
|
|
|
307
|
-
|
|
271
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
272
|
+
reqData: {
|
|
273
|
+
hostname: 'api.example.com',
|
|
274
|
+
path: '/data',
|
|
275
|
+
method: REQUEST_METHODS.GET
|
|
276
|
+
},
|
|
277
|
+
resReq: true,
|
|
278
|
+
commonBuffer: buffer,
|
|
279
|
+
statePersistence
|
|
280
|
+
});
|
|
281
|
+
})();
|
|
282
|
+
```
|
|
308
283
|
|
|
309
|
-
|
|
284
|
+
### 🪝 Lifecycle Hooks
|
|
310
285
|
|
|
311
|
-
|
|
286
|
+
Tap into every stage of request execution:
|
|
312
287
|
|
|
313
288
|
```typescript
|
|
314
|
-
import {
|
|
315
|
-
import type {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
289
|
+
import { stableRequest, REQUEST_METHODS } from 'stable-request';
|
|
290
|
+
import type {
|
|
291
|
+
STABLE_REQUEST_RESULT,
|
|
292
|
+
STABLE_REQUEST,
|
|
293
|
+
PreExecutionHookOptions,
|
|
294
|
+
ResponseAnalysisHookOptions,
|
|
295
|
+
HandleErrorHookOptions,
|
|
296
|
+
HandleSuccessfulAttemptDataHookOptions,
|
|
297
|
+
FinalErrorAnalysisHookOptions
|
|
298
|
+
} from 'stable-request';
|
|
323
299
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
type AuditArgs = [ProcessResult, string];
|
|
331
|
-
type AuditResult = { logged: boolean; timestamp: string };
|
|
300
|
+
interface ApiResponse {
|
|
301
|
+
status: 'success' | 'pending' | 'failed';
|
|
302
|
+
data?: unknown;
|
|
303
|
+
}
|
|
332
304
|
|
|
333
|
-
|
|
334
|
-
{
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
305
|
+
(async () => {
|
|
306
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
307
|
+
reqData: {
|
|
308
|
+
hostname: 'api.example.com',
|
|
309
|
+
path: '/data',
|
|
310
|
+
method: REQUEST_METHODS.GET
|
|
311
|
+
},
|
|
312
|
+
resReq: true, // Return response data
|
|
313
|
+
|
|
314
|
+
// Pre-execution hook
|
|
315
|
+
preExecution: {
|
|
316
|
+
preExecutionHook: async (options: PreExecutionHookOptions<void, ApiResponse>): Promise<Partial<STABLE_REQUEST<void, ApiResponse>>> => {
|
|
317
|
+
const { inputParams, commonBuffer, stableRequestOptions } = options;
|
|
318
|
+
// Modify options before execution
|
|
319
|
+
return {
|
|
340
320
|
reqData: {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
},
|
|
344
|
-
resReq: true,
|
|
345
|
-
attempts: 3
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
]
|
|
349
|
-
},
|
|
350
|
-
{
|
|
351
|
-
id: 'process-and-audit',
|
|
352
|
-
markConcurrentPhase: true,
|
|
353
|
-
items: [
|
|
354
|
-
{
|
|
355
|
-
type: RequestOrFunction.FUNCTION,
|
|
356
|
-
function: {
|
|
357
|
-
id: 'process-data',
|
|
358
|
-
functionOptions: {
|
|
359
|
-
fn: (data: FetchResponse): ProcessResult => ({
|
|
360
|
-
merged: data.users.map((user, idx) => ({
|
|
361
|
-
userId: user.id,
|
|
362
|
-
userName: user.name,
|
|
363
|
-
postTitle: data.posts[idx]?.title || 'No post'
|
|
364
|
-
}))
|
|
365
|
-
}),
|
|
366
|
-
args: [{ users: [], posts: [] }] as ProcessArgs,
|
|
367
|
-
returnResult: true
|
|
321
|
+
...stableRequestOptions.reqData,
|
|
322
|
+
headers: { 'X-Custom': 'value' }
|
|
368
323
|
}
|
|
369
|
-
}
|
|
324
|
+
};
|
|
370
325
|
},
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
326
|
+
applyPreExecutionConfigOverride: true
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
// Response validation
|
|
330
|
+
responseAnalyzer: async (options: ResponseAnalysisHookOptions<void, ApiResponse>): Promise<boolean> => {
|
|
331
|
+
const { data, trialMode, commonBuffer } = options;
|
|
332
|
+
return data.status === 'success'; // Return true if response is valid
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
// Error handling
|
|
336
|
+
logAllErrors: true,
|
|
337
|
+
handleErrors: async (options: HandleErrorHookOptions<void>): Promise<void> => {
|
|
338
|
+
const { reqData, errorLog, commonBuffer } = options;
|
|
339
|
+
await logToMonitoring(errorLog);
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
// Success tracking
|
|
343
|
+
logAllSuccessfulAttempts: true,
|
|
344
|
+
handleSuccessfulAttemptData: async (options: HandleSuccessfulAttemptDataHookOptions<void, ApiResponse>): Promise<void> => {
|
|
345
|
+
const { successfulAttemptData } = options;
|
|
346
|
+
await trackMetric('request_success', successfulAttemptData);
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
// Final error analysis
|
|
350
|
+
finalErrorAnalyzer: async (options: FinalErrorAnalysisHookOptions<void>): Promise<boolean> => {
|
|
351
|
+
const { error, commonBuffer } = options;
|
|
352
|
+
return error.message.includes('temporary'); // Return true if handled
|
|
391
353
|
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
id: 'store-result',
|
|
398
|
-
requestOptions: {
|
|
399
|
-
reqData: {
|
|
400
|
-
hostname: 'api.example.com',
|
|
401
|
-
path: '/store',
|
|
402
|
-
method: REQUEST_METHODS.POST
|
|
403
|
-
},
|
|
404
|
-
resReq: false
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
]
|
|
408
|
-
}
|
|
409
|
-
];
|
|
410
|
-
|
|
411
|
-
const result = await stableWorkflow(phases, {
|
|
412
|
-
workflowId: 'data-pipeline',
|
|
413
|
-
concurrentPhaseExecution: false, // Phases sequential
|
|
414
|
-
enableNonLinearExecution: true,
|
|
415
|
-
sharedBuffer: { userId: '123' },
|
|
416
|
-
commonAttempts: 2,
|
|
417
|
-
commonWait: 200,
|
|
418
|
-
handlePhaseCompletion: ({ phaseResult, workflowId }) => {
|
|
419
|
-
console.log(`Phase ${phaseResult.phaseId} complete in workflow ${workflowId}`);
|
|
420
|
-
}
|
|
421
|
-
});
|
|
354
|
+
});
|
|
355
|
+
})();
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
---
|
|
422
359
|
|
|
423
|
-
|
|
360
|
+
## 🎣 Hook Reference
|
|
361
|
+
|
|
362
|
+
stable-request provides **5 hooks** that let you inject business logic into the request lifecycle. Each hook serves a specific purpose and receives contextual information to make intelligent decisions.
|
|
363
|
+
|
|
364
|
+
### Hook Execution Flow
|
|
365
|
+
|
|
366
|
+
```
|
|
367
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
368
|
+
│ stableRequest() called │
|
|
369
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
370
|
+
│
|
|
371
|
+
▼
|
|
372
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
373
|
+
│ 1. preExecutionHook │
|
|
374
|
+
│ • Modify request config, inject auth tokens, validate inputs │
|
|
375
|
+
│ • Can override any stableRequest option │
|
|
376
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
377
|
+
│
|
|
378
|
+
▼
|
|
379
|
+
┌───────────────────────────────┐
|
|
380
|
+
│ Execute HTTP Request │
|
|
381
|
+
│ (attempt 1 of N) │
|
|
382
|
+
└───────────────────────────────┘
|
|
383
|
+
│ │
|
|
384
|
+
Success (2xx) Network/HTTP Error
|
|
385
|
+
│ │
|
|
386
|
+
▼ ▼
|
|
387
|
+
┌──────────────────────────────────┐ ┌─────────────────────────────────────┐
|
|
388
|
+
│ 2. responseAnalyzer │ │ 3. handleErrors │
|
|
389
|
+
│ • Validate business logic │ │ • Log error, alert, track │
|
|
390
|
+
│ • Return false = retry │ │ • Called for each failed attempt│
|
|
391
|
+
└──────────────────────────────────┘ └─────────────────────────────────────┘
|
|
392
|
+
│ │ │
|
|
393
|
+
Return true Return false │
|
|
394
|
+
(valid) (invalid = retry) │
|
|
395
|
+
│ │ │
|
|
396
|
+
│ └──────────────────────────────┤
|
|
397
|
+
│ │
|
|
398
|
+
▼ ▼
|
|
399
|
+
┌──────────────────────────────────┐ ┌─────────────────────────────────┐
|
|
400
|
+
│ 4. handleSuccessfulAttemptData │ │ (Retry with backoff if │
|
|
401
|
+
│ • Track successful attempts │ │ attempts remaining) │
|
|
402
|
+
│ • Audit logging │ └─────────────────────────────────┘
|
|
403
|
+
└──────────────────────────────────┘ │
|
|
404
|
+
│ │
|
|
405
|
+
│ All attempts exhausted
|
|
406
|
+
│ │
|
|
407
|
+
▼ ▼
|
|
408
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
409
|
+
│ Return Result │
|
|
410
|
+
│ │ │
|
|
411
|
+
│ ┌───────────────┴───────────────┐ │
|
|
412
|
+
│ success: true success: false │
|
|
413
|
+
│ │ │
|
|
414
|
+
│ ▼ │
|
|
415
|
+
│ ┌─────────────────────────────────────┐ │
|
|
416
|
+
│ │ 5. finalErrorAnalyzer │ │
|
|
417
|
+
│ │ • Analyze final failure │ │
|
|
418
|
+
│ │ • Determine if error is fatal │ │
|
|
419
|
+
│ │ • Control throwOnFailedError │ │
|
|
420
|
+
│ └─────────────────────────────────────┘ │
|
|
421
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
424
422
|
```
|
|
425
423
|
|
|
426
|
-
|
|
427
|
-
- Execute phases sequentially or concurrently
|
|
428
|
-
- Support mixed requests and functions per phase
|
|
429
|
-
- Enable non-linear flow (CONTINUE, SKIP, REPLAY, JUMP, TERMINATE)
|
|
430
|
-
- Maintain shared buffer across all phases
|
|
431
|
-
- Apply phase-level and request-level config cascading
|
|
432
|
-
- Support branching with parallel/sequential branches
|
|
433
|
-
- Collect per-phase metrics and workflow aggregates
|
|
424
|
+
---
|
|
434
425
|
|
|
435
|
-
###
|
|
426
|
+
### 1. `preExecutionHook` - Pre-Request Setup
|
|
436
427
|
|
|
437
|
-
|
|
428
|
+
Called **once** before any attempt is made. Use it to modify request configuration, inject dynamic values, or validate preconditions.
|
|
438
429
|
|
|
439
430
|
```typescript
|
|
440
|
-
import {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
reqData: { hostname: 'api.example.com', path: '/posts' },
|
|
448
|
-
resReq: true
|
|
449
|
-
}
|
|
450
|
-
}]
|
|
451
|
-
})
|
|
452
|
-
.addPhase('fetch-users', {
|
|
453
|
-
requests: [{
|
|
454
|
-
id: 'get-users',
|
|
455
|
-
requestOptions: {
|
|
456
|
-
reqData: { hostname: 'api.example.com', path: '/users' },
|
|
457
|
-
resReq: true
|
|
458
|
-
}
|
|
459
|
-
}]
|
|
460
|
-
})
|
|
461
|
-
.addParallelGroup('fetch-all', ['fetch-posts', 'fetch-users'])
|
|
462
|
-
.addPhase('aggregate', {
|
|
463
|
-
functions: [{
|
|
464
|
-
id: 'combine',
|
|
465
|
-
functionOptions: {
|
|
466
|
-
fn: () => ({ posts: [], users: [] }),
|
|
467
|
-
args: [],
|
|
468
|
-
returnResult: true
|
|
469
|
-
}
|
|
470
|
-
}]
|
|
471
|
-
})
|
|
472
|
-
.addMergePoint('sync', ['fetch-all'])
|
|
473
|
-
.connectSequence('fetch-all', 'sync', 'aggregate')
|
|
474
|
-
.setEntryPoint('fetch-all')
|
|
475
|
-
.build();
|
|
476
|
-
|
|
477
|
-
const result = await stableWorkflowGraph(graph, {
|
|
478
|
-
workflowId: 'data-aggregation'
|
|
479
|
-
});
|
|
431
|
+
import { stableRequest, REQUEST_METHODS } from 'stable-request';
|
|
432
|
+
import type {
|
|
433
|
+
STABLE_REQUEST_RESULT,
|
|
434
|
+
STABLE_REQUEST,
|
|
435
|
+
PreExecutionHookOptions,
|
|
436
|
+
RequestPreExecutionOptions
|
|
437
|
+
} from 'stable-request';
|
|
480
438
|
|
|
481
|
-
|
|
482
|
-
|
|
439
|
+
interface OrderRequest {
|
|
440
|
+
productId: string;
|
|
441
|
+
quantity: number;
|
|
442
|
+
}
|
|
483
443
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
444
|
+
interface OrderResponse {
|
|
445
|
+
orderId: string;
|
|
446
|
+
status: string;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
(async () => {
|
|
450
|
+
const preExecutionConfig: RequestPreExecutionOptions<OrderRequest, OrderResponse> = {
|
|
451
|
+
preExecutionHook: async (options: PreExecutionHookOptions<OrderRequest, OrderResponse>): Promise<Partial<STABLE_REQUEST<OrderRequest, OrderResponse>>> => {
|
|
452
|
+
const { inputParams, commonBuffer, stableRequestOptions, transactionLogs } = options;
|
|
453
|
+
|
|
454
|
+
// Inject fresh auth token
|
|
455
|
+
const token: string = await getAuthToken();
|
|
456
|
+
|
|
457
|
+
// Validate business preconditions
|
|
458
|
+
if (!commonBuffer?.userId) {
|
|
459
|
+
throw new Error('User ID required');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Return partial config to merge (if applyPreExecutionConfigOverride is true)
|
|
463
|
+
return {
|
|
464
|
+
reqData: {
|
|
465
|
+
...stableRequestOptions.reqData,
|
|
466
|
+
headers: {
|
|
467
|
+
...stableRequestOptions.reqData.headers,
|
|
468
|
+
'Authorization': `Bearer ${token}`,
|
|
469
|
+
'X-User-Id': commonBuffer.userId
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
},
|
|
474
|
+
preExecutionHookParams: { customData: 'value' }, // Passed as inputParams
|
|
475
|
+
applyPreExecutionConfigOverride: true, // Merge returned config
|
|
476
|
+
continueOnPreExecutionHookFailure: false // Fail fast if hook throws
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const result: STABLE_REQUEST_RESULT<OrderResponse> = await stableRequest<OrderRequest, OrderResponse>({
|
|
480
|
+
reqData: {
|
|
481
|
+
hostname: 'api.example.com',
|
|
482
|
+
path: '/orders',
|
|
483
|
+
method: REQUEST_METHODS.POST
|
|
484
|
+
},
|
|
485
|
+
resReq: true,
|
|
486
|
+
preExecution: preExecutionConfig
|
|
487
|
+
});
|
|
488
|
+
})();
|
|
489
|
+
```
|
|
491
490
|
|
|
492
|
-
|
|
491
|
+
**💡 Use cases:**
|
|
492
|
+
- Inject fresh authentication tokens
|
|
493
|
+
- Add request signing/HMAC
|
|
494
|
+
- Validate preconditions before making the request
|
|
495
|
+
- Dynamically modify endpoints based on state
|
|
496
|
+
- Load configuration from external sources
|
|
493
497
|
|
|
494
|
-
|
|
498
|
+
---
|
|
495
499
|
|
|
496
|
-
|
|
497
|
-
- Enforce max-parallel job execution
|
|
498
|
-
- Schedule jobs with cron, interval, or timestamp(s)
|
|
499
|
-
- Persist and restore scheduler state via user-provided handlers
|
|
500
|
+
### 2. `responseAnalyzer` - Business Logic Validation
|
|
500
501
|
|
|
501
|
-
|
|
502
|
+
Called after **each successful HTTP response** (2xx status). This is where you validate that the response meets your business requirements.
|
|
502
503
|
|
|
503
|
-
|
|
504
|
+
> **Key insight:** Return `true` if the response is acceptable, `false` to trigger a retry.
|
|
504
505
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
506
|
+
```typescript
|
|
507
|
+
import { stableRequest, StableBuffer, REQUEST_METHODS, RETRY_STRATEGIES } from 'stable-request';
|
|
508
|
+
import type {
|
|
509
|
+
STABLE_REQUEST_RESULT,
|
|
510
|
+
ResponseAnalysisHookOptions,
|
|
511
|
+
HookParams
|
|
512
|
+
} from 'stable-request';
|
|
510
513
|
|
|
511
|
-
|
|
512
|
-
|
|
514
|
+
interface PaymentRequest {
|
|
515
|
+
cardToken: string;
|
|
516
|
+
amount: number;
|
|
517
|
+
}
|
|
513
518
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
519
|
+
interface PaymentResponse {
|
|
520
|
+
status: 'pending' | 'processing' | 'completed' | 'failed';
|
|
521
|
+
transactionId?: string;
|
|
522
|
+
receiptUrl?: string;
|
|
523
|
+
amount?: number;
|
|
524
|
+
error?: string;
|
|
525
|
+
errorCode?: string;
|
|
526
|
+
}
|
|
521
527
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
528
|
+
(async () => {
|
|
529
|
+
// Create buffer to track state across retries
|
|
530
|
+
const buffer = new StableBuffer({
|
|
531
|
+
initialState: { expectedAmount: 100, transactionId: null }
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const hookParams: HookParams = {
|
|
535
|
+
responseAnalyzerParams: { expectedStatus: 'completed' } // Passed as params
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const result: STABLE_REQUEST_RESULT<PaymentResponse> = await stableRequest<PaymentRequest, PaymentResponse>({
|
|
539
|
+
reqData: {
|
|
540
|
+
hostname: 'payment.api.com',
|
|
541
|
+
path: '/charge',
|
|
542
|
+
method: REQUEST_METHODS.POST,
|
|
543
|
+
body: { cardToken: 'tok_xxx', amount: 100 }
|
|
544
|
+
},
|
|
545
|
+
resReq: true, // Must be true to receive response data
|
|
546
|
+
attempts: 5,
|
|
547
|
+
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
548
|
+
commonBuffer: buffer,
|
|
549
|
+
|
|
550
|
+
responseAnalyzer: async (options: ResponseAnalysisHookOptions<PaymentRequest, PaymentResponse>): Promise<boolean> => {
|
|
551
|
+
const { reqData, data, trialMode, params, commonBuffer, executionContext } = options;
|
|
552
|
+
|
|
553
|
+
// Example 1: Wait for async processing to complete
|
|
554
|
+
if (data.status === 'pending' || data.status === 'processing') {
|
|
555
|
+
console.log('Payment still processing, will retry...');
|
|
556
|
+
return false; // Retry
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Example 2: Validate response has required data
|
|
560
|
+
if (!data.transactionId || !data.receiptUrl) {
|
|
561
|
+
console.log('Incomplete response, will retry...');
|
|
562
|
+
return false; // Retry
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Example 3: Check for soft errors in response body
|
|
566
|
+
if (data.error || data.errorCode) {
|
|
567
|
+
console.log(`Soft error: ${data.errorCode}, will retry...`);
|
|
568
|
+
return false; // Retry
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Example 4: Validate against business rules
|
|
572
|
+
if (data.amount !== commonBuffer?.expectedAmount) {
|
|
573
|
+
console.log('Amount mismatch, will retry...');
|
|
574
|
+
return false; // Retry
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Success - accept this response
|
|
578
|
+
if (commonBuffer) {
|
|
579
|
+
commonBuffer.transactionId = data.transactionId;
|
|
580
|
+
}
|
|
581
|
+
return true;
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
hookParams
|
|
585
|
+
});
|
|
586
|
+
})();
|
|
526
587
|
```
|
|
527
588
|
|
|
528
|
-
|
|
589
|
+
**💡 Use cases:**
|
|
590
|
+
- Poll until async operation completes (`status: pending` → `status: completed`)
|
|
591
|
+
- Validate response data integrity
|
|
592
|
+
- Check for soft errors in response body (APIs that return 200 with error payloads)
|
|
593
|
+
- Ensure eventual consistency (retry until data propagates)
|
|
594
|
+
- Validate business invariants
|
|
529
595
|
|
|
530
|
-
|
|
531
|
-
import { replayStableBufferTransactions } from '@emmvish/stable-request';
|
|
596
|
+
---
|
|
532
597
|
|
|
533
|
-
|
|
534
|
-
logs, // StableBufferTransactionLog[]
|
|
535
|
-
handlers: {
|
|
536
|
-
'phase-1': (state) => { state.counter += 1; }
|
|
537
|
-
},
|
|
538
|
-
initialState: { counter: 0 }
|
|
539
|
-
});
|
|
598
|
+
### 3. `handleErrors` - Error Observation & Logging
|
|
540
599
|
|
|
541
|
-
|
|
542
|
-
```
|
|
600
|
+
Called after **each failed attempt** (network error, non-2xx status, or `responseAnalyzer` returning `false`). Use for observability - this hook doesn't affect retry behavior.
|
|
543
601
|
|
|
544
|
-
|
|
602
|
+
> **Note:** Only called when `logAllErrors: true`
|
|
545
603
|
|
|
546
|
-
|
|
604
|
+
```typescript
|
|
605
|
+
import { stableRequest, StableBuffer, REQUEST_METHODS, RETRY_STRATEGIES } from 'stable-request';
|
|
606
|
+
import type {
|
|
607
|
+
STABLE_REQUEST_RESULT,
|
|
608
|
+
HandleErrorHookOptions,
|
|
609
|
+
ERROR_LOG,
|
|
610
|
+
HookParams
|
|
611
|
+
} from 'stable-request';
|
|
547
612
|
|
|
548
|
-
|
|
613
|
+
interface ApiResponse {
|
|
614
|
+
data: unknown;
|
|
615
|
+
}
|
|
549
616
|
|
|
550
|
-
|
|
617
|
+
interface ErrorHistoryEntry {
|
|
618
|
+
timestamp: string;
|
|
619
|
+
attempt: string;
|
|
620
|
+
error: string;
|
|
621
|
+
statusCode: number;
|
|
622
|
+
}
|
|
551
623
|
|
|
552
|
-
|
|
624
|
+
(async () => {
|
|
625
|
+
// Create buffer to track errors
|
|
626
|
+
const buffer = new StableBuffer({
|
|
627
|
+
initialState: { errorHistory: [] as ErrorHistoryEntry[] }
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
const hookParams: HookParams = {
|
|
631
|
+
handleErrorsParams: { alertChannel: '#api-errors' }
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
635
|
+
reqData: {
|
|
636
|
+
hostname: 'api.example.com',
|
|
637
|
+
path: '/data',
|
|
638
|
+
method: REQUEST_METHODS.GET
|
|
639
|
+
},
|
|
640
|
+
resReq: true,
|
|
641
|
+
attempts: 3,
|
|
642
|
+
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
643
|
+
logAllErrors: true, // Required to trigger handleErrors
|
|
644
|
+
commonBuffer: buffer,
|
|
645
|
+
|
|
646
|
+
handleErrors: async (options: HandleErrorHookOptions<void>): Promise<void> => {
|
|
647
|
+
const { reqData, errorLog, maxSerializableChars, params, commonBuffer, executionContext } = options;
|
|
648
|
+
|
|
649
|
+
// errorLog contains detailed error information
|
|
650
|
+
const { timestamp, attempt, error, type, isRetryable, executionTime, statusCode }: ERROR_LOG = errorLog;
|
|
651
|
+
|
|
652
|
+
// Log to monitoring system
|
|
653
|
+
await sendToDatadog({
|
|
654
|
+
level: 'error',
|
|
655
|
+
message: error,
|
|
656
|
+
tags: {
|
|
657
|
+
attempt,
|
|
658
|
+
statusCode,
|
|
659
|
+
isRetryable,
|
|
660
|
+
workflowId: executionContext?.workflowId,
|
|
661
|
+
requestId: executionContext?.requestId
|
|
662
|
+
},
|
|
663
|
+
executionTime
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Track in shared buffer for analysis
|
|
667
|
+
if (commonBuffer) {
|
|
668
|
+
commonBuffer.errorHistory = commonBuffer.errorHistory || [];
|
|
669
|
+
commonBuffer.errorHistory.push({
|
|
670
|
+
timestamp,
|
|
671
|
+
attempt,
|
|
672
|
+
error,
|
|
673
|
+
statusCode
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Alert on specific error types
|
|
678
|
+
if (statusCode === 503) {
|
|
679
|
+
await sendSlackAlert(`Service unavailable: ${reqData.url}`);
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
hookParams
|
|
684
|
+
});
|
|
685
|
+
})();
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
**Error Log Structure:**
|
|
689
|
+
```typescript
|
|
690
|
+
interface ERROR_LOG {
|
|
691
|
+
timestamp: string; // ISO timestamp of the error
|
|
692
|
+
attempt: string; // e.g., "2/5" (attempt 2 of 5)
|
|
693
|
+
error: string; // Error message
|
|
694
|
+
type: 'HTTP_ERROR' | 'INVALID_CONTENT'; // HTTP error or responseAnalyzer rejection
|
|
695
|
+
isRetryable: boolean; // Whether this error qualifies for retry
|
|
696
|
+
executionTime: number; // Time taken for this attempt (ms)
|
|
697
|
+
statusCode: number; // HTTP status code (0 for network errors)
|
|
698
|
+
}
|
|
699
|
+
```
|
|
553
700
|
|
|
554
|
-
|
|
701
|
+
**💡 Use cases:**
|
|
702
|
+
- Send errors to monitoring (Datadog, New Relic, Sentry)
|
|
703
|
+
- Track error patterns for circuit breaker decisions
|
|
704
|
+
- Alert on specific error types
|
|
705
|
+
- Build error history for debugging
|
|
706
|
+
- Audit logging
|
|
555
707
|
|
|
556
|
-
|
|
708
|
+
---
|
|
557
709
|
|
|
558
|
-
|
|
710
|
+
### 4. `handleSuccessfulAttemptData` - Success Observation
|
|
559
711
|
|
|
560
|
-
|
|
712
|
+
Called after **each successful attempt** (HTTP 2xx + `responseAnalyzer` returns `true`). Use for observability and tracking.
|
|
561
713
|
|
|
562
|
-
|
|
714
|
+
> **Note:** Only called when `logAllSuccessfulAttempts: true`. Most useful with `performAllAttempts: true` for polling scenarios.
|
|
563
715
|
|
|
564
716
|
```typescript
|
|
565
|
-
import {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
return 'success';
|
|
572
|
-
},
|
|
573
|
-
args: [],
|
|
574
|
-
returnResult: true,
|
|
575
|
-
executionTimeout: 5000, // 5 seconds max
|
|
576
|
-
attempts: 3,
|
|
577
|
-
});
|
|
717
|
+
import { stableRequest, StableBuffer, REQUEST_METHODS, RETRY_STRATEGIES } from 'stable-request';
|
|
718
|
+
import type {
|
|
719
|
+
STABLE_REQUEST_RESULT,
|
|
720
|
+
HandleSuccessfulAttemptDataHookOptions,
|
|
721
|
+
SUCCESSFUL_ATTEMPT_DATA
|
|
722
|
+
} from 'stable-request';
|
|
578
723
|
|
|
579
|
-
|
|
580
|
-
|
|
724
|
+
interface HealthResponse {
|
|
725
|
+
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
726
|
+
uptime: number;
|
|
581
727
|
}
|
|
582
|
-
```
|
|
583
728
|
|
|
584
|
-
|
|
729
|
+
interface ResponseHistoryEntry {
|
|
730
|
+
attempt: string;
|
|
731
|
+
timestamp: string;
|
|
732
|
+
status: string;
|
|
733
|
+
latency: number;
|
|
734
|
+
}
|
|
585
735
|
|
|
586
|
-
|
|
736
|
+
(async () => {
|
|
737
|
+
// Create buffer to track response history
|
|
738
|
+
const buffer = new StableBuffer({
|
|
739
|
+
initialState: { responseHistory: [] as ResponseHistoryEntry[] }
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
const result: STABLE_REQUEST_RESULT<HealthResponse> = await stableRequest<void, HealthResponse>({
|
|
743
|
+
reqData: {
|
|
744
|
+
hostname: 'api.example.com',
|
|
745
|
+
path: '/health',
|
|
746
|
+
method: REQUEST_METHODS.GET
|
|
747
|
+
},
|
|
748
|
+
resReq: true,
|
|
749
|
+
attempts: 10,
|
|
750
|
+
retryStrategy: RETRY_STRATEGIES.LINEAR,
|
|
751
|
+
performAllAttempts: true, // Continue even after success
|
|
752
|
+
logAllSuccessfulAttempts: true, // Required to trigger this hook
|
|
753
|
+
commonBuffer: buffer,
|
|
754
|
+
|
|
755
|
+
handleSuccessfulAttemptData: async (options: HandleSuccessfulAttemptDataHookOptions<void, HealthResponse>): Promise<void> => {
|
|
756
|
+
const { reqData, successfulAttemptData, maxSerializableChars, params, commonBuffer } = options;
|
|
757
|
+
const { attempt, timestamp, data, executionTime, statusCode }: SUCCESSFUL_ATTEMPT_DATA<HealthResponse> = successfulAttemptData;
|
|
758
|
+
|
|
759
|
+
// Track latency metrics
|
|
760
|
+
await sendMetric('api_latency', executionTime, {
|
|
761
|
+
endpoint: reqData.url,
|
|
762
|
+
attempt
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// Build response history (useful for polling scenarios)
|
|
766
|
+
if (commonBuffer) {
|
|
767
|
+
commonBuffer.responseHistory = commonBuffer.responseHistory || [];
|
|
768
|
+
commonBuffer.responseHistory.push({
|
|
769
|
+
attempt,
|
|
770
|
+
timestamp,
|
|
771
|
+
status: data.status,
|
|
772
|
+
latency: executionTime
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Log successful recovery
|
|
777
|
+
if (attempt !== '1/10') {
|
|
778
|
+
console.log(`Recovered on attempt ${attempt} after ${executionTime}ms`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
})();
|
|
783
|
+
```
|
|
587
784
|
|
|
785
|
+
**Successful Attempt Data Structure:**
|
|
588
786
|
```typescript
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
id: 'task1',
|
|
597
|
-
functionOptions: {
|
|
598
|
-
fn: async () => await task1(),
|
|
599
|
-
args: [],
|
|
600
|
-
// No timeout specified - inherits from gateway
|
|
601
|
-
},
|
|
602
|
-
},
|
|
603
|
-
},
|
|
604
|
-
{
|
|
605
|
-
type: RequestOrFunction.FUNCTION,
|
|
606
|
-
function: {
|
|
607
|
-
id: 'task2',
|
|
608
|
-
functionOptions: {
|
|
609
|
-
fn: async () => await task2(),
|
|
610
|
-
args: [],
|
|
611
|
-
executionTimeout: 10000, // Override gateway timeout
|
|
612
|
-
},
|
|
613
|
-
},
|
|
614
|
-
},
|
|
615
|
-
],
|
|
616
|
-
{
|
|
617
|
-
commonExecutionTimeout: 3000, // Default 3s for all functions
|
|
618
|
-
}
|
|
619
|
-
);
|
|
787
|
+
interface SUCCESSFUL_ATTEMPT_DATA<ResponseDataType> {
|
|
788
|
+
attempt: string; // e.g., "3/5"
|
|
789
|
+
timestamp: string; // ISO timestamp
|
|
790
|
+
data: ResponseDataType; // Response data
|
|
791
|
+
executionTime: number; // Time taken (ms)
|
|
792
|
+
statusCode: number; // HTTP status code
|
|
793
|
+
}
|
|
620
794
|
```
|
|
621
795
|
|
|
622
|
-
|
|
796
|
+
**💡 Use cases:**
|
|
797
|
+
- Track latency percentiles
|
|
798
|
+
- Monitor recovery patterns (which attempts succeed?)
|
|
799
|
+
- Build response history for polling workflows
|
|
800
|
+
- Celebrate successful retries in observability dashboards
|
|
623
801
|
|
|
624
|
-
|
|
802
|
+
---
|
|
625
803
|
|
|
626
|
-
|
|
627
|
-
const results = await stableApiGateway(
|
|
628
|
-
[
|
|
629
|
-
{
|
|
630
|
-
type: RequestOrFunction.FUNCTION,
|
|
631
|
-
function: {
|
|
632
|
-
id: 'critical',
|
|
633
|
-
groupId: 'criticalOps',
|
|
634
|
-
functionOptions: { fn: criticalOp, args: [] },
|
|
635
|
-
},
|
|
636
|
-
},
|
|
637
|
-
{
|
|
638
|
-
type: RequestOrFunction.FUNCTION,
|
|
639
|
-
function: {
|
|
640
|
-
id: 'background',
|
|
641
|
-
groupId: 'backgroundOps',
|
|
642
|
-
functionOptions: { fn: backgroundOp, args: [] },
|
|
643
|
-
},
|
|
644
|
-
},
|
|
645
|
-
],
|
|
646
|
-
{
|
|
647
|
-
requestGroups: [
|
|
648
|
-
{
|
|
649
|
-
id: 'criticalOps',
|
|
650
|
-
commonConfig: {
|
|
651
|
-
commonExecutionTimeout: 1000, // Strict 1s timeout
|
|
652
|
-
},
|
|
653
|
-
},
|
|
654
|
-
{
|
|
655
|
-
id: 'backgroundOps',
|
|
656
|
-
commonConfig: {
|
|
657
|
-
commonExecutionTimeout: 30000, // Lenient 30s timeout
|
|
658
|
-
},
|
|
659
|
-
},
|
|
660
|
-
],
|
|
661
|
-
}
|
|
662
|
-
);
|
|
663
|
-
```
|
|
804
|
+
### 5. `finalErrorAnalyzer` - Final Failure Analysis
|
|
664
805
|
|
|
665
|
-
|
|
806
|
+
Called **once** when all retry attempts have been exhausted and the request has failed. This is your last chance to analyze the failure and decide how to handle it.
|
|
666
807
|
|
|
667
|
-
|
|
808
|
+
> **Key insight:** Return `true` if you've handled the error gracefully, `false` to let it propagate. Works with `throwOnFailedErrorAnalysis` option.
|
|
668
809
|
|
|
669
810
|
```typescript
|
|
670
|
-
import {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
811
|
+
import { stableRequest, StableBuffer, REQUEST_METHODS, RETRY_STRATEGIES } from 'stable-request';
|
|
812
|
+
import type {
|
|
813
|
+
STABLE_REQUEST_RESULT,
|
|
814
|
+
FinalErrorAnalysisHookOptions,
|
|
815
|
+
HookParams,
|
|
816
|
+
ERROR_LOG
|
|
817
|
+
} from 'stable-request';
|
|
818
|
+
|
|
819
|
+
interface CriticalResponse {
|
|
820
|
+
result: string;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
(async () => {
|
|
824
|
+
// Create buffer with fallback state
|
|
825
|
+
const buffer = new StableBuffer({
|
|
826
|
+
initialState: {
|
|
827
|
+
errorHistory: [] as ERROR_LOG[],
|
|
828
|
+
isMaintenanceMode: false,
|
|
829
|
+
cachedResponse: null as CriticalResponse | null,
|
|
830
|
+
useFallback: false
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const hookParams: HookParams = {
|
|
835
|
+
finalErrorAnalyzerParams: { allowFailure: false }
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
const result: STABLE_REQUEST_RESULT<CriticalResponse> = await stableRequest<void, CriticalResponse>({
|
|
839
|
+
reqData: {
|
|
840
|
+
hostname: 'api.example.com',
|
|
841
|
+
path: '/critical-operation',
|
|
842
|
+
method: REQUEST_METHODS.POST
|
|
688
843
|
},
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
844
|
+
resReq: true,
|
|
845
|
+
attempts: 5,
|
|
846
|
+
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
847
|
+
throwOnFailedErrorAnalysis: true, // Throw if finalErrorAnalyzer returns false
|
|
848
|
+
commonBuffer: buffer,
|
|
849
|
+
|
|
850
|
+
finalErrorAnalyzer: async (options: FinalErrorAnalysisHookOptions<void>): Promise<boolean> => {
|
|
851
|
+
const { reqData, error, trialMode, params, commonBuffer, executionContext } = options;
|
|
852
|
+
|
|
853
|
+
// Log comprehensive failure report
|
|
854
|
+
await logFinalFailure({
|
|
855
|
+
request: reqData,
|
|
856
|
+
error: error.message,
|
|
857
|
+
errorHistory: commonBuffer?.errorHistory,
|
|
858
|
+
context: executionContext
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// Check if this is a known/acceptable failure
|
|
862
|
+
if (error.message.includes('MAINTENANCE_MODE')) {
|
|
863
|
+
if (commonBuffer) commonBuffer.isMaintenanceMode = true;
|
|
864
|
+
return true; // Handled - won't throw
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Check if we should fall back to cached data
|
|
868
|
+
if (commonBuffer?.cachedResponse) {
|
|
869
|
+
commonBuffer.useFallback = true;
|
|
870
|
+
return true; // Handled - use fallback
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Check if this is a non-critical operation
|
|
874
|
+
if (params?.allowFailure) {
|
|
875
|
+
return true; // Handled - operation is optional
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Unhandled critical failure
|
|
879
|
+
await sendPagerDutyAlert({
|
|
880
|
+
severity: 'critical',
|
|
881
|
+
message: `Critical API failure after 5 attempts`,
|
|
882
|
+
context: executionContext
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
return false; // Not handled - will throw if throwOnFailedErrorAnalysis is true
|
|
703
886
|
},
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
);
|
|
887
|
+
|
|
888
|
+
hookParams
|
|
889
|
+
});
|
|
890
|
+
})();
|
|
709
891
|
```
|
|
710
892
|
|
|
711
|
-
|
|
893
|
+
**💡 Use cases:**
|
|
894
|
+
- Comprehensive failure reporting
|
|
895
|
+
- Determine if failure is recoverable vs. fatal
|
|
896
|
+
- Trigger fallback mechanisms
|
|
897
|
+
- Escalate to PagerDuty/on-call
|
|
898
|
+
- Mark operation as gracefully degraded
|
|
899
|
+
- Decide whether to throw or return error result
|
|
712
900
|
|
|
713
|
-
|
|
901
|
+
---
|
|
714
902
|
|
|
715
|
-
|
|
903
|
+
### Hook Parameters Summary
|
|
904
|
+
|
|
905
|
+
All hooks receive contextual information through their options parameter:
|
|
906
|
+
|
|
907
|
+
| Parameter | Description | Available In |
|
|
908
|
+
|-----------|-------------|--------------|
|
|
909
|
+
| `reqData` | Axios request configuration | responseAnalyzer, handleErrors, handleSuccessfulAttemptData, finalErrorAnalyzer |
|
|
910
|
+
| `data` | Response data | responseAnalyzer |
|
|
911
|
+
| `error` | Error object | finalErrorAnalyzer |
|
|
912
|
+
| `errorLog` | Detailed error information | handleErrors |
|
|
913
|
+
| `successfulAttemptData` | Success details | handleSuccessfulAttemptData |
|
|
914
|
+
| `trialMode` | Trial mode configuration | responseAnalyzer, finalErrorAnalyzer |
|
|
915
|
+
| `params` | Custom params from hookParams | All hooks |
|
|
916
|
+
| `preExecutionResult` | Return value from preExecutionHook | responseAnalyzer, handleErrors, handleSuccessfulAttemptData, finalErrorAnalyzer |
|
|
917
|
+
| `commonBuffer` | Shared state buffer | All hooks |
|
|
918
|
+
| `executionContext` | Workflow/phase/request IDs | All hooks |
|
|
919
|
+
| `transactionLogs` | Historical transaction logs | All hooks |
|
|
920
|
+
| `inputParams` | preExecutionHookParams | preExecutionHook |
|
|
921
|
+
| `stableRequestOptions` | Full stableRequest config | preExecutionHook |
|
|
716
922
|
|
|
717
|
-
|
|
718
|
-
- If not set, inherits from request group's `commonExecutionTimeout`
|
|
719
|
-
- If not set, inherits from phase/branch's `commonExecutionTimeout`
|
|
720
|
-
- If not set, inherits from gateway's `commonExecutionTimeout`
|
|
721
|
-
- If not set, no timeout is applied
|
|
923
|
+
---
|
|
722
924
|
|
|
723
|
-
|
|
925
|
+
### 📊 Comprehensive Metrics
|
|
724
926
|
|
|
725
|
-
|
|
726
|
-
- When timeout is exceeded, function returns failed result with timeout error
|
|
727
|
-
- Timeout does NOT stop execution mid-flight (no AbortController)
|
|
728
|
-
- Metrics are still collected even when timeout occurs
|
|
729
|
-
- Use with retries: timeout encompasses all attempts, not per-attempt
|
|
927
|
+
Get detailed metrics for every request:
|
|
730
928
|
|
|
731
929
|
```typescript
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
});
|
|
930
|
+
import { stableRequest, REQUEST_METHODS, RETRY_STRATEGIES } from 'stable-request';
|
|
931
|
+
import type {
|
|
932
|
+
STABLE_REQUEST_RESULT,
|
|
933
|
+
MetricsGuardrails,
|
|
934
|
+
StableRequestMetrics
|
|
935
|
+
} from 'stable-request';
|
|
739
936
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// - Attempt 3: would start at 3600ms → TIMEOUT at 3000ms
|
|
744
|
-
```
|
|
745
|
-
|
|
746
|
-
### Retry Strategies
|
|
747
|
-
|
|
748
|
-
When a request or function fails and is retryable, retry with configurable backoff.
|
|
937
|
+
interface ApiResponse {
|
|
938
|
+
data: string[];
|
|
939
|
+
}
|
|
749
940
|
|
|
750
|
-
|
|
941
|
+
(async () => {
|
|
942
|
+
const metricsGuardrails: MetricsGuardrails = {
|
|
943
|
+
request: {
|
|
944
|
+
totalAttempts: { max: 5 },
|
|
945
|
+
totalExecutionTime: { max: 10000 },
|
|
946
|
+
failedAttempts: { max: 2 }
|
|
947
|
+
}
|
|
948
|
+
};
|
|
751
949
|
|
|
752
|
-
|
|
950
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
951
|
+
reqData: {
|
|
952
|
+
hostname: 'api.example.com',
|
|
953
|
+
path: '/data',
|
|
954
|
+
method: REQUEST_METHODS.GET
|
|
955
|
+
},
|
|
956
|
+
resReq: true,
|
|
957
|
+
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
958
|
+
logAllErrors: true,
|
|
959
|
+
logAllSuccessfulAttempts: true,
|
|
960
|
+
metricsGuardrails
|
|
961
|
+
});
|
|
753
962
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
//
|
|
767
|
-
}
|
|
963
|
+
const metrics: StableRequestMetrics | undefined = result.metrics;
|
|
964
|
+
console.log(metrics);
|
|
965
|
+
// {
|
|
966
|
+
// totalAttempts: 3,
|
|
967
|
+
// successfulAttempts: 1,
|
|
968
|
+
// failedAttempts: 2,
|
|
969
|
+
// totalExecutionTime: 4532,
|
|
970
|
+
// averageAttemptTime: 1510,
|
|
971
|
+
// infrastructureMetrics: {
|
|
972
|
+
// circuitBreaker: { state: 'CLOSED', failurePercentage: 10, ... },
|
|
973
|
+
// cache: { hitRate: 45.5, missRate: 54.5, ... }
|
|
974
|
+
// },
|
|
975
|
+
// validation: { isValid: true, anomalies: [] }
|
|
976
|
+
// }
|
|
977
|
+
})();
|
|
768
978
|
```
|
|
769
979
|
|
|
770
|
-
|
|
980
|
+
### 🧪 Trial Mode (Chaos Engineering)
|
|
771
981
|
|
|
772
|
-
|
|
982
|
+
Test your error handling without hitting real endpoints:
|
|
773
983
|
|
|
774
984
|
```typescript
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
resReq: true,
|
|
778
|
-
attempts: 4,
|
|
779
|
-
wait: 100,
|
|
780
|
-
retryStrategy: RETRY_STRATEGIES.LINEAR
|
|
781
|
-
// Retries at: 100ms, 200ms, 300ms (wait * attempt)
|
|
782
|
-
});
|
|
783
|
-
```
|
|
784
|
-
|
|
785
|
-
#### EXPONENTIAL Strategy
|
|
985
|
+
import { stableRequest, REQUEST_METHODS } from 'stable-request';
|
|
986
|
+
import type { STABLE_REQUEST_RESULT, TRIAL_MODE_OPTIONS } from 'stable-request';
|
|
786
987
|
|
|
787
|
-
|
|
988
|
+
interface ApiResponse {
|
|
989
|
+
data: unknown;
|
|
990
|
+
}
|
|
788
991
|
|
|
789
|
-
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
992
|
+
(async () => {
|
|
993
|
+
const trialMode: TRIAL_MODE_OPTIONS = {
|
|
994
|
+
enabled: true,
|
|
995
|
+
reqFailureProbability: 0.3, // 30% chance of request failure
|
|
996
|
+
retryFailureProbability: 0.2 // 20% chance retry is not allowed
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
1000
|
+
reqData: {
|
|
1001
|
+
hostname: 'api.example.com',
|
|
1002
|
+
path: '/data',
|
|
1003
|
+
method: REQUEST_METHODS.GET
|
|
1004
|
+
},
|
|
1005
|
+
resReq: true,
|
|
1006
|
+
trialMode
|
|
1007
|
+
});
|
|
1008
|
+
})();
|
|
800
1009
|
```
|
|
801
1010
|
|
|
802
|
-
|
|
1011
|
+
### 🎯 Execution Context
|
|
803
1012
|
|
|
804
|
-
|
|
1013
|
+
Track requests across distributed systems:
|
|
805
1014
|
|
|
806
1015
|
```typescript
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
resReq: true,
|
|
810
|
-
attempts: 3,
|
|
811
|
-
wait: 500,
|
|
812
|
-
jitter: 200, // Add 0-200ms randomness
|
|
813
|
-
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL
|
|
814
|
-
});
|
|
815
|
-
```
|
|
816
|
-
|
|
817
|
-
#### Perform All Attempts
|
|
1016
|
+
import { stableRequest, REQUEST_METHODS } from 'stable-request';
|
|
1017
|
+
import type { STABLE_REQUEST_RESULT, ExecutionContext } from 'stable-request';
|
|
818
1018
|
|
|
819
|
-
|
|
1019
|
+
interface ApiResponse {
|
|
1020
|
+
data: unknown;
|
|
1021
|
+
}
|
|
820
1022
|
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1023
|
+
(async () => {
|
|
1024
|
+
const executionContext: ExecutionContext = {
|
|
1025
|
+
workflowId: 'order-processing-123',
|
|
1026
|
+
phaseId: 'payment-validation',
|
|
1027
|
+
requestId: 'req-456'
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
1031
|
+
reqData: {
|
|
1032
|
+
hostname: 'api.example.com',
|
|
1033
|
+
path: '/data',
|
|
1034
|
+
method: REQUEST_METHODS.GET
|
|
1035
|
+
},
|
|
1036
|
+
resReq: true,
|
|
1037
|
+
executionContext
|
|
1038
|
+
});
|
|
1039
|
+
// Logs will include: [Workflow: order-processing-123] [Phase: payment-validation] [Request: req-456]
|
|
1040
|
+
})();
|
|
829
1041
|
```
|
|
830
1042
|
|
|
831
|
-
|
|
1043
|
+
## StableBuffer API
|
|
832
1044
|
|
|
833
|
-
|
|
1045
|
+
### Constructor Options
|
|
834
1046
|
|
|
835
1047
|
```typescript
|
|
836
|
-
import {
|
|
837
|
-
|
|
838
|
-
interface FlakyRequest {}
|
|
839
|
-
interface FlakyResponse { status: string; }
|
|
1048
|
+
import { StableBuffer } from 'stable-request';
|
|
1049
|
+
import type { StableBufferOptions, StableBufferTransactionLog, MetricsGuardrailsStableBuffer, StableBufferState } from 'stable-request';
|
|
840
1050
|
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
}
|
|
1051
|
+
const bufferOptions: StableBufferOptions = {
|
|
1052
|
+
initialState: {}, // Starting state
|
|
1053
|
+
clone: (state: StableBufferState): StableBufferState => ({ ...state }), // Custom cloning function
|
|
1054
|
+
transactionTimeoutMs: 5000, // Transaction timeout
|
|
1055
|
+
logTransaction: async (log: StableBufferTransactionLog): Promise<void> => {}, // Transaction logger
|
|
1056
|
+
metricsGuardrails: { // Validation rules
|
|
1057
|
+
totalTransactions: { max: 1000 },
|
|
1058
|
+
averageQueueWaitMs: { max: 100 }
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
848
1061
|
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
{ id: 'req-2', requestOptions: { reqData: { path: '/flaky' }, resReq: true } }
|
|
852
|
-
];
|
|
1062
|
+
const buffer = new StableBuffer(bufferOptions);
|
|
1063
|
+
```
|
|
853
1064
|
|
|
854
|
-
|
|
855
|
-
circuitBreaker: breaker
|
|
856
|
-
});
|
|
1065
|
+
### Methods
|
|
857
1066
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
// HALF_OPEN: Testing recovery; allow limited requests
|
|
862
|
-
```
|
|
1067
|
+
```typescript
|
|
1068
|
+
import { StableBuffer } from 'stable-request';
|
|
1069
|
+
import type { StableBufferMetrics, StableBufferState } from 'stable-request';
|
|
863
1070
|
|
|
864
|
-
|
|
1071
|
+
interface BufferState extends StableBufferState {
|
|
1072
|
+
value: string;
|
|
1073
|
+
counter: number;
|
|
1074
|
+
newState?: boolean;
|
|
1075
|
+
}
|
|
865
1076
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1077
|
+
const buffer = new StableBuffer({
|
|
1078
|
+
initialState: { value: '', counter: 0 } as BufferState
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
(async () => {
|
|
1082
|
+
// Read current state (cloned)
|
|
1083
|
+
const state = buffer.read() as BufferState;
|
|
1084
|
+
|
|
1085
|
+
// Get direct state reference
|
|
1086
|
+
const directState = buffer.getState() as BufferState;
|
|
1087
|
+
|
|
1088
|
+
// Set entire state
|
|
1089
|
+
buffer.setState({ value: '', counter: 0, newState: true } as BufferState);
|
|
1090
|
+
|
|
1091
|
+
// Run transaction
|
|
1092
|
+
const result: string = await buffer.run(async (state): Promise<string> => {
|
|
1093
|
+
const typedState = state as BufferState;
|
|
1094
|
+
typedState.value = 'updated';
|
|
1095
|
+
return typedState.value;
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
// Update state (no return value)
|
|
1099
|
+
await buffer.run(async (state): Promise<void> => {
|
|
1100
|
+
const typedState = state as BufferState;
|
|
1101
|
+
typedState.counter += 1;
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// Get metrics
|
|
1105
|
+
const metrics: StableBufferMetrics = buffer.getMetrics();
|
|
1106
|
+
})();
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
### Transaction Logs
|
|
1110
|
+
|
|
1111
|
+
Each transaction generates a log entry:
|
|
1112
|
+
|
|
1113
|
+
```typescript
|
|
1114
|
+
interface StableBufferTransactionLog {
|
|
1115
|
+
transactionId: string;
|
|
1116
|
+
queuedAt: string;
|
|
1117
|
+
startedAt: string;
|
|
1118
|
+
finishedAt: string;
|
|
1119
|
+
durationMs: number;
|
|
1120
|
+
queueWaitMs: number;
|
|
1121
|
+
success: boolean;
|
|
1122
|
+
errorMessage?: string;
|
|
1123
|
+
stateBefore: Record<string, any>;
|
|
1124
|
+
stateAfter: Record<string, any>;
|
|
1125
|
+
activity?: string;
|
|
1126
|
+
hookName?: string;
|
|
1127
|
+
hookParams?: any;
|
|
1128
|
+
workflowId?: string;
|
|
1129
|
+
branchId?: string;
|
|
1130
|
+
phaseId?: string;
|
|
1131
|
+
requestId?: string;
|
|
1132
|
+
}
|
|
1133
|
+
```
|
|
870
1134
|
|
|
871
|
-
|
|
1135
|
+
## Infrastructure Persistence
|
|
872
1136
|
|
|
873
|
-
|
|
1137
|
+
Persist circuit breaker and cache state for recovery:
|
|
874
1138
|
|
|
875
1139
|
```typescript
|
|
876
|
-
import { stableRequest,
|
|
1140
|
+
import { stableRequest, StableBuffer, REQUEST_METHODS } from 'stable-request';
|
|
1141
|
+
import type {
|
|
1142
|
+
STABLE_REQUEST_RESULT,
|
|
1143
|
+
CircuitBreakerConfig,
|
|
1144
|
+
CacheConfig,
|
|
1145
|
+
CircuitBreakerPersistedState,
|
|
1146
|
+
CacheManagerPersistedState,
|
|
1147
|
+
InfrastructurePersistence
|
|
1148
|
+
} from 'stable-request';
|
|
877
1149
|
|
|
878
|
-
interface UserRequest {}
|
|
879
|
-
interface UserResponse {
|
|
880
|
-
id: number;
|
|
881
|
-
name: string;
|
|
882
|
-
email: string;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
const cache = new CacheManager({
|
|
886
|
-
enabled: true,
|
|
887
|
-
ttl: 5000 // 5 seconds
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
// First call: cache miss, hits API
|
|
891
|
-
const result1 = await stableRequest<UserRequest, UserResponse>({
|
|
892
|
-
reqData: { hostname: 'api.example.com', path: '/user/1' },
|
|
893
|
-
resReq: true,
|
|
894
|
-
cache
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
// Second call within 5s: cache hit, returns cached response
|
|
898
|
-
const result2 = await stableRequest<UserRequest, UserResponse>({
|
|
899
|
-
reqData: { hostname: 'api.example.com', path: '/user/1' },
|
|
900
|
-
resReq: true,
|
|
901
|
-
cache
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
// Respects Cache-Control headers if enabled
|
|
905
|
-
const cache2 = new CacheManager({
|
|
906
|
-
enabled: true,
|
|
907
|
-
ttl: 60000,
|
|
908
|
-
respectCacheControl: true // Uses max-age, no-cache, no-store
|
|
909
|
-
});
|
|
910
|
-
```
|
|
911
|
-
|
|
912
|
-
**Function Caching:**
|
|
913
|
-
|
|
914
|
-
Arguments become cache key; identical args hit cache.
|
|
915
|
-
|
|
916
|
-
```typescript
|
|
917
|
-
import { stableFunction } from '@emmvish/stable-request';
|
|
918
|
-
|
|
919
|
-
const expensive = (x: number) => x * x * x; // Cubic calculation
|
|
920
|
-
|
|
921
|
-
const result1 = await stableFunction({
|
|
922
|
-
fn: expensive,
|
|
923
|
-
args: [5],
|
|
924
|
-
returnResult: true,
|
|
925
|
-
cache: { enabled: true, ttl: 10000 }
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
const result2 = await stableFunction({
|
|
929
|
-
fn: expensive,
|
|
930
|
-
args: [5], // Same args → cache hit
|
|
931
|
-
returnResult: true,
|
|
932
|
-
cache: { enabled: true, ttl: 10000 }
|
|
933
|
-
});
|
|
934
|
-
```
|
|
935
|
-
|
|
936
|
-
### Rate Limiting
|
|
937
|
-
|
|
938
|
-
Enforce max requests per time window.
|
|
939
|
-
|
|
940
|
-
```typescript
|
|
941
|
-
import { stableApiGateway } from '@emmvish/stable-request';
|
|
942
|
-
|
|
943
|
-
interface ItemRequest {}
|
|
944
|
-
interface ItemResponse {
|
|
945
|
-
id: number;
|
|
946
|
-
data: any;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
const requests = Array.from({ length: 20 }, (_, i) => ({
|
|
950
|
-
id: `req-${i}`,
|
|
951
|
-
requestOptions: {
|
|
952
|
-
reqData: { path: `/item/${i}` },
|
|
953
|
-
resReq: true
|
|
954
|
-
}
|
|
955
|
-
}));
|
|
956
|
-
|
|
957
|
-
const responses = await stableApiGateway<ItemRequest, ItemResponse>(requests, {
|
|
958
|
-
concurrentExecution: true,
|
|
959
|
-
rateLimit: {
|
|
960
|
-
maxRequests: 5,
|
|
961
|
-
windowMs: 1000 // 5 requests per second
|
|
962
|
-
}
|
|
963
|
-
// Requests queued until window allows; prevents overwhelming API
|
|
964
|
-
});
|
|
965
|
-
```
|
|
966
|
-
|
|
967
|
-
### Concurrency Limiting
|
|
968
|
-
|
|
969
|
-
Limit concurrent in-flight requests.
|
|
970
|
-
|
|
971
|
-
```typescript
|
|
972
|
-
import { stableApiGateway } from '@emmvish/stable-request';
|
|
973
|
-
|
|
974
|
-
interface ItemRequest {}
|
|
975
|
-
interface ItemResponse {
|
|
976
|
-
id: number;
|
|
977
|
-
data: any;
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
const requests = Array.from({ length: 50 }, (_, i) => ({
|
|
981
|
-
id: `req-${i}`,
|
|
982
|
-
requestOptions: {
|
|
983
|
-
reqData: { path: `/item/${i}` },
|
|
984
|
-
resReq: true,
|
|
985
|
-
attempts: 1
|
|
986
|
-
}
|
|
987
|
-
}));
|
|
988
|
-
|
|
989
|
-
const responses = await stableApiGateway<ItemRequest, ItemResponse>(requests, {
|
|
990
|
-
concurrentExecution: true,
|
|
991
|
-
maxConcurrentRequests: 5 // Only 5 requests in-flight at a time
|
|
992
|
-
// Others queued and executed as slots free
|
|
993
|
-
});
|
|
994
|
-
```
|
|
995
|
-
|
|
996
|
-
---
|
|
997
|
-
|
|
998
|
-
## Workflow Patterns
|
|
999
|
-
|
|
1000
|
-
### Sequential & Concurrent Phases
|
|
1001
|
-
|
|
1002
|
-
#### Sequential (Default)
|
|
1003
|
-
|
|
1004
|
-
Each phase waits for the previous to complete.
|
|
1005
|
-
|
|
1006
|
-
```typescript
|
|
1007
|
-
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1008
|
-
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1009
|
-
|
|
1010
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1011
|
-
{
|
|
1012
|
-
id: 'phase-1',
|
|
1013
|
-
requests: [{ id: 'r1', requestOptions: { reqData: { path: '/p1' }, resReq: true } }]
|
|
1014
|
-
},
|
|
1015
|
-
{
|
|
1016
|
-
id: 'phase-2',
|
|
1017
|
-
requests: [{ id: 'r2', requestOptions: { reqData: { path: '/p2' }, resReq: true } }]
|
|
1018
|
-
},
|
|
1019
|
-
{
|
|
1020
|
-
id: 'phase-3',
|
|
1021
|
-
requests: [{ id: 'r3', requestOptions: { reqData: { path: '/p3' }, resReq: true } }]
|
|
1022
|
-
}
|
|
1023
|
-
];
|
|
1024
|
-
|
|
1025
|
-
const result = await stableWorkflow(phases, {
|
|
1026
|
-
workflowId: 'sequential-phases',
|
|
1027
|
-
concurrentPhaseExecution: false // Phase-1 → Phase-2 → Phase-3
|
|
1028
|
-
});
|
|
1029
|
-
```
|
|
1030
|
-
|
|
1031
|
-
#### Concurrent Phases
|
|
1032
|
-
|
|
1033
|
-
Multiple phases run in parallel.
|
|
1034
|
-
|
|
1035
|
-
```typescript
|
|
1036
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1037
|
-
{
|
|
1038
|
-
id: 'fetch-users',
|
|
1039
|
-
requests: [{ id: 'get-users', requestOptions: { reqData: { path: '/users' }, resReq: true } }]
|
|
1040
|
-
},
|
|
1041
|
-
{
|
|
1042
|
-
id: 'fetch-posts',
|
|
1043
|
-
requests: [{ id: 'get-posts', requestOptions: { reqData: { path: '/posts' }, resReq: true } }]
|
|
1044
|
-
},
|
|
1045
|
-
{
|
|
1046
|
-
id: 'fetch-comments',
|
|
1047
|
-
requests: [{ id: 'get-comments', requestOptions: { reqData: { path: '/comments' }, resReq: true } }]
|
|
1048
|
-
}
|
|
1049
|
-
];
|
|
1050
|
-
|
|
1051
|
-
const result = await stableWorkflow(phases, {
|
|
1052
|
-
workflowId: 'parallel-phases',
|
|
1053
|
-
concurrentPhaseExecution: true // All 3 phases in parallel
|
|
1054
|
-
});
|
|
1055
|
-
```
|
|
1056
|
-
|
|
1057
|
-
#### Mixed Phases
|
|
1058
|
-
|
|
1059
|
-
Combine sequential and concurrent phases in one workflow.
|
|
1060
|
-
|
|
1061
|
-
```typescript
|
|
1062
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1063
|
-
{
|
|
1064
|
-
id: 'init', // Sequential
|
|
1065
|
-
requests: [{ id: 'setup', requestOptions: { reqData: { path: '/init' }, resReq: true } }]
|
|
1066
|
-
},
|
|
1067
|
-
{
|
|
1068
|
-
id: 'fetch-a',
|
|
1069
|
-
markConcurrentPhase: true, // Concurrent with next
|
|
1070
|
-
requests: [{ id: 'data-a', requestOptions: { reqData: { path: '/a' }, resReq: true } }]
|
|
1071
|
-
},
|
|
1072
|
-
{
|
|
1073
|
-
id: 'fetch-b',
|
|
1074
|
-
markConcurrentPhase: true, // Concurrent with fetch-a
|
|
1075
|
-
requests: [{ id: 'data-b', requestOptions: { reqData: { path: '/b' }, resReq: true } }]
|
|
1076
|
-
},
|
|
1077
|
-
{
|
|
1078
|
-
id: 'finalize', // Sequential after fetch-a/b complete
|
|
1079
|
-
requests: [{ id: 'done', requestOptions: { reqData: { path: '/finalize' }, resReq: true } }]
|
|
1080
|
-
}
|
|
1081
|
-
];
|
|
1082
|
-
|
|
1083
|
-
const result = await stableWorkflow(phases, {
|
|
1084
|
-
concurrentPhaseExecution: false // Respects markConcurrentPhase per phase
|
|
1085
|
-
});
|
|
1086
|
-
```
|
|
1087
|
-
|
|
1088
|
-
### Non-Linear Workflows
|
|
1089
|
-
|
|
1090
|
-
Use decision hooks to dynamically control phase flow.
|
|
1091
|
-
|
|
1092
|
-
#### CONTINUE
|
|
1093
|
-
|
|
1094
|
-
Standard flow to next sequential phase.
|
|
1095
|
-
|
|
1096
|
-
```typescript
|
|
1097
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1098
|
-
{
|
|
1099
|
-
id: 'check-status',
|
|
1100
|
-
requests: [{ id: 'api', requestOptions: { reqData: { path: '/status' }, resReq: true } }],
|
|
1101
|
-
phaseDecisionHook: async ({ phaseResult, sharedBuffer }) => {
|
|
1102
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1103
|
-
}
|
|
1104
|
-
},
|
|
1105
|
-
{
|
|
1106
|
-
id: 'process', // Executes after check-status
|
|
1107
|
-
requests: [{ id: 'process-data', requestOptions: { reqData: { path: '/process' }, resReq: true } }]
|
|
1108
|
-
}
|
|
1109
|
-
];
|
|
1110
|
-
|
|
1111
|
-
const result = await stableWorkflow(phases, {
|
|
1112
|
-
enableNonLinearExecution: true
|
|
1113
|
-
});
|
|
1114
|
-
```
|
|
1115
|
-
|
|
1116
|
-
#### SKIP
|
|
1117
|
-
|
|
1118
|
-
Skip the next phase; execute the one after.
|
|
1119
|
-
|
|
1120
|
-
```typescript
|
|
1121
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1122
|
-
{
|
|
1123
|
-
id: 'phase-1',
|
|
1124
|
-
requests: [{ id: 'r1', requestOptions: { reqData: { path: '/p1' }, resReq: true } }],
|
|
1125
|
-
phaseDecisionHook: async () => ({
|
|
1126
|
-
action: PHASE_DECISION_ACTIONS.SKIP
|
|
1127
|
-
})
|
|
1128
|
-
},
|
|
1129
|
-
{
|
|
1130
|
-
id: 'phase-2', // Skipped
|
|
1131
|
-
requests: [{ id: 'r2', requestOptions: { reqData: { path: '/p2' }, resReq: true } }]
|
|
1132
|
-
},
|
|
1133
|
-
{
|
|
1134
|
-
id: 'phase-3', // Executes
|
|
1135
|
-
requests: [{ id: 'r3', requestOptions: { reqData: { path: '/p3' }, resReq: true } }]
|
|
1136
|
-
}
|
|
1137
|
-
];
|
|
1138
|
-
|
|
1139
|
-
const result = await stableWorkflow(phases, {
|
|
1140
|
-
enableNonLinearExecution: true
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
// Execution: phase-1 → phase-3
|
|
1144
|
-
```
|
|
1145
|
-
|
|
1146
|
-
#### JUMP
|
|
1147
|
-
|
|
1148
|
-
Jump to a specific phase by ID.
|
|
1149
|
-
|
|
1150
|
-
```typescript
|
|
1151
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1152
|
-
{
|
|
1153
|
-
id: 'phase-1',
|
|
1154
|
-
requests: [{ id: 'r1', requestOptions: { reqData: { path: '/p1' }, resReq: true } }],
|
|
1155
|
-
phaseDecisionHook: async () => ({
|
|
1156
|
-
action: PHASE_DECISION_ACTIONS.JUMP,
|
|
1157
|
-
targetPhaseId: 'recovery'
|
|
1158
|
-
})
|
|
1159
|
-
},
|
|
1160
|
-
{
|
|
1161
|
-
id: 'phase-2', // Skipped
|
|
1162
|
-
requests: [{ id: 'r2', requestOptions: { reqData: { path: '/p2' }, resReq: true } }]
|
|
1163
|
-
},
|
|
1164
|
-
{
|
|
1165
|
-
id: 'recovery',
|
|
1166
|
-
requests: [{ id: 'recover', requestOptions: { reqData: { path: '/recovery' }, resReq: true } }]
|
|
1167
|
-
}
|
|
1168
|
-
];
|
|
1169
|
-
|
|
1170
|
-
const result = await stableWorkflow(phases, {
|
|
1171
|
-
enableNonLinearExecution: true
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
// Execution: phase-1 → recovery
|
|
1175
|
-
```
|
|
1176
|
-
|
|
1177
|
-
#### REPLAY
|
|
1178
|
-
|
|
1179
|
-
Re-execute current phase; useful for polling.
|
|
1180
|
-
|
|
1181
|
-
```typescript
|
|
1182
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1183
|
-
{
|
|
1184
|
-
id: 'wait-for-job',
|
|
1185
|
-
allowReplay: true,
|
|
1186
|
-
maxReplayCount: 5,
|
|
1187
|
-
requests: [
|
|
1188
|
-
{
|
|
1189
|
-
id: 'check-job',
|
|
1190
|
-
requestOptions: { reqData: { path: '/job/status' }, resReq: true, attempts: 1 }
|
|
1191
|
-
}
|
|
1192
|
-
],
|
|
1193
|
-
phaseDecisionHook: async ({ phaseResult, executionHistory }) => {
|
|
1194
|
-
const lastResponse = phaseResult.responses?.[0];
|
|
1195
|
-
if ((lastResponse as any)?.data?.status === 'pending' && executionHistory.length < 5) {
|
|
1196
|
-
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
1197
|
-
}
|
|
1198
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1199
|
-
}
|
|
1200
|
-
},
|
|
1201
|
-
{
|
|
1202
|
-
id: 'process-result',
|
|
1203
|
-
requests: [{ id: 'process', requestOptions: { reqData: { path: '/process' }, resReq: true } }]
|
|
1204
|
-
}
|
|
1205
|
-
];
|
|
1206
|
-
|
|
1207
|
-
const result = await stableWorkflow(phases, {
|
|
1208
|
-
enableNonLinearExecution: true,
|
|
1209
|
-
maxWorkflowIterations: 100
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
// Polls up to 5 times before continuing
|
|
1213
|
-
```
|
|
1214
|
-
|
|
1215
|
-
#### TERMINATE
|
|
1216
|
-
|
|
1217
|
-
Stop workflow early.
|
|
1218
|
-
|
|
1219
|
-
```typescript
|
|
1220
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1221
|
-
{
|
|
1222
|
-
id: 'validate',
|
|
1223
|
-
requests: [{ id: 'validate-input', requestOptions: { reqData: { path: '/validate' }, resReq: true } }],
|
|
1224
|
-
phaseDecisionHook: async ({ phaseResult, sharedBuffer }) => {
|
|
1225
|
-
if (!phaseResult.success) {
|
|
1226
|
-
return { action: PHASE_DECISION_ACTIONS.TERMINATE };
|
|
1227
|
-
}
|
|
1228
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1229
|
-
}
|
|
1230
|
-
},
|
|
1231
|
-
{
|
|
1232
|
-
id: 'phase-2', // Won't execute if validation fails
|
|
1233
|
-
requests: [{ id: 'r2', requestOptions: { reqData: { path: '/p2' }, resReq: true } }]
|
|
1234
|
-
}
|
|
1235
|
-
];
|
|
1236
|
-
|
|
1237
|
-
const result = await stableWorkflow(phases, {
|
|
1238
|
-
enableNonLinearExecution: true
|
|
1239
|
-
});
|
|
1240
|
-
|
|
1241
|
-
console.log(result.terminatedEarly); // true if TERMINATE triggered
|
|
1242
|
-
```
|
|
1243
|
-
|
|
1244
|
-
### Branched Workflows
|
|
1245
|
-
|
|
1246
|
-
Execute multiple independent branches with shared state.
|
|
1247
|
-
|
|
1248
|
-
```typescript
|
|
1249
|
-
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1250
|
-
import type { STABLE_WORKFLOW_BRANCH } from '@emmvish/stable-request';
|
|
1251
|
-
|
|
1252
|
-
const branches: STABLE_WORKFLOW_BRANCH[] = [
|
|
1253
|
-
{
|
|
1254
|
-
id: 'branch-payment',
|
|
1255
|
-
phases: [
|
|
1256
|
-
{
|
|
1257
|
-
id: 'process-payment',
|
|
1258
|
-
requests: [
|
|
1259
|
-
{
|
|
1260
|
-
id: 'charge-card',
|
|
1261
|
-
requestOptions: {
|
|
1262
|
-
reqData: { path: '/payment/charge' },
|
|
1263
|
-
resReq: true
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
]
|
|
1267
|
-
}
|
|
1268
|
-
]
|
|
1269
|
-
},
|
|
1270
|
-
{
|
|
1271
|
-
id: 'branch-notification',
|
|
1272
|
-
phases: [
|
|
1273
|
-
{
|
|
1274
|
-
id: 'send-email',
|
|
1275
|
-
requests: [
|
|
1276
|
-
{
|
|
1277
|
-
id: 'send',
|
|
1278
|
-
requestOptions: {
|
|
1279
|
-
reqData: { path: '/notify/email' },
|
|
1280
|
-
resReq: false
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
]
|
|
1284
|
-
}
|
|
1285
|
-
]
|
|
1286
|
-
}
|
|
1287
|
-
];
|
|
1288
|
-
|
|
1289
|
-
const result = await stableWorkflow([], {
|
|
1290
|
-
workflowId: 'checkout',
|
|
1291
|
-
enableBranchExecution: true,
|
|
1292
|
-
branches,
|
|
1293
|
-
sharedBuffer: { orderId: '12345' },
|
|
1294
|
-
markConcurrentBranch: true // Branches run in parallel
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
// Both branches access/modify sharedBuffer
|
|
1298
|
-
```
|
|
1299
|
-
|
|
1300
|
-
#### Branch Racing
|
|
1301
|
-
|
|
1302
|
-
When multiple branches execute concurrently, enable racing to accept the first successful branch and cancel others.
|
|
1303
|
-
|
|
1304
|
-
```typescript
|
|
1305
|
-
const result = await stableWorkflow([], {
|
|
1306
|
-
workflowId: 'payment-racing',
|
|
1307
|
-
enableBranchExecution: true,
|
|
1308
|
-
enableBranchRacing: true, // First successful branch wins
|
|
1309
|
-
branches: [
|
|
1310
|
-
{
|
|
1311
|
-
id: 'payment-provider-a',
|
|
1312
|
-
phases: [/* ... */]
|
|
1313
|
-
},
|
|
1314
|
-
{
|
|
1315
|
-
id: 'payment-provider-b',
|
|
1316
|
-
phases: [/* ... */]
|
|
1317
|
-
}
|
|
1318
|
-
],
|
|
1319
|
-
markConcurrentBranch: true
|
|
1320
|
-
});
|
|
1321
|
-
|
|
1322
|
-
// Only winning branch's execution history recorded
|
|
1323
|
-
// Losing branches marked as cancelled
|
|
1324
|
-
```
|
|
1325
|
-
|
|
1326
|
-
## Graph-based Workflow Patterns
|
|
1327
|
-
|
|
1328
|
-
**Key responsibilities:**
|
|
1329
|
-
- Define phases as DAG nodes with explicit dependency edges
|
|
1330
|
-
- Execute independent phases in parallel automatically
|
|
1331
|
-
- Support parallel groups, merge points, and conditional routing
|
|
1332
|
-
- Validate graph structure (cycle detection, reachability, orphan detection)
|
|
1333
|
-
- Provide deterministic execution order
|
|
1334
|
-
- Offer higher parallelism than phased workflows for complex topologies
|
|
1335
|
-
|
|
1336
|
-
### Graph-Based Workflows with Mixed Items
|
|
1337
|
-
|
|
1338
|
-
For complex topologies with explicit dependencies, use DAG execution mixing requests and functions.
|
|
1339
|
-
|
|
1340
|
-
```typescript
|
|
1341
|
-
import { stableWorkflowGraph, WorkflowGraphBuilder, RequestOrFunction } from '@emmvish/stable-request';
|
|
1342
|
-
import type { API_GATEWAY_ITEM } from '@emmvish/stable-request';
|
|
1343
|
-
|
|
1344
|
-
// Request types
|
|
1345
|
-
interface PostsRequest {}
|
|
1346
|
-
interface PostsResponse { posts: Array<{ id: number; title: string }> };
|
|
1347
|
-
|
|
1348
|
-
interface UsersRequest {}
|
|
1349
|
-
interface UsersResponse { users: Array<{ id: number; name: string }> };
|
|
1350
|
-
|
|
1351
|
-
// Function types
|
|
1352
|
-
type AggregateArgs = [PostsResponse, UsersResponse];
|
|
1353
|
-
type AggregateResult = {
|
|
1354
|
-
combined: Array<{ userId: number; userName: string; postCount: number }>;
|
|
1355
|
-
};
|
|
1356
|
-
|
|
1357
|
-
type AnalyzeArgs = [AggregateResult];
|
|
1358
|
-
type AnalyzeResult = { totalPosts: number; activeUsers: number };
|
|
1359
|
-
|
|
1360
|
-
const graph = new WorkflowGraphBuilder<
|
|
1361
|
-
PostsRequest | UsersRequest,
|
|
1362
|
-
PostsResponse | UsersResponse,
|
|
1363
|
-
AggregateArgs | AnalyzeArgs,
|
|
1364
|
-
AggregateResult | AnalyzeResult
|
|
1365
|
-
>()
|
|
1366
|
-
.addPhase('fetch-posts', {
|
|
1367
|
-
requests: [{
|
|
1368
|
-
id: 'get-posts',
|
|
1369
|
-
requestOptions: {
|
|
1370
|
-
reqData: { path: '/posts' },
|
|
1371
|
-
resReq: true
|
|
1372
|
-
}
|
|
1373
|
-
}]
|
|
1374
|
-
})
|
|
1375
|
-
.addPhase('fetch-users', {
|
|
1376
|
-
requests: [{
|
|
1377
|
-
id: 'get-users',
|
|
1378
|
-
requestOptions: {
|
|
1379
|
-
reqData: { path: '/users' },
|
|
1380
|
-
resReq: true
|
|
1381
|
-
}
|
|
1382
|
-
}]
|
|
1383
|
-
})
|
|
1384
|
-
.addParallelGroup('fetch-all', ['fetch-posts', 'fetch-users'])
|
|
1385
|
-
.addPhase('aggregate', {
|
|
1386
|
-
functions: [{
|
|
1387
|
-
id: 'combine-data',
|
|
1388
|
-
functionOptions: {
|
|
1389
|
-
fn: (posts: PostsResponse, users: UsersResponse): AggregateResult => ({
|
|
1390
|
-
combined: users.users.map(user => ({
|
|
1391
|
-
userId: user.id,
|
|
1392
|
-
userName: user.name,
|
|
1393
|
-
postCount: posts.posts.filter(p => p.id === user.id).length
|
|
1394
|
-
}))
|
|
1395
|
-
}),
|
|
1396
|
-
args: [{ posts: [] }, { users: [] }] as AggregateArgs,
|
|
1397
|
-
returnResult: true
|
|
1398
|
-
}
|
|
1399
|
-
}]
|
|
1400
|
-
})
|
|
1401
|
-
.addPhase('analyze', {
|
|
1402
|
-
functions: [{
|
|
1403
|
-
id: 'analyze-data',
|
|
1404
|
-
functionOptions: {
|
|
1405
|
-
fn: (aggregated: AggregateResult): AnalyzeResult => ({
|
|
1406
|
-
totalPosts: aggregated.combined.reduce((sum, u) => sum + u.postCount, 0),
|
|
1407
|
-
activeUsers: aggregated.combined.filter(u => u.postCount > 0).length
|
|
1408
|
-
}),
|
|
1409
|
-
args: [{ combined: [] }] as AnalyzeArgs,
|
|
1410
|
-
returnResult: true
|
|
1411
|
-
}
|
|
1412
|
-
}]
|
|
1413
|
-
})
|
|
1414
|
-
.addMergePoint('sync', ['fetch-all'])
|
|
1415
|
-
.connectSequence('fetch-all', 'sync', 'aggregate', 'analyze')
|
|
1416
|
-
.setEntryPoint('fetch-all')
|
|
1417
|
-
.build();
|
|
1418
|
-
|
|
1419
|
-
const result = await stableWorkflowGraph(graph, {
|
|
1420
|
-
workflowId: 'data-aggregation'
|
|
1421
|
-
});
|
|
1422
|
-
|
|
1423
|
-
console.log(`Graph workflow success: ${result.success}`);
|
|
1424
|
-
```
|
|
1425
|
-
|
|
1426
|
-
### Parallel Phase Execution
|
|
1427
|
-
|
|
1428
|
-
Execute multiple phases concurrently within a group.
|
|
1429
|
-
|
|
1430
|
-
```typescript
|
|
1431
|
-
import { stableWorkflowGraph, WorkflowGraphBuilder } from '@emmvish/stable-request';
|
|
1432
|
-
|
|
1433
|
-
const graph = new WorkflowGraphBuilder()
|
|
1434
|
-
.addPhase('fetch-users', {
|
|
1435
|
-
requests: [{
|
|
1436
|
-
id: 'users',
|
|
1437
|
-
requestOptions: { reqData: { path: '/users' }, resReq: true }
|
|
1438
|
-
}]
|
|
1439
|
-
})
|
|
1440
|
-
.addPhase('fetch-posts', {
|
|
1441
|
-
requests: [{
|
|
1442
|
-
id: 'posts',
|
|
1443
|
-
requestOptions: { reqData: { path: '/posts' }, resReq: true }
|
|
1444
|
-
}]
|
|
1445
|
-
})
|
|
1446
|
-
.addPhase('fetch-comments', {
|
|
1447
|
-
requests: [{
|
|
1448
|
-
id: 'comments',
|
|
1449
|
-
requestOptions: { reqData: { path: '/comments' }, resReq: true }
|
|
1450
|
-
}]
|
|
1451
|
-
})
|
|
1452
|
-
.addParallelGroup('data-fetch', ['fetch-users', 'fetch-posts', 'fetch-comments'])
|
|
1453
|
-
.setEntryPoint('data-fetch')
|
|
1454
|
-
.build();
|
|
1455
|
-
|
|
1456
|
-
const result = await stableWorkflowGraph(graph, {
|
|
1457
|
-
workflowId: 'data-aggregation'
|
|
1458
|
-
});
|
|
1459
|
-
|
|
1460
|
-
// All 3 phases run concurrently
|
|
1461
|
-
```
|
|
1462
|
-
|
|
1463
|
-
### Merge Points
|
|
1464
|
-
|
|
1465
|
-
Synchronize multiple predecessor phases.
|
|
1466
|
-
|
|
1467
|
-
```typescript
|
|
1468
|
-
const graph = new WorkflowGraphBuilder()
|
|
1469
|
-
.addPhase('fetch-a', {
|
|
1470
|
-
requests: [{ id: 'a', requestOptions: { reqData: { path: '/a' }, resReq: true } }]
|
|
1471
|
-
})
|
|
1472
|
-
.addPhase('fetch-b', {
|
|
1473
|
-
requests: [{ id: 'b', requestOptions: { reqData: { path: '/b' }, resReq: true } }]
|
|
1474
|
-
})
|
|
1475
|
-
.addMergePoint('sync', ['fetch-a', 'fetch-b'])
|
|
1476
|
-
.addPhase('aggregate', {
|
|
1477
|
-
functions: [{
|
|
1478
|
-
id: 'combine',
|
|
1479
|
-
functionOptions: {
|
|
1480
|
-
fn: () => 'combined',
|
|
1481
|
-
args: [],
|
|
1482
|
-
returnResult: true
|
|
1483
|
-
}
|
|
1484
|
-
}]
|
|
1485
|
-
})
|
|
1486
|
-
.connectSequence('fetch-a', 'sync')
|
|
1487
|
-
.connectSequence('fetch-b', 'sync')
|
|
1488
|
-
.connectSequence('sync', 'aggregate')
|
|
1489
|
-
.setEntryPoint('fetch-a')
|
|
1490
|
-
.build();
|
|
1491
|
-
|
|
1492
|
-
const result = await stableWorkflowGraph(graph, {
|
|
1493
|
-
workflowId: 'parallel-sync'
|
|
1494
|
-
});
|
|
1495
|
-
|
|
1496
|
-
// fetch-a and fetch-b run in parallel
|
|
1497
|
-
// aggregate waits for both to complete
|
|
1498
|
-
```
|
|
1499
|
-
|
|
1500
|
-
### Linear Helper
|
|
1501
|
-
|
|
1502
|
-
Convenience function for sequential phase chains.
|
|
1503
|
-
|
|
1504
|
-
```typescript
|
|
1505
|
-
import { createLinearWorkflowGraph } from '@emmvish/stable-request';
|
|
1506
|
-
|
|
1507
|
-
const phases = [
|
|
1508
|
-
{
|
|
1509
|
-
id: 'init',
|
|
1510
|
-
requests: [{ id: 'setup', requestOptions: { reqData: { path: '/init' }, resReq: true } }]
|
|
1511
|
-
},
|
|
1512
|
-
{
|
|
1513
|
-
id: 'process',
|
|
1514
|
-
requests: [{ id: 'do-work', requestOptions: { reqData: { path: '/work' }, resReq: true } }]
|
|
1515
|
-
},
|
|
1516
|
-
{
|
|
1517
|
-
id: 'finalize',
|
|
1518
|
-
requests: [{ id: 'cleanup', requestOptions: { reqData: { path: '/cleanup' }, resReq: true } }]
|
|
1519
|
-
}
|
|
1520
|
-
];
|
|
1521
|
-
|
|
1522
|
-
const graph = createLinearWorkflowGraph(phases);
|
|
1523
|
-
|
|
1524
|
-
const result = await stableWorkflowGraph(graph, {
|
|
1525
|
-
workflowId: 'linear-workflow'
|
|
1526
|
-
});
|
|
1527
|
-
```
|
|
1528
|
-
|
|
1529
|
-
### Branch Racing in Graphs
|
|
1530
|
-
|
|
1531
|
-
Enable branch racing in workflow graphs to accept the first successful branch node when multiple branches are executed in parallel.
|
|
1532
|
-
|
|
1533
|
-
```typescript
|
|
1534
|
-
import { stableWorkflowGraph, WorkflowGraphBuilder } from '@emmvish/stable-request';
|
|
1535
|
-
|
|
1536
|
-
const branch1 = {
|
|
1537
|
-
id: 'provider-a',
|
|
1538
|
-
phases: [{ /* ... */ }]
|
|
1539
|
-
};
|
|
1540
|
-
|
|
1541
|
-
const branch2 = {
|
|
1542
|
-
id: 'provider-b',
|
|
1543
|
-
phases: [{ /* ... */ }]
|
|
1544
|
-
};
|
|
1545
|
-
|
|
1546
|
-
const graph = new WorkflowGraphBuilder()
|
|
1547
|
-
.addBranch('provider-a', branch1)
|
|
1548
|
-
.addBranch('provider-b', branch2)
|
|
1549
|
-
.addParallelGroup('race', ['provider-a', 'provider-b'])
|
|
1550
|
-
.setEntryPoint('race')
|
|
1551
|
-
.build();
|
|
1552
|
-
|
|
1553
|
-
const result = await stableWorkflowGraph(graph, {
|
|
1554
|
-
workflowId: 'provider-racing',
|
|
1555
|
-
enableBranchRacing: true // First successful branch wins
|
|
1556
|
-
});
|
|
1557
|
-
|
|
1558
|
-
// Only winning branch's results recorded
|
|
1559
|
-
// Losing branch marked as cancelled
|
|
1560
|
-
```
|
|
1561
|
-
|
|
1562
|
-
---
|
|
1563
|
-
|
|
1564
|
-
## Configuration & State
|
|
1565
|
-
|
|
1566
|
-
### Config Cascading
|
|
1567
|
-
|
|
1568
|
-
Define defaults globally; override at group, phase, branch, or item level.
|
|
1569
|
-
|
|
1570
|
-
```typescript
|
|
1571
|
-
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1572
|
-
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1573
|
-
|
|
1574
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1575
|
-
{
|
|
1576
|
-
id: 'phase-1',
|
|
1577
|
-
attempts: 5, // Override global attempts for this phase
|
|
1578
|
-
wait: 1000,
|
|
1579
|
-
requests: [
|
|
1580
|
-
{
|
|
1581
|
-
id: 'req-1',
|
|
1582
|
-
requestOptions: {
|
|
1583
|
-
reqData: { path: '/data' },
|
|
1584
|
-
resReq: true,
|
|
1585
|
-
attempts: 2 // Override phase attempts for this item
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
]
|
|
1589
|
-
}
|
|
1590
|
-
];
|
|
1591
|
-
|
|
1592
|
-
const result = await stableWorkflow(phases, {
|
|
1593
|
-
workflowId: 'cascade-demo',
|
|
1594
|
-
commonAttempts: 1, // Global default
|
|
1595
|
-
commonWait: 500,
|
|
1596
|
-
retryStrategy: 'LINEAR' // Global default
|
|
1597
|
-
// Final config per item: merge common → phase → request
|
|
1598
|
-
});
|
|
1599
|
-
```
|
|
1600
|
-
|
|
1601
|
-
Hierarchy: global → group → phase → branch → item. Lower levels override.
|
|
1602
|
-
|
|
1603
|
-
### Shared & State Buffers
|
|
1604
|
-
|
|
1605
|
-
Pass mutable state across phases, branches, and items. For concurrency-safe shared state, pass a `StableBuffer` instance instead of a plain object.
|
|
1606
|
-
|
|
1607
|
-
#### Shared Buffer (Workflow/Gateway)
|
|
1608
|
-
|
|
1609
|
-
```typescript
|
|
1610
|
-
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1611
|
-
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1612
|
-
|
|
1613
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1614
|
-
{
|
|
1615
|
-
id: 'fetch',
|
|
1616
|
-
requests: [
|
|
1617
|
-
{
|
|
1618
|
-
id: 'user-data',
|
|
1619
|
-
requestOptions: {
|
|
1620
|
-
reqData: { path: '/users/1' },
|
|
1621
|
-
resReq: true,
|
|
1622
|
-
handleSuccessfulAttemptData: ({ successfulAttemptData, stableRequestOptions }) => {
|
|
1623
|
-
// Mutate shared buffer
|
|
1624
|
-
const sharedBuffer = (stableRequestOptions as any).sharedBuffer;
|
|
1625
|
-
sharedBuffer.userId = (successfulAttemptData.data as any).id;
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
]
|
|
1630
|
-
},
|
|
1631
|
-
{
|
|
1632
|
-
id: 'use-shared-data',
|
|
1633
|
-
requests: [
|
|
1634
|
-
{
|
|
1635
|
-
id: 'dependent-call',
|
|
1636
|
-
requestOptions: {
|
|
1637
|
-
reqData: { path: '/user-posts' },
|
|
1638
|
-
resReq: true,
|
|
1639
|
-
preExecution: {
|
|
1640
|
-
preExecutionHook: async ({ stableRequestOptions, commonBuffer }) => {
|
|
1641
|
-
const sharedBuffer = (stableRequestOptions as any).sharedBuffer;
|
|
1642
|
-
console.log(`Using userId: ${sharedBuffer.userId}`);
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
]
|
|
1648
|
-
}
|
|
1649
|
-
];
|
|
1650
|
-
|
|
1651
|
-
const result = await stableWorkflow(phases, {
|
|
1652
|
-
workflowId: 'shared-state-demo',
|
|
1653
|
-
sharedBuffer: {} // Mutable across phases
|
|
1654
|
-
});
|
|
1655
|
-
```
|
|
1656
|
-
|
|
1657
|
-
#### Common Buffer (Request Level)
|
|
1658
|
-
|
|
1659
|
-
```typescript
|
|
1660
|
-
import { stableRequest, PersistenceStage } from '@emmvish/stable-request';
|
|
1661
|
-
|
|
1662
|
-
const commonBuffer = { transactionId: null };
|
|
1663
|
-
|
|
1664
|
-
const result = await stableRequest({
|
|
1665
|
-
reqData: { path: '/transaction/start' },
|
|
1666
|
-
resReq: true,
|
|
1667
|
-
commonBuffer,
|
|
1668
|
-
preExecution: {
|
|
1669
|
-
preExecutionHook: async ({ commonBuffer, stableRequestOptions }) => {
|
|
1670
|
-
// commonBuffer writable here
|
|
1671
|
-
commonBuffer.userId = '123';
|
|
1672
|
-
}
|
|
1673
|
-
},
|
|
1674
|
-
handleSuccessfulAttemptData: ({ successfulAttemptData }) => {
|
|
1675
|
-
// commonBuffer readable in handlers
|
|
1676
|
-
console.log(`Transaction for user ${commonBuffer.userId} done`);
|
|
1677
|
-
}
|
|
1678
|
-
});
|
|
1679
|
-
```
|
|
1680
|
-
|
|
1681
|
-
---
|
|
1682
|
-
|
|
1683
|
-
## Hooks & Observability
|
|
1684
|
-
|
|
1685
|
-
### Pre-Execution Hooks
|
|
1686
|
-
|
|
1687
|
-
Modify config or state before execution.
|
|
1688
|
-
|
|
1689
|
-
```typescript
|
|
1690
|
-
import { stableRequest } from '@emmvish/stable-request';
|
|
1691
|
-
|
|
1692
|
-
interface SecureRequest {}
|
|
1693
|
-
interface SecureResponse {
|
|
1694
|
-
data: any;
|
|
1695
|
-
token?: string;
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
const result = await stableRequest<SecureRequest, SecureResponse>({
|
|
1699
|
-
reqData: { path: '/secure-data' },
|
|
1700
|
-
resReq: true,
|
|
1701
|
-
preExecution: {
|
|
1702
|
-
preExecutionHook: async ({ inputParams, commonBuffer, stableRequestOptions }) => {
|
|
1703
|
-
// Dynamically fetch auth token
|
|
1704
|
-
const token = await getAuthToken();
|
|
1705
|
-
|
|
1706
|
-
// Return partial config override
|
|
1707
|
-
return {
|
|
1708
|
-
reqData: {
|
|
1709
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
1710
|
-
}
|
|
1711
|
-
};
|
|
1712
|
-
},
|
|
1713
|
-
preExecutionHookParams: { context: 'auth-fetch' },
|
|
1714
|
-
applyPreExecutionConfigOverride: true,
|
|
1715
|
-
continueOnPreExecutionHookFailure: false
|
|
1716
|
-
}
|
|
1717
|
-
});
|
|
1718
|
-
```
|
|
1719
|
-
|
|
1720
|
-
### Analysis Hooks
|
|
1721
|
-
|
|
1722
|
-
Validate responses and errors.
|
|
1723
|
-
|
|
1724
|
-
#### Response Analyzer
|
|
1725
|
-
|
|
1726
|
-
```typescript
|
|
1727
|
-
import { stableRequest } from '@emmvish/stable-request';
|
|
1728
|
-
|
|
1729
|
-
interface ResourceRequest {}
|
|
1730
1150
|
interface ApiResponse {
|
|
1731
|
-
|
|
1732
|
-
status: 'active' | 'inactive';
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
const result = await stableRequest<ResourceRequest, ApiResponse>({
|
|
1736
|
-
reqData: { path: '/resource' },
|
|
1737
|
-
resReq: true,
|
|
1738
|
-
responseAnalyzer: ({ data, reqData, trialMode }) => {
|
|
1739
|
-
// Return true to accept, false to retry
|
|
1740
|
-
if (!data || typeof data !== 'object') return false;
|
|
1741
|
-
if (!('id' in data)) return false;
|
|
1742
|
-
if ((data as any).status !== 'active') return false;
|
|
1743
|
-
return true;
|
|
1744
|
-
}
|
|
1745
|
-
});
|
|
1746
|
-
```
|
|
1747
|
-
|
|
1748
|
-
#### Error Analyzer
|
|
1749
|
-
|
|
1750
|
-
Decide whether to suppress error gracefully.
|
|
1751
|
-
|
|
1752
|
-
```typescript
|
|
1753
|
-
import { stableRequest } from '@emmvish/stable-request';
|
|
1754
|
-
|
|
1755
|
-
interface FeatureRequest {}
|
|
1756
|
-
interface FeatureResponse {
|
|
1757
|
-
enabled: boolean;
|
|
1758
|
-
data?: any;
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
const result = await stableRequest<FeatureRequest, FeatureResponse>({
|
|
1762
|
-
reqData: { path: '/optional-feature' },
|
|
1763
|
-
resReq: true,
|
|
1764
|
-
finalErrorAnalyzer: ({ error, reqData, trialMode }) => {
|
|
1765
|
-
// Return true to suppress error and return failure result
|
|
1766
|
-
// Return false to throw error
|
|
1767
|
-
if (error.code === 'ECONNREFUSED') {
|
|
1768
|
-
console.warn('Service unavailable, continuing with fallback');
|
|
1769
|
-
return true; // Suppress, don't throw
|
|
1770
|
-
}
|
|
1771
|
-
return false; // Throw
|
|
1772
|
-
}
|
|
1773
|
-
});
|
|
1774
|
-
|
|
1775
|
-
if (result.success) {
|
|
1776
|
-
console.log('Got data:', result.data);
|
|
1777
|
-
} else {
|
|
1778
|
-
console.log('Service offline, but we continue');
|
|
1779
|
-
}
|
|
1780
|
-
```
|
|
1781
|
-
|
|
1782
|
-
### Handler Hooks
|
|
1783
|
-
|
|
1784
|
-
Custom logging and processing.
|
|
1785
|
-
|
|
1786
|
-
#### Success Handler
|
|
1787
|
-
|
|
1788
|
-
```typescript
|
|
1789
|
-
import { stableRequest } from '@emmvish/stable-request';
|
|
1790
|
-
|
|
1791
|
-
interface DataRequest {}
|
|
1792
|
-
interface DataResponse {
|
|
1793
|
-
id: number;
|
|
1794
|
-
value: string;
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
const result = await stableRequest<DataRequest, DataResponse>({
|
|
1798
|
-
reqData: { path: '/data' },
|
|
1799
|
-
resReq: true,
|
|
1800
|
-
logAllSuccessfulAttempts: true,
|
|
1801
|
-
handleSuccessfulAttemptData: ({
|
|
1802
|
-
successfulAttemptData,
|
|
1803
|
-
reqData,
|
|
1804
|
-
maxSerializableChars,
|
|
1805
|
-
executionContext
|
|
1806
|
-
}) => {
|
|
1807
|
-
// Custom logging, metrics, state updates
|
|
1808
|
-
console.log(
|
|
1809
|
-
`Success in context ${executionContext.workflowId}`,
|
|
1810
|
-
`data:`,
|
|
1811
|
-
successfulAttemptData.data
|
|
1812
|
-
);
|
|
1813
|
-
}
|
|
1814
|
-
});
|
|
1815
|
-
```
|
|
1816
|
-
|
|
1817
|
-
#### Error Handler
|
|
1818
|
-
|
|
1819
|
-
```typescript
|
|
1820
|
-
const result = await stableRequest<DataRequest, DataResponse>({
|
|
1821
|
-
reqData: { path: '/data' },
|
|
1822
|
-
resReq: true,
|
|
1823
|
-
logAllErrors: true,
|
|
1824
|
-
handleErrors: ({ errorLog, reqData, executionContext }) => {
|
|
1825
|
-
// Custom error logging, alerting, retry logic
|
|
1826
|
-
console.error(
|
|
1827
|
-
`Error in ${executionContext.workflowId}:`,
|
|
1828
|
-
errorLog.errorMessage,
|
|
1829
|
-
`Retryable: ${errorLog.isRetryable}`
|
|
1830
|
-
);
|
|
1831
|
-
}
|
|
1832
|
-
});
|
|
1833
|
-
```
|
|
1834
|
-
|
|
1835
|
-
#### Phase Handlers (Workflow)
|
|
1836
|
-
|
|
1837
|
-
```typescript
|
|
1838
|
-
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1839
|
-
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1840
|
-
|
|
1841
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1842
|
-
{
|
|
1843
|
-
id: 'phase-1',
|
|
1844
|
-
requests: [{ id: 'r1', requestOptions: { reqData: { path: '/data' }, resReq: true } }]
|
|
1845
|
-
}
|
|
1846
|
-
];
|
|
1847
|
-
|
|
1848
|
-
const result = await stableWorkflow(phases, {
|
|
1849
|
-
workflowId: 'wf-handlers',
|
|
1850
|
-
handlePhaseCompletion: ({ phaseResult, workflowId }) => {
|
|
1851
|
-
console.log(`Phase ${phaseResult.phaseId} complete in ${workflowId}`);
|
|
1852
|
-
},
|
|
1853
|
-
handlePhaseError: ({ phaseResult, error, workflowId }) => {
|
|
1854
|
-
console.error(`Phase ${phaseResult.phaseId} failed:`, error);
|
|
1855
|
-
},
|
|
1856
|
-
handlePhaseDecision: ({ decision, phaseResult }) => {
|
|
1857
|
-
console.log(`Phase decision: ${decision.action}`);
|
|
1858
|
-
}
|
|
1859
|
-
});
|
|
1860
|
-
```
|
|
1861
|
-
|
|
1862
|
-
### Decision Hooks
|
|
1863
|
-
|
|
1864
|
-
Dynamically determine workflow flow.
|
|
1865
|
-
|
|
1866
|
-
```typescript
|
|
1867
|
-
import { stableWorkflow, PHASE_DECISION_ACTIONS } from '@emmvish/stable-request';
|
|
1868
|
-
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1869
|
-
|
|
1870
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1871
|
-
{
|
|
1872
|
-
id: 'fetch-data',
|
|
1873
|
-
requests: [{ id: 'api', requestOptions: { reqData: { path: '/data' }, resReq: true } }],
|
|
1874
|
-
phaseDecisionHook: async ({ phaseResult, sharedBuffer, executionHistory }) => {
|
|
1875
|
-
if (!phaseResult.success) {
|
|
1876
|
-
return { action: PHASE_DECISION_ACTIONS.TERMINATE };
|
|
1877
|
-
}
|
|
1878
|
-
if (phaseResult.responses[0].data?.needsRetry) {
|
|
1879
|
-
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
1880
|
-
}
|
|
1881
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
];
|
|
1885
|
-
|
|
1886
|
-
const result = await stableWorkflow(phases, {
|
|
1887
|
-
enableNonLinearExecution: true
|
|
1888
|
-
});
|
|
1889
|
-
```
|
|
1890
|
-
|
|
1891
|
-
### Metrics & Logging
|
|
1892
|
-
|
|
1893
|
-
Automatic metrics collection across all execution modes.
|
|
1894
|
-
|
|
1895
|
-
#### Request Metrics
|
|
1896
|
-
|
|
1897
|
-
```typescript
|
|
1898
|
-
import { stableRequest } from '@emmvish/stable-request';
|
|
1899
|
-
|
|
1900
|
-
interface DataRequest {}
|
|
1901
|
-
interface DataResponse { data: any; }
|
|
1902
|
-
|
|
1903
|
-
const result = await stableRequest<DataRequest, DataResponse>({
|
|
1904
|
-
reqData: { path: '/data' },
|
|
1905
|
-
resReq: true,
|
|
1906
|
-
attempts: 3
|
|
1907
|
-
});
|
|
1908
|
-
|
|
1909
|
-
console.log(result.metrics); // {
|
|
1910
|
-
// totalAttempts: 2,
|
|
1911
|
-
// successfulAttempts: 1,
|
|
1912
|
-
// failedAttempts: 1,
|
|
1913
|
-
// totalExecutionTime: 450,
|
|
1914
|
-
// averageAttemptTime: 225,
|
|
1915
|
-
// infrastructureMetrics: {
|
|
1916
|
-
// circuitBreaker: { /* state, stats, config */ },
|
|
1917
|
-
// cache: { /* hits, misses, size */ },
|
|
1918
|
-
// rateLimiter: { /* limit, current rate */ },
|
|
1919
|
-
// concurrencyLimiter: { /* limit, in-flight */ }
|
|
1920
|
-
// },
|
|
1921
|
-
// validation: {
|
|
1922
|
-
// isValid: true,
|
|
1923
|
-
// anomalies: [],
|
|
1924
|
-
// validatedAt: '2026-01-20T...'
|
|
1925
|
-
// }
|
|
1926
|
-
// }
|
|
1927
|
-
```
|
|
1928
|
-
|
|
1929
|
-
#### API Gateway Metrics
|
|
1930
|
-
|
|
1931
|
-
```typescript
|
|
1932
|
-
import { stableApiGateway } from '@emmvish/stable-request';
|
|
1933
|
-
import type { API_GATEWAY_REQUEST } from '@emmvish/stable-request';
|
|
1934
|
-
|
|
1935
|
-
interface ApiRequest {}
|
|
1936
|
-
interface ApiResponse { data: any; }
|
|
1937
|
-
|
|
1938
|
-
const requests: API_GATEWAY_REQUEST<ApiRequest, ApiResponse>[] = [
|
|
1939
|
-
{ id: 'req-1', requestOptions: { reqData: { path: '/data/1' }, resReq: true } },
|
|
1940
|
-
{ id: 'req-2', requestOptions: { reqData: { path: '/data/2' }, resReq: true } },
|
|
1941
|
-
{ id: 'req-3', requestOptions: { reqData: { path: '/data/3' }, resReq: true } }
|
|
1942
|
-
];
|
|
1943
|
-
|
|
1944
|
-
const result = await stableApiGateway<ApiRequest, ApiResponse>(requests, {
|
|
1945
|
-
concurrentExecution: true,
|
|
1946
|
-
maxConcurrentRequests: 5
|
|
1947
|
-
});
|
|
1948
|
-
|
|
1949
|
-
console.log(result.metrics); // {
|
|
1950
|
-
// totalRequests: 3,
|
|
1951
|
-
// successfulRequests: 3,
|
|
1952
|
-
// failedRequests: 0,
|
|
1953
|
-
// successRate: 100,
|
|
1954
|
-
// failureRate: 0,
|
|
1955
|
-
// executionTime: 450, // Total execution time in ms
|
|
1956
|
-
// timestamp: '2026-01-20T...', // ISO 8601 completion timestamp
|
|
1957
|
-
// throughput: 6.67, // Requests per second
|
|
1958
|
-
// averageRequestDuration: 150, // Average time per request in ms
|
|
1959
|
-
// requestGroups: [/* per-group stats */],
|
|
1960
|
-
// infrastructureMetrics: {
|
|
1961
|
-
// circuitBreaker: { /* state, stats, config */ },
|
|
1962
|
-
// cache: { /* hit rate, size, utilization */ },
|
|
1963
|
-
// rateLimiter: { /* throttle rate, queue length */ },
|
|
1964
|
-
// concurrencyLimiter: { /* utilization, queue */ }
|
|
1965
|
-
// },
|
|
1966
|
-
// validation: {
|
|
1967
|
-
// isValid: true,
|
|
1968
|
-
// anomalies: [],
|
|
1969
|
-
// validatedAt: '2026-01-20T...'
|
|
1970
|
-
// }
|
|
1971
|
-
// }
|
|
1972
|
-
```
|
|
1973
|
-
|
|
1974
|
-
#### Workflow Metrics
|
|
1975
|
-
|
|
1976
|
-
```typescript
|
|
1977
|
-
import { stableWorkflow } from '@emmvish/stable-request';
|
|
1978
|
-
import type { STABLE_WORKFLOW_PHASE } from '@emmvish/stable-request';
|
|
1979
|
-
|
|
1980
|
-
const phases: STABLE_WORKFLOW_PHASE[] = [
|
|
1981
|
-
{ id: 'p1', requests: [{ id: 'r1', requestOptions: { reqData: { path: '/a' }, resReq: true } }] },
|
|
1982
|
-
{ id: 'p2', requests: [{ id: 'r2', requestOptions: { reqData: { path: '/b' }, resReq: true } }] }
|
|
1983
|
-
];
|
|
1984
|
-
|
|
1985
|
-
const result = await stableWorkflow(phases, {
|
|
1986
|
-
workflowId: 'wf-metrics'
|
|
1987
|
-
});
|
|
1988
|
-
|
|
1989
|
-
console.log(result); // {
|
|
1990
|
-
// workflowId: 'wf-metrics',
|
|
1991
|
-
// success: true,
|
|
1992
|
-
// totalPhases: 2,
|
|
1993
|
-
// completedPhases: 2,
|
|
1994
|
-
// totalRequests: 2,
|
|
1995
|
-
// successfulRequests: 2,
|
|
1996
|
-
// failedRequests: 0,
|
|
1997
|
-
// workflowExecutionTime: 1200,
|
|
1998
|
-
// phases: [
|
|
1999
|
-
// { phaseId: 'p1', success: true, responses: [...], validation: {...}, ... },
|
|
2000
|
-
// { phaseId: 'p2', success: true, responses: [...], validation: {...}, ... }
|
|
2001
|
-
// ],
|
|
2002
|
-
// validation: {
|
|
2003
|
-
// isValid: true,
|
|
2004
|
-
// anomalies: [],
|
|
2005
|
-
// validatedAt: '2026-01-20T...'
|
|
2006
|
-
// }
|
|
2007
|
-
// }
|
|
2008
|
-
```
|
|
2009
|
-
|
|
2010
|
-
#### Structured Error Logs
|
|
2011
|
-
|
|
2012
|
-
```typescript
|
|
2013
|
-
const result = await stableRequest<DataRequest, DataResponse>({
|
|
2014
|
-
reqData: { path: '/flaky' },
|
|
2015
|
-
resReq: true,
|
|
2016
|
-
attempts: 3,
|
|
2017
|
-
logAllErrors: true,
|
|
2018
|
-
handleErrors: ({ errorLog }) => {
|
|
2019
|
-
console.log(errorLog); // {
|
|
2020
|
-
// attempt: '1/3',
|
|
2021
|
-
// type: 'NetworkError',
|
|
2022
|
-
// error: 'ECONNREFUSED',
|
|
2023
|
-
// isRetryable: true,
|
|
2024
|
-
// timestamp: 1234567890
|
|
2025
|
-
// }
|
|
2026
|
-
}
|
|
2027
|
-
});
|
|
2028
|
-
|
|
2029
|
-
if (result.errorLogs) {
|
|
2030
|
-
console.log(`${result.errorLogs.length} errors logged`);
|
|
1151
|
+
data: unknown;
|
|
2031
1152
|
}
|
|
2032
|
-
```
|
|
2033
1153
|
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
]
|
|
2062
|
-
}
|
|
2063
|
-
];
|
|
2064
|
-
|
|
2065
|
-
const result = await stableWorkflow(phases, {
|
|
2066
|
-
workflowId: 'payment-trial',
|
|
2067
|
-
trialMode: {
|
|
1154
|
+
(async () => {
|
|
1155
|
+
const sharedBuffer = new StableBuffer({
|
|
1156
|
+
initialState: {}
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
const circuitBreakerPersistence: InfrastructurePersistence<CircuitBreakerPersistedState> = {
|
|
1160
|
+
load: async (): Promise<CircuitBreakerPersistedState | null> => await loadCircuitBreakerState(),
|
|
1161
|
+
store: async (state: CircuitBreakerPersistedState): Promise<void> => await saveCircuitBreakerState(state),
|
|
1162
|
+
buffer: sharedBuffer // Use StableBuffer for coordination
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
const circuitBreakerConfig: CircuitBreakerConfig = {
|
|
1166
|
+
failureThresholdPercentage: 50,
|
|
1167
|
+
minimumRequests: 10,
|
|
1168
|
+
recoveryTimeoutMs: 30000,
|
|
1169
|
+
persistence: circuitBreakerPersistence
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const cachePersistence: InfrastructurePersistence<CacheManagerPersistedState> = {
|
|
1173
|
+
load: async (): Promise<CacheManagerPersistedState | null> => await loadCacheState(),
|
|
1174
|
+
store: async (state: CacheManagerPersistedState): Promise<void> => await saveCacheState(state)
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
const cacheConfig: CacheConfig = {
|
|
2068
1178
|
enabled: true,
|
|
2069
|
-
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
### State Persistence
|
|
2079
|
-
|
|
2080
|
-
Persist state across retry attempts for distributed tracing.
|
|
2081
|
-
|
|
2082
|
-
The `persistenceFunction` receives a `persistenceStage` parameter (`PersistenceStage.BEFORE_HOOK` or `PersistenceStage.AFTER_HOOK`) to indicate when it is called.
|
|
2083
|
-
|
|
2084
|
-
```typescript
|
|
2085
|
-
import { stableRequest, PersistenceStage } from '@emmvish/stable-request';
|
|
2086
|
-
|
|
2087
|
-
interface DataRequest {}
|
|
2088
|
-
interface DataResponse { data: any; }
|
|
2089
|
-
|
|
2090
|
-
const result = await stableRequest<DataRequest, DataResponse>({
|
|
2091
|
-
reqData: { path: '/data' },
|
|
2092
|
-
resReq: true,
|
|
2093
|
-
attempts: 3,
|
|
2094
|
-
statePersistence: {
|
|
2095
|
-
persistenceFunction: async ({ executionContext, buffer, params, persistenceStage }) => {
|
|
2096
|
-
const key = `${executionContext.workflowId}:${executionContext.requestId}`;
|
|
2097
|
-
if (persistenceStage === PersistenceStage.BEFORE_HOOK || params?.operation === 'load') {
|
|
2098
|
-
// Load state for recovery
|
|
2099
|
-
return await loadFromDatabase(key);
|
|
2100
|
-
}
|
|
2101
|
-
// Save state to database or distributed cache
|
|
2102
|
-
await saveToDatabase({ key, state: buffer });
|
|
2103
|
-
return buffer;
|
|
1179
|
+
persistence: cachePersistence
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
const result: STABLE_REQUEST_RESULT<ApiResponse> = await stableRequest<void, ApiResponse>({
|
|
1183
|
+
reqData: {
|
|
1184
|
+
hostname: 'api.example.com',
|
|
1185
|
+
path: '/data',
|
|
1186
|
+
method: REQUEST_METHODS.GET
|
|
2104
1187
|
},
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
}
|
|
2109
|
-
});
|
|
2110
|
-
```
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
1188
|
+
resReq: true,
|
|
1189
|
+
circuitBreaker: circuitBreakerConfig,
|
|
1190
|
+
cache: cacheConfig
|
|
1191
|
+
});
|
|
1192
|
+
})();
|
|
1193
|
+
```
|
|
1194
|
+
|
|
1195
|
+
## Complete Example
|
|
1196
|
+
|
|
1197
|
+
```typescript
|
|
1198
|
+
import {
|
|
1199
|
+
stableRequest,
|
|
1200
|
+
StableBuffer,
|
|
1201
|
+
RETRY_STRATEGIES,
|
|
1202
|
+
REQUEST_METHODS
|
|
1203
|
+
} from 'stable-request';
|
|
1204
|
+
import type {
|
|
1205
|
+
STABLE_REQUEST_RESULT,
|
|
1206
|
+
StableBufferTransactionLog,
|
|
1207
|
+
CircuitBreakerConfig,
|
|
1208
|
+
CacheConfig,
|
|
1209
|
+
MetricsGuardrails,
|
|
1210
|
+
ExecutionContext,
|
|
1211
|
+
ResponseAnalysisHookOptions,
|
|
1212
|
+
HandleErrorHookOptions,
|
|
1213
|
+
ERROR_LOG
|
|
1214
|
+
} from 'stable-request';
|
|
1215
|
+
|
|
1216
|
+
// Define response and request types
|
|
1217
|
+
interface UserRequest {
|
|
2123
1218
|
name: string;
|
|
2124
|
-
price: number;
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
interface InventoryRequest {}
|
|
2128
|
-
interface InventoryResponse {
|
|
2129
|
-
productId: number;
|
|
2130
|
-
stock: number;
|
|
2131
1219
|
}
|
|
2132
1220
|
|
|
2133
|
-
// Function types
|
|
2134
|
-
type EnrichArgs = [ProductResponse[], InventoryResponse[]];
|
|
2135
|
-
type EnrichResult = Array<{
|
|
2136
|
-
id: number;
|
|
2137
|
-
name: string;
|
|
2138
|
-
price: number;
|
|
2139
|
-
stock: number;
|
|
2140
|
-
inStock: boolean;
|
|
2141
|
-
}>;
|
|
2142
|
-
|
|
2143
|
-
type CalculateArgs = [EnrichResult];
|
|
2144
|
-
type CalculateResult = {
|
|
2145
|
-
totalValue: number;
|
|
2146
|
-
lowStockItems: number;
|
|
2147
|
-
};
|
|
2148
|
-
|
|
2149
|
-
type NotifyArgs = [CalculateResult, string];
|
|
2150
|
-
type NotifyResult = { notified: boolean };
|
|
2151
|
-
|
|
2152
|
-
const phase: STABLE_WORKFLOW_PHASE<
|
|
2153
|
-
ProductRequest | InventoryRequest,
|
|
2154
|
-
ProductResponse | InventoryResponse,
|
|
2155
|
-
EnrichArgs | CalculateArgs | NotifyArgs,
|
|
2156
|
-
EnrichResult | CalculateResult | NotifyResult
|
|
2157
|
-
> = {
|
|
2158
|
-
id: 'mixed-phase',
|
|
2159
|
-
items: [
|
|
2160
|
-
{
|
|
2161
|
-
type: RequestOrFunction.REQUEST,
|
|
2162
|
-
request: {
|
|
2163
|
-
id: 'fetch-products',
|
|
2164
|
-
requestOptions: {
|
|
2165
|
-
reqData: { path: '/products' },
|
|
2166
|
-
resReq: true
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
},
|
|
2170
|
-
{
|
|
2171
|
-
type: RequestOrFunction.REQUEST,
|
|
2172
|
-
request: {
|
|
2173
|
-
id: 'fetch-inventory',
|
|
2174
|
-
requestOptions: {
|
|
2175
|
-
reqData: { path: '/inventory' },
|
|
2176
|
-
resReq: true
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
},
|
|
2180
|
-
{
|
|
2181
|
-
type: RequestOrFunction.FUNCTION,
|
|
2182
|
-
function: {
|
|
2183
|
-
id: 'enrich-products',
|
|
2184
|
-
functionOptions: {
|
|
2185
|
-
fn: (products: ProductResponse[], inventory: InventoryResponse[]): EnrichResult => {
|
|
2186
|
-
return products.map(product => {
|
|
2187
|
-
const inv = inventory.find(i => i.productId === product.id);
|
|
2188
|
-
return {
|
|
2189
|
-
...product,
|
|
2190
|
-
stock: inv?.stock || 0,
|
|
2191
|
-
inStock: (inv?.stock || 0) > 0
|
|
2192
|
-
};
|
|
2193
|
-
});
|
|
2194
|
-
},
|
|
2195
|
-
args: [[], []] as EnrichArgs,
|
|
2196
|
-
returnResult: true,
|
|
2197
|
-
cache: { enabled: true, ttl: 30000 }
|
|
2198
|
-
}
|
|
2199
|
-
}
|
|
2200
|
-
},
|
|
2201
|
-
{
|
|
2202
|
-
type: RequestOrFunction.FUNCTION,
|
|
2203
|
-
function: {
|
|
2204
|
-
id: 'calculate-metrics',
|
|
2205
|
-
functionOptions: {
|
|
2206
|
-
fn: (enriched: EnrichResult): CalculateResult => ({
|
|
2207
|
-
totalValue: enriched.reduce((sum, p) => sum + (p.price * p.stock), 0),
|
|
2208
|
-
lowStockItems: enriched.filter(p => p.stock < 10 && p.stock > 0).length
|
|
2209
|
-
}),
|
|
2210
|
-
args: [[]] as CalculateArgs,
|
|
2211
|
-
returnResult: true
|
|
2212
|
-
}
|
|
2213
|
-
}
|
|
2214
|
-
},
|
|
2215
|
-
{
|
|
2216
|
-
type: RequestOrFunction.FUNCTION,
|
|
2217
|
-
function: {
|
|
2218
|
-
id: 'notify-if-needed',
|
|
2219
|
-
functionOptions: {
|
|
2220
|
-
fn: async (metrics: CalculateResult, channel: string): Promise<NotifyResult> => {
|
|
2221
|
-
if (metrics.lowStockItems > 5) {
|
|
2222
|
-
console.log(`Sending alert to ${channel}: ${metrics.lowStockItems} items low`);
|
|
2223
|
-
return { notified: true };
|
|
2224
|
-
}
|
|
2225
|
-
return { notified: false };
|
|
2226
|
-
},
|
|
2227
|
-
args: [{ totalValue: 0, lowStockItems: 0 }, 'slack'] as NotifyArgs,
|
|
2228
|
-
returnResult: true,
|
|
2229
|
-
attempts: 3,
|
|
2230
|
-
wait: 1000
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
}
|
|
2234
|
-
]
|
|
2235
|
-
};
|
|
2236
|
-
|
|
2237
|
-
const result = await stableWorkflow([phase], {
|
|
2238
|
-
workflowId: 'mixed-execution',
|
|
2239
|
-
sharedBuffer: {}
|
|
2240
|
-
});
|
|
2241
|
-
```
|
|
2242
|
-
|
|
2243
|
-
---
|
|
2244
|
-
|
|
2245
|
-
## Best Practices
|
|
2246
|
-
|
|
2247
|
-
### 1. Start Conservative, Override When Needed
|
|
2248
|
-
|
|
2249
|
-
Define global defaults; override only where necessary.
|
|
2250
|
-
|
|
2251
|
-
```typescript
|
|
2252
|
-
await stableWorkflow(phases, {
|
|
2253
|
-
// Global defaults (conservative)
|
|
2254
|
-
commonAttempts: 3,
|
|
2255
|
-
commonWait: 500,
|
|
2256
|
-
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
2257
|
-
|
|
2258
|
-
// Override for specific phase
|
|
2259
|
-
phases: [
|
|
2260
|
-
{
|
|
2261
|
-
id: 'fast-phase',
|
|
2262
|
-
attempts: 1, // Override: no retries
|
|
2263
|
-
requests: [...]
|
|
2264
|
-
}
|
|
2265
|
-
]
|
|
2266
|
-
});
|
|
2267
|
-
```
|
|
2268
|
-
|
|
2269
|
-
### 2. Validate Responses
|
|
2270
|
-
|
|
2271
|
-
Use analyzers to ensure data shape and freshness.
|
|
2272
|
-
|
|
2273
|
-
```typescript
|
|
2274
|
-
interface DataRequest {}
|
|
2275
|
-
interface ApiResponse {
|
|
2276
|
-
id: number;
|
|
2277
|
-
lastUpdated: string;
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
const result = await stableRequest<DataRequest, ApiResponse>({
|
|
2281
|
-
reqData: { path: '/data' },
|
|
2282
|
-
resReq: true,
|
|
2283
|
-
responseAnalyzer: ({ data }) => {
|
|
2284
|
-
if (!data || typeof data !== 'object') return false;
|
|
2285
|
-
if (!('id' in data && 'lastUpdated' in data)) return false;
|
|
2286
|
-
const age = Date.now() - new Date((data as any).lastUpdated).getTime();
|
|
2287
|
-
if (age > 60000) return false; // Data older than 1 minute
|
|
2288
|
-
return true;
|
|
2289
|
-
}
|
|
2290
|
-
});
|
|
2291
|
-
```
|
|
2292
|
-
|
|
2293
|
-
### 3. Cache Idempotent Reads Aggressively
|
|
2294
|
-
|
|
2295
|
-
Reduce latency and load on dependencies.
|
|
2296
|
-
|
|
2297
|
-
```typescript
|
|
2298
|
-
interface UserRequest {}
|
|
2299
1221
|
interface UserResponse {
|
|
2300
|
-
id:
|
|
1222
|
+
id: string;
|
|
2301
1223
|
name: string;
|
|
1224
|
+
createdAt: string;
|
|
2302
1225
|
}
|
|
2303
1226
|
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
});
|
|
2315
|
-
|
|
2316
|
-
await stableRequest<UserRequest, UserResponse>({
|
|
2317
|
-
reqData: { path: '/users/1' },
|
|
2318
|
-
resReq: true,
|
|
2319
|
-
cache: userCache // Cached within 30s
|
|
2320
|
-
});
|
|
2321
|
-
```
|
|
2322
|
-
|
|
2323
|
-
### 4. Use Circuit Breaker for Unstable Services
|
|
2324
|
-
|
|
2325
|
-
Protect against cascading failures.
|
|
2326
|
-
|
|
2327
|
-
```typescript
|
|
2328
|
-
interface ServiceRequest {}
|
|
2329
|
-
interface ServiceResponse { status: string; data: any; }
|
|
2330
|
-
|
|
2331
|
-
const unstabledServiceBreaker = new CircuitBreaker({
|
|
2332
|
-
failureThresholdPercentage: 40,
|
|
2333
|
-
minimumRequests: 5,
|
|
2334
|
-
recoveryTimeoutMs: 30000,
|
|
2335
|
-
successThresholdPercentage: 80
|
|
2336
|
-
});
|
|
1227
|
+
(async () => {
|
|
1228
|
+
// Create shared buffer for state management
|
|
1229
|
+
const buffer = new StableBuffer({
|
|
1230
|
+
initialState: {
|
|
1231
|
+
requestCount: 0,
|
|
1232
|
+
errors: [] as ERROR_LOG[]
|
|
1233
|
+
},
|
|
1234
|
+
logTransaction: async (log: StableBufferTransactionLog): Promise<void> => {
|
|
1235
|
+
await persistTransactionLog(log);
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
2337
1238
|
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
1239
|
+
// Define typed configurations
|
|
1240
|
+
const circuitBreakerConfig: CircuitBreakerConfig = {
|
|
1241
|
+
failureThresholdPercentage: 50,
|
|
1242
|
+
minimumRequests: 5,
|
|
1243
|
+
recoveryTimeoutMs: 30000
|
|
1244
|
+
};
|
|
2342
1245
|
|
|
2343
|
-
|
|
1246
|
+
const cacheConfig: CacheConfig = {
|
|
1247
|
+
enabled: true,
|
|
1248
|
+
ttl: 60000
|
|
1249
|
+
};
|
|
2344
1250
|
|
|
2345
|
-
|
|
1251
|
+
const executionContext: ExecutionContext = {
|
|
1252
|
+
workflowId: 'user-creation',
|
|
1253
|
+
requestId: 'create-user-001'
|
|
1254
|
+
};
|
|
2346
1255
|
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
interface ApiRequest {}
|
|
2352
|
-
interface ApiResponse { data: any; }
|
|
2353
|
-
|
|
2354
|
-
const phases: STABLE_WORKFLOW_PHASE<ApiRequest, ApiResponse>[] = [
|
|
2355
|
-
{
|
|
2356
|
-
id: 'critical-phase',
|
|
2357
|
-
requests: [{ id: 'req-1', requestOptions: { reqData: { path: '/critical' }, resReq: true } }],
|
|
2358
|
-
metricsGuardrails: {
|
|
2359
|
-
phase: {
|
|
2360
|
-
executionTime: { max: 3000 }, // SLA: <3s
|
|
2361
|
-
requestSuccessRate: { min: 99.5 } // SLA: 99.5% success
|
|
2362
|
-
}
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
];
|
|
2366
|
-
|
|
2367
|
-
const result = await stableWorkflow(phases, {
|
|
2368
|
-
workflowId: 'sla-monitored',
|
|
2369
|
-
metricsGuardrails: {
|
|
2370
|
-
workflow: {
|
|
2371
|
-
executionTime: { max: 10000 }, // Workflow SLA: <10s
|
|
2372
|
-
requestSuccessRate: { min: 99 } // Workflow SLA: 99% success
|
|
1256
|
+
const metricsGuardrails: MetricsGuardrails = {
|
|
1257
|
+
request: {
|
|
1258
|
+
totalExecutionTime: { max: 15000 },
|
|
1259
|
+
failedAttempts: { max: 2 }
|
|
2373
1260
|
}
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
// Make a resilient request
|
|
1264
|
+
const result: STABLE_REQUEST_RESULT<UserResponse> = await stableRequest<UserRequest, UserResponse>({
|
|
1265
|
+
reqData: {
|
|
1266
|
+
hostname: 'api.example.com',
|
|
1267
|
+
path: '/users',
|
|
1268
|
+
method: REQUEST_METHODS.POST,
|
|
1269
|
+
body: { name: 'John Doe' },
|
|
1270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1271
|
+
timeout: 10000
|
|
1272
|
+
},
|
|
1273
|
+
resReq: true,
|
|
1274
|
+
attempts: 3,
|
|
1275
|
+
wait: 1000,
|
|
1276
|
+
retryStrategy: RETRY_STRATEGIES.EXPONENTIAL,
|
|
1277
|
+
jitter: 0.2,
|
|
1278
|
+
|
|
1279
|
+
commonBuffer: buffer,
|
|
1280
|
+
|
|
1281
|
+
circuitBreaker: circuitBreakerConfig,
|
|
1282
|
+
cache: cacheConfig,
|
|
1283
|
+
|
|
1284
|
+
responseAnalyzer: async (options: ResponseAnalysisHookOptions<UserRequest, UserResponse>): Promise<boolean> => {
|
|
1285
|
+
const { data, commonBuffer } = options;
|
|
1286
|
+
if (commonBuffer) commonBuffer.requestCount += 1;
|
|
1287
|
+
return data.id !== undefined;
|
|
1288
|
+
},
|
|
1289
|
+
|
|
1290
|
+
logAllErrors: true,
|
|
1291
|
+
handleErrors: async (options: HandleErrorHookOptions<UserRequest>): Promise<void> => {
|
|
1292
|
+
const { errorLog, commonBuffer } = options;
|
|
1293
|
+
if (commonBuffer) commonBuffer.errors.push(errorLog);
|
|
1294
|
+
},
|
|
1295
|
+
|
|
1296
|
+
executionContext,
|
|
1297
|
+
metricsGuardrails
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
if (result.success) {
|
|
1301
|
+
console.log('User created:', result.data);
|
|
1302
|
+
} else {
|
|
1303
|
+
console.error('Failed:', result.error);
|
|
2374
1304
|
}
|
|
2375
|
-
});
|
|
2376
|
-
|
|
2377
|
-
// Automatic SLA violation detection
|
|
2378
|
-
if (result.validation && !result.validation.isValid) {
|
|
2379
|
-
const criticalAnomalies = result.validation.anomalies.filter(a => a.severity === 'CRITICAL');
|
|
2380
|
-
if (criticalAnomalies.length > 0) {
|
|
2381
|
-
// Trigger alerts for SLA violations
|
|
2382
|
-
console.error('SLA violated:', criticalAnomalies);
|
|
2383
|
-
}
|
|
2384
|
-
}
|
|
2385
|
-
```
|
|
2386
|
-
|
|
2387
|
-
### 6. Apply Rate & Concurrency Limits
|
|
2388
|
-
|
|
2389
|
-
Respect external quotas and capacity.
|
|
2390
|
-
|
|
2391
|
-
```typescript
|
|
2392
|
-
interface ApiRequest {}
|
|
2393
|
-
interface ApiResponse { result: any; }
|
|
2394
|
-
|
|
2395
|
-
// API allows 100 req/second, use 80% headroom
|
|
2396
|
-
const rateLimit = { maxRequests: 80, windowMs: 1000 };
|
|
2397
|
-
|
|
2398
|
-
// Database connection pool has 10 slots, use 5
|
|
2399
|
-
const maxConcurrent = 5;
|
|
2400
1305
|
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
});
|
|
1306
|
+
console.log('Metrics:', result.metrics);
|
|
1307
|
+
console.log('Buffer state:', buffer.read());
|
|
1308
|
+
})();
|
|
2405
1309
|
```
|
|
2406
1310
|
|
|
2407
|
-
|
|
1311
|
+
## TypeScript Support
|
|
2408
1312
|
|
|
2409
|
-
|
|
1313
|
+
This library is written in TypeScript and includes full type definitions:
|
|
2410
1314
|
|
|
2411
1315
|
```typescript
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
1316
|
+
import type {
|
|
1317
|
+
STABLE_REQUEST,
|
|
1318
|
+
STABLE_REQUEST_RESULT,
|
|
1319
|
+
StableBufferOptions,
|
|
1320
|
+
StableBufferTransactionLog,
|
|
1321
|
+
CircuitBreakerConfig,
|
|
1322
|
+
CacheConfig,
|
|
1323
|
+
MetricsGuardrails
|
|
1324
|
+
} from 'stable-request';
|
|
2420
1325
|
```
|
|
2421
1326
|
|
|
2422
|
-
|
|
1327
|
+
## Migration to stable-infra
|
|
2423
1328
|
|
|
2424
|
-
|
|
1329
|
+
If you need workflows, schedulers, or advanced orchestration, migrating to stable-infra is straightforward:
|
|
2425
1330
|
|
|
2426
1331
|
```typescript
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
await stableRequest<DataRequest, DataResponse>({
|
|
2431
|
-
reqData: { path: '/data' },
|
|
2432
|
-
resReq: true,
|
|
2433
|
-
maxSerializableChars: 500, // Truncate logs to 500 chars
|
|
2434
|
-
handleSuccessfulAttemptData: ({ successfulAttemptData, maxSerializableChars }) => {
|
|
2435
|
-
console.log(safelyStringify(successfulAttemptData, maxSerializableChars));
|
|
2436
|
-
}
|
|
2437
|
-
});
|
|
2438
|
-
```
|
|
2439
|
-
|
|
2440
|
-
### 8. Use Non-Linear Workflows for Polling
|
|
2441
|
-
|
|
2442
|
-
REPLAY action simplifies polling logic.
|
|
1332
|
+
// stable-request
|
|
1333
|
+
import { stableRequest } from 'stable-request';
|
|
2443
1334
|
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
{
|
|
2447
|
-
id: 'wait-for-job',
|
|
2448
|
-
allowReplay: true,
|
|
2449
|
-
maxReplayCount: 10,
|
|
2450
|
-
requests: [
|
|
2451
|
-
{
|
|
2452
|
-
id: 'check-status',
|
|
2453
|
-
requestOptions: {
|
|
2454
|
-
reqData: { path: '/jobs/123' },
|
|
2455
|
-
resReq: true,
|
|
2456
|
-
attempts: 1
|
|
2457
|
-
}
|
|
2458
|
-
}
|
|
2459
|
-
],
|
|
2460
|
-
phaseDecisionHook: async ({ phaseResult }) => {
|
|
2461
|
-
const status = (phaseResult.responses[0].data as any)?.status;
|
|
2462
|
-
if (status === 'pending') {
|
|
2463
|
-
return { action: PHASE_DECISION_ACTIONS.REPLAY };
|
|
2464
|
-
}
|
|
2465
|
-
return { action: PHASE_DECISION_ACTIONS.CONTINUE };
|
|
2466
|
-
}
|
|
2467
|
-
}
|
|
2468
|
-
];
|
|
1335
|
+
// stable-infra (same API, more features)
|
|
1336
|
+
import { stableRequest } from 'stable-infra';
|
|
2469
1337
|
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
```typescript
|
|
2480
|
-
// Clearer than 6 phases with conditional concurrency markers
|
|
2481
|
-
const graph = new WorkflowGraphBuilder()
|
|
2482
|
-
.addParallelGroup('fetch', ['fetch-users', 'fetch-posts', 'fetch-comments'])
|
|
2483
|
-
.addMergePoint('sync', ['fetch'])
|
|
2484
|
-
.addPhase('aggregate', {...})
|
|
2485
|
-
.connectSequence('fetch', 'sync', 'aggregate')
|
|
2486
|
-
.build();
|
|
2487
|
-
|
|
2488
|
-
await stableWorkflowGraph(graph);
|
|
1338
|
+
// Plus you get access to:
|
|
1339
|
+
import {
|
|
1340
|
+
stableWorkflow,
|
|
1341
|
+
stableApiGateway,
|
|
1342
|
+
stableFunction,
|
|
1343
|
+
stableScheduler,
|
|
1344
|
+
stableWorkflowGraph
|
|
1345
|
+
} from 'stable-infra';
|
|
2489
1346
|
```
|
|
2490
1347
|
|
|
2491
|
-
|
|
1348
|
+
## License
|
|
2492
1349
|
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
```typescript
|
|
2496
|
-
await stableWorkflow(phases, {
|
|
2497
|
-
workflowId: 'payment-pipeline',
|
|
2498
|
-
trialMode: { enabled: true }, // Dry-run before production
|
|
2499
|
-
handlePhaseCompletion: ({ phaseResult }) => {
|
|
2500
|
-
console.log(`Trial phase: ${phaseResult.phaseId}, success=${phaseResult.success}`);
|
|
2501
|
-
}
|
|
2502
|
-
});
|
|
2503
|
-
|
|
2504
|
-
// If satisfied, deploy with trialMode: { enabled: false }
|
|
2505
|
-
```
|
|
1350
|
+
MIT
|
|
2506
1351
|
|
|
2507
1352
|
---
|
|
2508
1353
|
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
@emmvish/stable-request provides a unified, type-safe framework for resilient execution:
|
|
2512
|
-
|
|
2513
|
-
- **Single calls** via `stableRequest` (APIs) or `stableFunction` (pure functions)
|
|
2514
|
-
- **Batch orchestration** via `stableApiGateway` (concurrent/sequential mixed items)
|
|
2515
|
-
- **Phased workflows** via `stableWorkflow` (array-based, non-linear, branched)
|
|
2516
|
-
- **Graph workflows** via `stableWorkflowGraph` (DAG, explicit parallelism)
|
|
2517
|
-
|
|
2518
|
-
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.
|
|
2519
|
-
|
|
2520
|
-
Build resilient, observable, type-safe systems with confidence.
|
|
1354
|
+
**stable-request** is now in maintenance mode. For new projects, please use [**stable-infra**](https://npmjs.com/package/@emmvish/stable-infra).
|