@agirails/sdk 2.0.3 → 2.2.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/README.md +536 -87
- package/dist/adapters/BaseAdapter.js +2 -2
- package/dist/adapters/BaseAdapter.js.map +1 -1
- package/dist/adapters/BasicAdapter.d.ts.map +1 -1
- package/dist/adapters/BasicAdapter.js +8 -0
- package/dist/adapters/BasicAdapter.js.map +1 -1
- package/dist/adapters/StandardAdapter.d.ts +10 -5
- package/dist/adapters/StandardAdapter.d.ts.map +1 -1
- package/dist/adapters/StandardAdapter.js +19 -6
- package/dist/adapters/StandardAdapter.js.map +1 -1
- package/dist/builders/QuoteBuilder.js +1 -1
- package/dist/builders/QuoteBuilder.js.map +1 -1
- package/dist/cli/commands/config.js +1 -1
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/simulate.js.map +1 -1
- package/dist/cli/commands/time.d.ts.map +1 -1
- package/dist/cli/commands/time.js.map +1 -1
- package/dist/config/networks.d.ts +9 -0
- package/dist/config/networks.d.ts.map +1 -1
- package/dist/config/networks.js +25 -10
- package/dist/config/networks.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/level0/provide.d.ts.map +1 -1
- package/dist/level0/provide.js +2 -1
- package/dist/level0/provide.js.map +1 -1
- package/dist/level0/request.d.ts.map +1 -1
- package/dist/level0/request.js +1 -2
- package/dist/level0/request.js.map +1 -1
- package/dist/level1/Agent.d.ts.map +1 -1
- package/dist/level1/Agent.js +11 -3
- package/dist/level1/Agent.js.map +1 -1
- package/dist/level1/pricing/PriceCalculator.js +1 -1
- package/dist/level1/pricing/PriceCalculator.js.map +1 -1
- package/dist/level1/types/Options.d.ts.map +1 -1
- package/dist/protocol/ACTPKernel.d.ts.map +1 -1
- package/dist/protocol/ACTPKernel.js +7 -5
- package/dist/protocol/ACTPKernel.js.map +1 -1
- package/dist/protocol/DIDResolver.js +1 -1
- package/dist/protocol/DIDResolver.js.map +1 -1
- package/dist/protocol/EASHelper.d.ts.map +1 -1
- package/dist/protocol/EASHelper.js +2 -3
- package/dist/protocol/EASHelper.js.map +1 -1
- package/dist/protocol/MessageSigner.d.ts.map +1 -1
- package/dist/protocol/MessageSigner.js +9 -9
- package/dist/protocol/MessageSigner.js.map +1 -1
- package/dist/protocol/ProofGenerator.d.ts.map +1 -1
- package/dist/protocol/ProofGenerator.js +1 -0
- package/dist/protocol/ProofGenerator.js.map +1 -1
- package/dist/runtime/BlockchainRuntime.d.ts +10 -3
- package/dist/runtime/BlockchainRuntime.d.ts.map +1 -1
- package/dist/runtime/BlockchainRuntime.js +41 -25
- package/dist/runtime/BlockchainRuntime.js.map +1 -1
- package/dist/runtime/IACTPRuntime.d.ts +15 -0
- package/dist/runtime/IACTPRuntime.d.ts.map +1 -1
- package/dist/runtime/MockRuntime.d.ts +7 -0
- package/dist/runtime/MockRuntime.d.ts.map +1 -1
- package/dist/runtime/MockRuntime.js +15 -4
- package/dist/runtime/MockRuntime.js.map +1 -1
- package/dist/runtime/types/MockState.d.ts +5 -2
- package/dist/runtime/types/MockState.d.ts.map +1 -1
- package/dist/runtime/types/MockState.js.map +1 -1
- package/dist/storage/ArchiveBundleBuilder.d.ts +150 -0
- package/dist/storage/ArchiveBundleBuilder.d.ts.map +1 -0
- package/dist/storage/ArchiveBundleBuilder.js +468 -0
- package/dist/storage/ArchiveBundleBuilder.js.map +1 -0
- package/dist/storage/ArweaveClient.d.ts +271 -0
- package/dist/storage/ArweaveClient.d.ts.map +1 -0
- package/dist/storage/ArweaveClient.js +761 -0
- package/dist/storage/ArweaveClient.js.map +1 -0
- package/dist/storage/FilebaseClient.d.ts +193 -0
- package/dist/storage/FilebaseClient.d.ts.map +1 -0
- package/dist/storage/FilebaseClient.js +643 -0
- package/dist/storage/FilebaseClient.js.map +1 -0
- package/dist/storage/index.d.ts +47 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +64 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/types.d.ts +291 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +18 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/types/state.d.ts +5 -4
- package/dist/types/state.d.ts.map +1 -1
- package/dist/types/state.js +10 -9
- package/dist/types/state.js.map +1 -1
- package/dist/utils/ErrorRecoveryGuide.d.ts.map +1 -1
- package/dist/utils/ErrorRecoveryGuide.js +1 -2
- package/dist/utils/ErrorRecoveryGuide.js.map +1 -1
- package/dist/utils/IPFSClient.d.ts.map +1 -1
- package/dist/utils/IPFSClient.js +5 -2
- package/dist/utils/IPFSClient.js.map +1 -1
- package/dist/utils/NonceManager.d.ts.map +1 -1
- package/dist/utils/NonceManager.js +3 -2
- package/dist/utils/NonceManager.js.map +1 -1
- package/dist/utils/UsedAttestationTracker.d.ts +1 -1
- package/dist/utils/UsedAttestationTracker.d.ts.map +1 -1
- package/dist/utils/UsedAttestationTracker.js +4 -4
- package/dist/utils/UsedAttestationTracker.js.map +1 -1
- package/dist/utils/circuitBreaker.d.ts +136 -0
- package/dist/utils/circuitBreaker.d.ts.map +1 -0
- package/dist/utils/circuitBreaker.js +253 -0
- package/dist/utils/circuitBreaker.js.map +1 -0
- package/dist/utils/retry.d.ts +120 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +260 -0
- package/dist/utils/retry.js.map +1 -0
- package/dist/utils/validation.d.ts +100 -0
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +248 -1
- package/dist/utils/validation.js.map +1 -1
- package/package.json +14 -2
- package/src/adapters/BaseAdapter.ts +2 -2
- package/src/adapters/BasicAdapter.ts +12 -2
- package/src/adapters/StandardAdapter.ts +27 -7
- package/src/builders/QuoteBuilder.ts +1 -1
- package/src/cli/commands/config.ts +1 -1
- package/src/cli/commands/simulate.ts +1 -1
- package/src/cli/commands/time.ts +1 -2
- package/src/config/networks.ts +34 -10
- package/src/index.ts +54 -0
- package/src/level0/provide.ts +2 -1
- package/src/level0/request.ts +1 -2
- package/src/level1/Agent.ts +15 -5
- package/src/level1/pricing/PriceCalculator.ts +1 -1
- package/src/level1/types/Options.ts +1 -1
- package/src/protocol/ACTPKernel.ts +7 -5
- package/src/protocol/DIDResolver.ts +1 -1
- package/src/protocol/EASHelper.ts +2 -5
- package/src/protocol/MessageSigner.ts +9 -15
- package/src/protocol/ProofGenerator.ts +1 -0
- package/src/runtime/BlockchainRuntime.ts +42 -48
- package/src/runtime/IACTPRuntime.ts +16 -0
- package/src/runtime/MockRuntime.ts +16 -6
- package/src/runtime/types/MockState.ts +5 -2
- package/src/storage/ArchiveBundleBuilder.ts +563 -0
- package/src/storage/ArweaveClient.ts +945 -0
- package/src/storage/FilebaseClient.ts +790 -0
- package/src/storage/index.ts +96 -0
- package/src/storage/types.ts +348 -0
- package/src/types/state.ts +10 -9
- package/src/utils/ErrorRecoveryGuide.ts +1 -2
- package/src/utils/IPFSClient.ts +5 -4
- package/src/utils/NonceManager.ts +3 -2
- package/src/utils/UsedAttestationTracker.ts +4 -6
- package/src/utils/circuitBreaker.ts +324 -0
- package/src/utils/fsSafe.ts +5 -0
- package/src/utils/retry.ts +365 -0
- package/src/utils/validation.ts +295 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit Breaker - Gateway Health Tracking (Retry Amplification Protection)
|
|
3
|
+
*
|
|
4
|
+
* Implements the Circuit Breaker pattern for storage gateways:
|
|
5
|
+
* - Tracks consecutive failures per gateway
|
|
6
|
+
* - "Opens" circuit after threshold failures (blocks requests)
|
|
7
|
+
* - Auto-resets after cooldown period
|
|
8
|
+
*
|
|
9
|
+
* This prevents retry amplification attacks where a malicious/failing
|
|
10
|
+
* gateway causes excessive retries.
|
|
11
|
+
*
|
|
12
|
+
* @module utils/circuitBreaker
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Circuit state
|
|
21
|
+
*/
|
|
22
|
+
export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gateway health record
|
|
26
|
+
*/
|
|
27
|
+
interface GatewayHealth {
|
|
28
|
+
/** Consecutive failure count */
|
|
29
|
+
failures: number;
|
|
30
|
+
/** Timestamp of last failure */
|
|
31
|
+
lastFailure: number;
|
|
32
|
+
/** Current circuit state */
|
|
33
|
+
state: CircuitState;
|
|
34
|
+
/** Timestamp when circuit opened */
|
|
35
|
+
openedAt?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Circuit breaker configuration
|
|
40
|
+
*/
|
|
41
|
+
export interface CircuitBreakerConfig {
|
|
42
|
+
/** Number of failures before opening circuit (default: 5) */
|
|
43
|
+
failureThreshold?: number;
|
|
44
|
+
|
|
45
|
+
/** Cooldown period in ms before attempting reset (default: 60000 = 1 min) */
|
|
46
|
+
resetTimeoutMs?: number;
|
|
47
|
+
|
|
48
|
+
/** Time window in ms for counting failures (default: 300000 = 5 min) */
|
|
49
|
+
failureWindowMs?: number;
|
|
50
|
+
|
|
51
|
+
/** Number of successes in half-open to close circuit (default: 2) */
|
|
52
|
+
successThreshold?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Constants
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
const DEFAULT_FAILURE_THRESHOLD = 5;
|
|
60
|
+
const DEFAULT_RESET_TIMEOUT_MS = 60000; // 1 minute
|
|
61
|
+
const DEFAULT_FAILURE_WINDOW_MS = 300000; // 5 minutes
|
|
62
|
+
const DEFAULT_SUCCESS_THRESHOLD = 2;
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// GatewayCircuitBreaker Class
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Circuit Breaker for gateway health management
|
|
70
|
+
*
|
|
71
|
+
* Usage:
|
|
72
|
+
* ```typescript
|
|
73
|
+
* const circuitBreaker = new GatewayCircuitBreaker();
|
|
74
|
+
*
|
|
75
|
+
* // Before making request
|
|
76
|
+
* if (!circuitBreaker.isHealthy(gatewayUrl)) {
|
|
77
|
+
* throw new Error('Gateway circuit open');
|
|
78
|
+
* }
|
|
79
|
+
*
|
|
80
|
+
* try {
|
|
81
|
+
* const result = await fetchFromGateway(gatewayUrl);
|
|
82
|
+
* circuitBreaker.recordSuccess(gatewayUrl);
|
|
83
|
+
* return result;
|
|
84
|
+
* } catch (error) {
|
|
85
|
+
* circuitBreaker.recordFailure(gatewayUrl);
|
|
86
|
+
* throw error;
|
|
87
|
+
* }
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export class GatewayCircuitBreaker {
|
|
91
|
+
private readonly health = new Map<string, GatewayHealth>();
|
|
92
|
+
private readonly config: Required<CircuitBreakerConfig>;
|
|
93
|
+
private halfOpenSuccesses = new Map<string, number>();
|
|
94
|
+
|
|
95
|
+
constructor(config: CircuitBreakerConfig = {}) {
|
|
96
|
+
this.config = {
|
|
97
|
+
failureThreshold: config.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD,
|
|
98
|
+
resetTimeoutMs: config.resetTimeoutMs ?? DEFAULT_RESET_TIMEOUT_MS,
|
|
99
|
+
failureWindowMs: config.failureWindowMs ?? DEFAULT_FAILURE_WINDOW_MS,
|
|
100
|
+
successThreshold: config.successThreshold ?? DEFAULT_SUCCESS_THRESHOLD
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if gateway is healthy (circuit closed or half-open)
|
|
106
|
+
*
|
|
107
|
+
* @param url - Gateway URL
|
|
108
|
+
* @returns true if requests should be allowed
|
|
109
|
+
*/
|
|
110
|
+
isHealthy(url: string): boolean {
|
|
111
|
+
const state = this.getState(url);
|
|
112
|
+
return state !== 'OPEN';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get current circuit state for gateway
|
|
117
|
+
*
|
|
118
|
+
* @param url - Gateway URL
|
|
119
|
+
* @returns Circuit state
|
|
120
|
+
*/
|
|
121
|
+
getState(url: string): CircuitState {
|
|
122
|
+
const health = this.health.get(url);
|
|
123
|
+
|
|
124
|
+
if (!health) {
|
|
125
|
+
return 'CLOSED';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check if circuit should transition from OPEN to HALF_OPEN
|
|
129
|
+
if (health.state === 'OPEN') {
|
|
130
|
+
const timeSinceOpen = Date.now() - (health.openedAt || 0);
|
|
131
|
+
if (timeSinceOpen >= this.config.resetTimeoutMs) {
|
|
132
|
+
// Transition to half-open
|
|
133
|
+
health.state = 'HALF_OPEN';
|
|
134
|
+
this.halfOpenSuccesses.set(url, 0);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check if failures have expired (outside window)
|
|
139
|
+
if (health.state === 'CLOSED') {
|
|
140
|
+
const timeSinceLastFailure = Date.now() - health.lastFailure;
|
|
141
|
+
if (timeSinceLastFailure >= this.config.failureWindowMs) {
|
|
142
|
+
// Reset failure count
|
|
143
|
+
health.failures = 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return health.state;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Record a successful request
|
|
152
|
+
*
|
|
153
|
+
* @param url - Gateway URL
|
|
154
|
+
*/
|
|
155
|
+
recordSuccess(url: string): void {
|
|
156
|
+
const health = this.health.get(url);
|
|
157
|
+
|
|
158
|
+
if (!health) {
|
|
159
|
+
return; // No failures recorded, nothing to do
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (health.state === 'HALF_OPEN') {
|
|
163
|
+
// Count successes in half-open state
|
|
164
|
+
const successes = (this.halfOpenSuccesses.get(url) || 0) + 1;
|
|
165
|
+
this.halfOpenSuccesses.set(url, successes);
|
|
166
|
+
|
|
167
|
+
if (successes >= this.config.successThreshold) {
|
|
168
|
+
// Enough successes - close circuit
|
|
169
|
+
this.reset(url);
|
|
170
|
+
}
|
|
171
|
+
} else if (health.state === 'CLOSED') {
|
|
172
|
+
// Reset failure count on success
|
|
173
|
+
health.failures = 0;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Record a failed request
|
|
179
|
+
*
|
|
180
|
+
* @param url - Gateway URL
|
|
181
|
+
*/
|
|
182
|
+
recordFailure(url: string): void {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
let health = this.health.get(url);
|
|
185
|
+
|
|
186
|
+
if (!health) {
|
|
187
|
+
health = {
|
|
188
|
+
failures: 0,
|
|
189
|
+
lastFailure: now,
|
|
190
|
+
state: 'CLOSED'
|
|
191
|
+
};
|
|
192
|
+
this.health.set(url, health);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// If in half-open state, any failure opens circuit again
|
|
196
|
+
if (health.state === 'HALF_OPEN') {
|
|
197
|
+
health.state = 'OPEN';
|
|
198
|
+
health.openedAt = now;
|
|
199
|
+
health.failures = this.config.failureThreshold; // Keep at threshold
|
|
200
|
+
this.halfOpenSuccesses.delete(url);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check if previous failures have expired
|
|
205
|
+
const timeSinceLastFailure = now - health.lastFailure;
|
|
206
|
+
if (timeSinceLastFailure >= this.config.failureWindowMs) {
|
|
207
|
+
health.failures = 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Increment failure count
|
|
211
|
+
health.failures++;
|
|
212
|
+
health.lastFailure = now;
|
|
213
|
+
|
|
214
|
+
// Check if threshold reached
|
|
215
|
+
if (health.failures >= this.config.failureThreshold) {
|
|
216
|
+
health.state = 'OPEN';
|
|
217
|
+
health.openedAt = now;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Reset circuit for gateway (force close)
|
|
223
|
+
*
|
|
224
|
+
* @param url - Gateway URL
|
|
225
|
+
*/
|
|
226
|
+
reset(url: string): void {
|
|
227
|
+
this.health.delete(url);
|
|
228
|
+
this.halfOpenSuccesses.delete(url);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reset all circuits
|
|
233
|
+
*/
|
|
234
|
+
resetAll(): void {
|
|
235
|
+
this.health.clear();
|
|
236
|
+
this.halfOpenSuccesses.clear();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get health status for all tracked gateways
|
|
241
|
+
*
|
|
242
|
+
* @returns Map of gateway URL to health info
|
|
243
|
+
*/
|
|
244
|
+
getHealthStatus(): Map<string, { state: CircuitState; failures: number }> {
|
|
245
|
+
const status = new Map<string, { state: CircuitState; failures: number }>();
|
|
246
|
+
|
|
247
|
+
for (const [url, health] of this.health) {
|
|
248
|
+
status.set(url, {
|
|
249
|
+
state: this.getState(url), // This updates state if needed
|
|
250
|
+
failures: health.failures
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return status;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get failure count for gateway
|
|
259
|
+
*
|
|
260
|
+
* @param url - Gateway URL
|
|
261
|
+
* @returns Number of consecutive failures
|
|
262
|
+
*/
|
|
263
|
+
getFailureCount(url: string): number {
|
|
264
|
+
return this.health.get(url)?.failures || 0;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// Singleton Instance
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Default global circuit breaker instance
|
|
274
|
+
*
|
|
275
|
+
* Use this for simple cases where a single circuit breaker is sufficient.
|
|
276
|
+
* For more control, create your own GatewayCircuitBreaker instance.
|
|
277
|
+
*/
|
|
278
|
+
export const globalCircuitBreaker = new GatewayCircuitBreaker();
|
|
279
|
+
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// Helper Functions
|
|
282
|
+
// ============================================================================
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Wrap an async operation with circuit breaker protection
|
|
286
|
+
*
|
|
287
|
+
* @param url - Gateway URL to track
|
|
288
|
+
* @param operation - Async operation to execute
|
|
289
|
+
* @param circuitBreaker - Circuit breaker instance (default: global)
|
|
290
|
+
* @returns Operation result
|
|
291
|
+
* @throws Error if circuit is open, or operation error
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```typescript
|
|
295
|
+
* const result = await withCircuitBreaker(
|
|
296
|
+
* 'https://ipfs.filebase.io',
|
|
297
|
+
* () => fetch(url).then(r => r.json())
|
|
298
|
+
* );
|
|
299
|
+
* ```
|
|
300
|
+
*/
|
|
301
|
+
export async function withCircuitBreaker<T>(
|
|
302
|
+
url: string,
|
|
303
|
+
operation: () => Promise<T>,
|
|
304
|
+
circuitBreaker: GatewayCircuitBreaker = globalCircuitBreaker
|
|
305
|
+
): Promise<T> {
|
|
306
|
+
// Check if circuit allows request
|
|
307
|
+
if (!circuitBreaker.isHealthy(url)) {
|
|
308
|
+
const state = circuitBreaker.getState(url);
|
|
309
|
+
throw new Error(
|
|
310
|
+
`Circuit breaker OPEN for gateway: ${url}. ` +
|
|
311
|
+
`State: ${state}, Failures: ${circuitBreaker.getFailureCount(url)}. ` +
|
|
312
|
+
`Please wait for cooldown period before retrying.`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const result = await operation();
|
|
318
|
+
circuitBreaker.recordSuccess(url);
|
|
319
|
+
return result;
|
|
320
|
+
} catch (error) {
|
|
321
|
+
circuitBreaker.recordFailure(url);
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/utils/fsSafe.ts
CHANGED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry Utility - Exponential Backoff with Jitter (P1-2)
|
|
3
|
+
*
|
|
4
|
+
* Implements retry logic for storage operations with:
|
|
5
|
+
* - Exponential backoff (doubles delay each attempt)
|
|
6
|
+
* - Random jitter (prevents thundering herd)
|
|
7
|
+
* - Maximum retry limit
|
|
8
|
+
* - Configurable retry conditions
|
|
9
|
+
*
|
|
10
|
+
* @module utils/retry
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { StorageRateLimitError } from '../errors';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Retry configuration options
|
|
21
|
+
*/
|
|
22
|
+
export interface RetryOptions {
|
|
23
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
24
|
+
maxAttempts?: number;
|
|
25
|
+
|
|
26
|
+
/** Initial delay in milliseconds (default: 1000) */
|
|
27
|
+
initialDelayMs?: number;
|
|
28
|
+
|
|
29
|
+
/** Maximum delay in milliseconds (default: 30000) */
|
|
30
|
+
maxDelayMs?: number;
|
|
31
|
+
|
|
32
|
+
/** Multiplier for exponential backoff (default: 2) */
|
|
33
|
+
backoffMultiplier?: number;
|
|
34
|
+
|
|
35
|
+
/** Jitter factor 0-1 (default: 0.1 = ±10%) */
|
|
36
|
+
jitterFactor?: number;
|
|
37
|
+
|
|
38
|
+
/** Function to determine if error is retryable (default: built-in check) */
|
|
39
|
+
isRetryable?: (error: unknown) => boolean;
|
|
40
|
+
|
|
41
|
+
/** Callback for each retry attempt (for logging) */
|
|
42
|
+
onRetry?: (attempt: number, error: unknown, delayMs: number) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Result of retry operation
|
|
47
|
+
*/
|
|
48
|
+
export interface RetryResult<T> {
|
|
49
|
+
/** Operation result (if successful) */
|
|
50
|
+
result?: T;
|
|
51
|
+
|
|
52
|
+
/** Final error (if all retries failed) */
|
|
53
|
+
error?: unknown;
|
|
54
|
+
|
|
55
|
+
/** Number of attempts made */
|
|
56
|
+
attempts: number;
|
|
57
|
+
|
|
58
|
+
/** Total time spent (including delays) */
|
|
59
|
+
totalTimeMs: number;
|
|
60
|
+
|
|
61
|
+
/** Whether operation succeeded */
|
|
62
|
+
success: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Constants
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
70
|
+
const DEFAULT_INITIAL_DELAY_MS = 1000;
|
|
71
|
+
const DEFAULT_MAX_DELAY_MS = 10000; // Reduced from 30s to 10s (NEW-4: prevent long stalls)
|
|
72
|
+
const DEFAULT_BACKOFF_MULTIPLIER = 2;
|
|
73
|
+
const DEFAULT_JITTER_FACTOR = 0.1;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default list of retryable HTTP status codes
|
|
77
|
+
*/
|
|
78
|
+
const RETRYABLE_STATUS_CODES = new Set([
|
|
79
|
+
408, // Request Timeout
|
|
80
|
+
429, // Too Many Requests
|
|
81
|
+
500, // Internal Server Error
|
|
82
|
+
502, // Bad Gateway
|
|
83
|
+
503, // Service Unavailable
|
|
84
|
+
504 // Gateway Timeout
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Default list of retryable error codes
|
|
89
|
+
*/
|
|
90
|
+
const RETRYABLE_ERROR_CODES = new Set([
|
|
91
|
+
'ECONNRESET',
|
|
92
|
+
'ECONNREFUSED',
|
|
93
|
+
'ETIMEDOUT',
|
|
94
|
+
'ENOTFOUND',
|
|
95
|
+
'ENETUNREACH',
|
|
96
|
+
'EAI_AGAIN',
|
|
97
|
+
'EPIPE',
|
|
98
|
+
'EHOSTUNREACH'
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Functions
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Default function to determine if error is retryable
|
|
107
|
+
*
|
|
108
|
+
* @param error - Error to check
|
|
109
|
+
* @returns True if error should trigger retry
|
|
110
|
+
*/
|
|
111
|
+
export function isRetryableError(error: unknown): boolean {
|
|
112
|
+
if (!error) return false;
|
|
113
|
+
|
|
114
|
+
// Rate limit errors are always retryable
|
|
115
|
+
if (error instanceof StorageRateLimitError) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const e = error as any;
|
|
120
|
+
|
|
121
|
+
// Check HTTP status codes
|
|
122
|
+
if (e.statusCode && RETRYABLE_STATUS_CODES.has(e.statusCode)) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (e.status && RETRYABLE_STATUS_CODES.has(e.status)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check error codes (network errors)
|
|
131
|
+
if (e.code && RETRYABLE_ERROR_CODES.has(e.code)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check for timeout errors
|
|
136
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check message for common retryable patterns
|
|
141
|
+
const message = String(e.message || e).toLowerCase();
|
|
142
|
+
if (
|
|
143
|
+
message.includes('timeout') ||
|
|
144
|
+
message.includes('rate limit') ||
|
|
145
|
+
message.includes('too many requests') ||
|
|
146
|
+
message.includes('service unavailable') ||
|
|
147
|
+
message.includes('temporarily unavailable')
|
|
148
|
+
) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Calculate delay with exponential backoff and jitter
|
|
157
|
+
*
|
|
158
|
+
* @param attempt - Current attempt number (1-based)
|
|
159
|
+
* @param options - Retry options
|
|
160
|
+
* @returns Delay in milliseconds
|
|
161
|
+
*/
|
|
162
|
+
export function calculateBackoffDelay(
|
|
163
|
+
attempt: number,
|
|
164
|
+
options: RetryOptions = {}
|
|
165
|
+
): number {
|
|
166
|
+
const {
|
|
167
|
+
initialDelayMs = DEFAULT_INITIAL_DELAY_MS,
|
|
168
|
+
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
|
169
|
+
backoffMultiplier = DEFAULT_BACKOFF_MULTIPLIER,
|
|
170
|
+
jitterFactor = DEFAULT_JITTER_FACTOR
|
|
171
|
+
} = options;
|
|
172
|
+
|
|
173
|
+
// Exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
|
|
174
|
+
const exponentialDelay = initialDelayMs * Math.pow(backoffMultiplier, attempt - 1);
|
|
175
|
+
|
|
176
|
+
// Cap at max delay
|
|
177
|
+
const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
|
|
178
|
+
|
|
179
|
+
// Add jitter: ±jitterFactor
|
|
180
|
+
const jitter = cappedDelay * jitterFactor * (Math.random() * 2 - 1);
|
|
181
|
+
const finalDelay = Math.max(0, cappedDelay + jitter);
|
|
182
|
+
|
|
183
|
+
return Math.round(finalDelay);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Sleep for specified duration
|
|
188
|
+
*
|
|
189
|
+
* @param ms - Milliseconds to sleep
|
|
190
|
+
*/
|
|
191
|
+
function sleep(ms: number): Promise<void> {
|
|
192
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Execute async operation with retry logic
|
|
197
|
+
*
|
|
198
|
+
* Implements exponential backoff with jitter for handling transient failures.
|
|
199
|
+
* Particularly useful for storage operations that may fail due to:
|
|
200
|
+
* - Rate limiting
|
|
201
|
+
* - Network timeouts
|
|
202
|
+
* - Temporary service unavailability
|
|
203
|
+
*
|
|
204
|
+
* @param operation - Async function to execute
|
|
205
|
+
* @param options - Retry configuration
|
|
206
|
+
* @returns Promise resolving to operation result or throwing final error
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```typescript
|
|
210
|
+
* // Basic usage
|
|
211
|
+
* const result = await withRetry(
|
|
212
|
+
* () => client.uploadJSON(data),
|
|
213
|
+
* { maxAttempts: 3 }
|
|
214
|
+
* );
|
|
215
|
+
*
|
|
216
|
+
* // With logging
|
|
217
|
+
* const result = await withRetry(
|
|
218
|
+
* () => client.downloadJSON(cid),
|
|
219
|
+
* {
|
|
220
|
+
* maxAttempts: 5,
|
|
221
|
+
* onRetry: (attempt, error, delay) => {
|
|
222
|
+
* console.log(`Retry ${attempt}, waiting ${delay}ms:`, error);
|
|
223
|
+
* }
|
|
224
|
+
* }
|
|
225
|
+
* );
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export async function withRetry<T>(
|
|
229
|
+
operation: () => Promise<T>,
|
|
230
|
+
options: RetryOptions = {}
|
|
231
|
+
): Promise<T> {
|
|
232
|
+
const {
|
|
233
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
234
|
+
isRetryable = isRetryableError,
|
|
235
|
+
onRetry
|
|
236
|
+
} = options;
|
|
237
|
+
|
|
238
|
+
let lastError: unknown;
|
|
239
|
+
const startTime = Date.now();
|
|
240
|
+
|
|
241
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
242
|
+
try {
|
|
243
|
+
return await operation();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
lastError = error;
|
|
246
|
+
|
|
247
|
+
// Check if this is the last attempt
|
|
248
|
+
if (attempt >= maxAttempts) {
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Check if error is retryable
|
|
253
|
+
if (!isRetryable(error)) {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Calculate delay
|
|
258
|
+
let delayMs = calculateBackoffDelay(attempt, options);
|
|
259
|
+
|
|
260
|
+
// If rate limit error with retry-after header, use that instead
|
|
261
|
+
if (error instanceof StorageRateLimitError && error.details?.retryAfter) {
|
|
262
|
+
delayMs = Math.max(delayMs, error.details.retryAfter * 1000);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Notify callback
|
|
266
|
+
if (onRetry) {
|
|
267
|
+
onRetry(attempt, error, delayMs);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Wait before retry
|
|
271
|
+
await sleep(delayMs);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// All retries failed
|
|
276
|
+
throw lastError;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Execute async operation with retry logic, returning detailed result
|
|
281
|
+
*
|
|
282
|
+
* Unlike `withRetry`, this function never throws - it returns a result object
|
|
283
|
+
* with success/failure status and metadata.
|
|
284
|
+
*
|
|
285
|
+
* @param operation - Async function to execute
|
|
286
|
+
* @param options - Retry configuration
|
|
287
|
+
* @returns Promise resolving to detailed result object
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```typescript
|
|
291
|
+
* const result = await withRetryResult(
|
|
292
|
+
* () => client.uploadJSON(data),
|
|
293
|
+
* { maxAttempts: 3 }
|
|
294
|
+
* );
|
|
295
|
+
*
|
|
296
|
+
* if (result.success) {
|
|
297
|
+
* console.log('Uploaded:', result.result);
|
|
298
|
+
* } else {
|
|
299
|
+
* console.error(`Failed after ${result.attempts} attempts:`, result.error);
|
|
300
|
+
* }
|
|
301
|
+
* ```
|
|
302
|
+
*/
|
|
303
|
+
export async function withRetryResult<T>(
|
|
304
|
+
operation: () => Promise<T>,
|
|
305
|
+
options: RetryOptions = {}
|
|
306
|
+
): Promise<RetryResult<T>> {
|
|
307
|
+
const {
|
|
308
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
309
|
+
isRetryable = isRetryableError,
|
|
310
|
+
onRetry
|
|
311
|
+
} = options;
|
|
312
|
+
|
|
313
|
+
let lastError: unknown;
|
|
314
|
+
const startTime = Date.now();
|
|
315
|
+
let attempts = 0;
|
|
316
|
+
|
|
317
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
318
|
+
attempts = attempt;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const result = await operation();
|
|
322
|
+
return {
|
|
323
|
+
result,
|
|
324
|
+
attempts,
|
|
325
|
+
totalTimeMs: Date.now() - startTime,
|
|
326
|
+
success: true
|
|
327
|
+
};
|
|
328
|
+
} catch (error) {
|
|
329
|
+
lastError = error;
|
|
330
|
+
|
|
331
|
+
// Check if this is the last attempt
|
|
332
|
+
if (attempt >= maxAttempts) {
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Check if error is retryable
|
|
337
|
+
if (!isRetryable(error)) {
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Calculate delay
|
|
342
|
+
let delayMs = calculateBackoffDelay(attempt, options);
|
|
343
|
+
|
|
344
|
+
// If rate limit error with retry-after header, use that instead
|
|
345
|
+
if (error instanceof StorageRateLimitError && error.details?.retryAfter) {
|
|
346
|
+
delayMs = Math.max(delayMs, error.details.retryAfter * 1000);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Notify callback
|
|
350
|
+
if (onRetry) {
|
|
351
|
+
onRetry(attempt, error, delayMs);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Wait before retry
|
|
355
|
+
await sleep(delayMs);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
error: lastError,
|
|
361
|
+
attempts,
|
|
362
|
+
totalTimeMs: Date.now() - startTime,
|
|
363
|
+
success: false
|
|
364
|
+
};
|
|
365
|
+
}
|