reqon-dsl 0.3.0 → 0.4.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 +23 -3
- package/dist/ast/nodes.d.ts +8 -0
- package/dist/auth/circuit-breaker.d.ts +11 -0
- package/dist/auth/circuit-breaker.js +83 -12
- package/dist/auth/credentials.d.ts +6 -1
- package/dist/auth/credentials.js +12 -4
- package/dist/auth/oauth2-provider.js +13 -3
- package/dist/auth/rate-limiter.d.ts +8 -1
- package/dist/auth/rate-limiter.js +30 -10
- package/dist/auth/token-store.js +8 -1
- package/dist/cli.d.ts +11 -1
- package/dist/cli.js +65 -6
- package/dist/config/constants.d.ts +15 -4
- package/dist/config/constants.js +15 -4
- package/dist/control/server.d.ts +17 -0
- package/dist/control/server.js +82 -5
- package/dist/control/types.d.ts +6 -0
- package/dist/debug/cli-debugger.js +8 -3
- package/dist/execution/store.js +2 -2
- package/dist/execution-log/events.d.ts +125 -0
- package/dist/execution-log/events.js +17 -0
- package/dist/execution-log/fold.d.ts +38 -0
- package/dist/execution-log/fold.js +54 -0
- package/dist/execution-log/index.d.ts +18 -0
- package/dist/execution-log/index.js +6 -0
- package/dist/execution-log/postgres-store.d.ts +36 -0
- package/dist/execution-log/postgres-store.js +108 -0
- package/dist/execution-log/resume.d.ts +11 -0
- package/dist/execution-log/resume.js +5 -0
- package/dist/execution-log/sqlite-store.d.ts +16 -0
- package/dist/execution-log/sqlite-store.js +101 -0
- package/dist/execution-log/store.d.ts +72 -0
- package/dist/execution-log/store.js +182 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/interpreter/context.d.ts +15 -0
- package/dist/interpreter/context.js +3 -0
- package/dist/interpreter/evaluator.js +38 -8
- package/dist/interpreter/executor.d.ts +63 -1
- package/dist/interpreter/executor.js +406 -30
- package/dist/interpreter/fetch-handler.d.ts +39 -1
- package/dist/interpreter/fetch-handler.js +84 -15
- package/dist/interpreter/http.d.ts +31 -2
- package/dist/interpreter/http.js +187 -26
- package/dist/interpreter/index.d.ts +3 -3
- package/dist/interpreter/index.js +3 -3
- package/dist/interpreter/pagination.d.ts +1 -1
- package/dist/interpreter/pagination.js +7 -1
- package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
- package/dist/interpreter/step-handlers/for-handler.js +18 -3
- package/dist/interpreter/step-handlers/match-handler.js +5 -2
- package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
- package/dist/interpreter/step-handlers/store-handler.js +25 -16
- package/dist/interpreter/step-handlers/validate-handler.js +4 -1
- package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
- package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
- package/dist/interpreter/store-manager.d.ts +1 -1
- package/dist/interpreter/store-manager.js +5 -1
- package/dist/loader/index.js +5 -8
- package/dist/mcp/sandbox.d.ts +41 -0
- package/dist/mcp/sandbox.js +76 -0
- package/dist/mcp/server.js +62 -9
- package/dist/oas/loader.d.ts +13 -1
- package/dist/oas/loader.js +25 -3
- package/dist/oas/mock-generator.js +13 -4
- package/dist/oas/validator.js +45 -5
- package/dist/observability/events.d.ts +6 -2
- package/dist/observability/events.js +0 -5
- package/dist/observability/logger.js +17 -10
- package/dist/observability/otel.d.ts +8 -0
- package/dist/observability/otel.js +45 -10
- package/dist/parser/action-parser.js +2 -2
- package/dist/parser/base.d.ts +7 -0
- package/dist/parser/base.js +11 -0
- package/dist/parser/expressions.d.ts +1 -0
- package/dist/parser/expressions.js +17 -4
- package/dist/parser/fetch-parser.js +13 -2
- package/dist/pause/index.d.ts +1 -0
- package/dist/pause/index.js +1 -0
- package/dist/pause/log-store.d.ts +33 -0
- package/dist/pause/log-store.js +98 -0
- package/dist/pause/manager.d.ts +12 -0
- package/dist/pause/manager.js +77 -28
- package/dist/pause/store.js +5 -3
- package/dist/scheduler/cron-parser.d.ts +10 -3
- package/dist/scheduler/cron-parser.js +227 -48
- package/dist/scheduler/scheduler.js +56 -22
- package/dist/stores/factory.d.ts +6 -0
- package/dist/stores/factory.js +11 -1
- package/dist/stores/file.js +9 -17
- package/dist/stores/memory.js +3 -12
- package/dist/stores/postgrest.d.ts +28 -0
- package/dist/stores/postgrest.js +84 -37
- package/dist/sync/index.d.ts +3 -2
- package/dist/sync/index.js +2 -1
- package/dist/sync/log-store.d.ts +30 -0
- package/dist/sync/log-store.js +45 -0
- package/dist/sync/store.js +1 -1
- package/dist/trace/index.d.ts +2 -0
- package/dist/trace/index.js +1 -0
- package/dist/trace/log-view.d.ts +57 -0
- package/dist/trace/log-view.js +76 -0
- package/dist/trace/recorder.d.ts +5 -1
- package/dist/trace/recorder.js +19 -6
- package/dist/trace/store.d.ts +6 -0
- package/dist/trace/store.js +47 -22
- package/dist/utils/deep-merge.d.ts +10 -0
- package/dist/utils/deep-merge.js +23 -0
- package/dist/utils/file.d.ts +13 -4
- package/dist/utils/file.js +70 -12
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/long-timeout.d.ts +19 -0
- package/dist/utils/long-timeout.js +33 -0
- package/dist/utils/path.d.ts +22 -1
- package/dist/utils/path.js +46 -1
- package/dist/utils/redact.d.ts +22 -0
- package/dist/utils/redact.js +42 -0
- package/dist/webhook/server.d.ts +9 -0
- package/dist/webhook/server.js +115 -30
- package/dist/webhook/types.d.ts +9 -1
- package/package.json +22 -4
|
@@ -18,6 +18,35 @@ export interface FetchHandlerDeps {
|
|
|
18
18
|
log: (message: string) => void;
|
|
19
19
|
/** Optional event emitter for observability */
|
|
20
20
|
emit?: <T>(type: EventType, payload: T) => void;
|
|
21
|
+
/**
|
|
22
|
+
* When set (durable mode), mutating requests carry a stable Idempotency-Key
|
|
23
|
+
* derived from (executionId, stepId, request signature) so retries and
|
|
24
|
+
* replays don't double-apply server-side where the API honours the header.
|
|
25
|
+
*/
|
|
26
|
+
idempotency?: {
|
|
27
|
+
executionId: string;
|
|
28
|
+
stepId: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Resumable (backfill) pagination, wired in durable mode. `resume` seeds the
|
|
32
|
+
* starting page/cursor from the folded log; `onPage` records each completed
|
|
33
|
+
* page back to the log; `maxItemsPerRun` bounds memory per run (a backfill
|
|
34
|
+
* that exceeds it stops cleanly and continues on the next resume).
|
|
35
|
+
*/
|
|
36
|
+
pagination?: {
|
|
37
|
+
resume?: {
|
|
38
|
+
page: number;
|
|
39
|
+
cursor?: string;
|
|
40
|
+
done: boolean;
|
|
41
|
+
};
|
|
42
|
+
onPage?: (progress: {
|
|
43
|
+
page: number;
|
|
44
|
+
cursor?: string;
|
|
45
|
+
recordCount: number;
|
|
46
|
+
done: boolean;
|
|
47
|
+
}) => Promise<void>;
|
|
48
|
+
maxItemsPerRun?: number;
|
|
49
|
+
};
|
|
21
50
|
}
|
|
22
51
|
export interface FetchResult {
|
|
23
52
|
data: unknown;
|
|
@@ -38,9 +67,18 @@ export declare class FetchHandler {
|
|
|
38
67
|
/**
|
|
39
68
|
* Record a sync checkpoint after successful fetch.
|
|
40
69
|
*/
|
|
41
|
-
recordCheckpoint(key: string, step: FetchStep, data: unknown): Promise<
|
|
70
|
+
recordCheckpoint(key: string, step: FetchStep, data: unknown, fallbackTime?: Date): Promise<Date | undefined>;
|
|
42
71
|
private resolveFetchTarget;
|
|
43
72
|
private resolveSinceParams;
|
|
73
|
+
/**
|
|
74
|
+
* Idempotency-Key header for a mutating request, when durable mode is on.
|
|
75
|
+
*
|
|
76
|
+
* GET is safe and gets no key. The key is a hash of
|
|
77
|
+
* (executionId, stepId, method, path, body) so it is stable across retries
|
|
78
|
+
* and replays of the same request, yet distinct per loop iteration (different
|
|
79
|
+
* path/body) — letting a cooperating API dedupe re-issued effects.
|
|
80
|
+
*/
|
|
81
|
+
private idempotencyKeyFor;
|
|
44
82
|
private executePaginated;
|
|
45
83
|
private countRecords;
|
|
46
84
|
private validateOASResponse;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { evaluate, interpolatePath } from './evaluator.js';
|
|
2
3
|
import { resolveOperation, getResponseSchema, validateResponse, generateMockData, } from '../oas/index.js';
|
|
3
4
|
import { generateCheckpointKey, formatSinceDate } from '../sync/index.js';
|
|
@@ -5,6 +6,12 @@ import { extractNestedValue } from '../utils/path.js';
|
|
|
5
6
|
import { createPaginationStrategy } from './pagination.js';
|
|
6
7
|
/** Maximum pages to fetch to prevent infinite loops */
|
|
7
8
|
const MAX_PAGINATION_PAGES = 100;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum items to accumulate in memory across all pages. Results are buffered
|
|
11
|
+
* in one array (no per-page streaming yet), so this caps memory regardless of
|
|
12
|
+
* page size. Hitting it stops pagination with a warning.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_PAGINATION_ITEMS = 100_000;
|
|
8
15
|
/**
|
|
9
16
|
* Handles HTTP fetch operations including pagination and incremental sync.
|
|
10
17
|
* Extracted from MissionExecutor for better separation of concerns.
|
|
@@ -52,12 +59,14 @@ export class FetchHandler {
|
|
|
52
59
|
pagesFetched = Array.isArray(result) ? undefined : 1; // Will be set by executePaginated
|
|
53
60
|
}
|
|
54
61
|
else {
|
|
62
|
+
const body = step.body ? evaluate(step.body, this.deps.ctx) : undefined;
|
|
55
63
|
const response = await client.request({
|
|
56
64
|
method: resolved.method,
|
|
57
65
|
path: resolved.path,
|
|
58
66
|
query: Object.keys(sinceQuery).length > 0 ? sinceQuery : undefined,
|
|
59
67
|
headers: Object.keys(sinceHeaders).length > 0 ? sinceHeaders : undefined,
|
|
60
|
-
body
|
|
68
|
+
body,
|
|
69
|
+
idempotencyKey: this.idempotencyKeyFor(resolved.method, resolved.path, body),
|
|
61
70
|
}, step.retry);
|
|
62
71
|
await this.validateOASResponse(resolved.sourceName, resolved.operationId, response.data);
|
|
63
72
|
data = response.data;
|
|
@@ -100,10 +109,13 @@ export class FetchHandler {
|
|
|
100
109
|
/**
|
|
101
110
|
* Record a sync checkpoint after successful fetch.
|
|
102
111
|
*/
|
|
103
|
-
async recordCheckpoint(key, step, data) {
|
|
112
|
+
async recordCheckpoint(key, step, data, fallbackTime) {
|
|
104
113
|
if (!this.deps.syncStore)
|
|
105
|
-
return;
|
|
106
|
-
|
|
114
|
+
return undefined;
|
|
115
|
+
// Prefer the data's own clock; otherwise fall back to when the request was
|
|
116
|
+
// issued (not now), so records written server-side during the fetch are
|
|
117
|
+
// re-fetched next run rather than skipped on clock skew.
|
|
118
|
+
let syncedAt = fallbackTime ?? new Date();
|
|
107
119
|
// If updateFrom is specified, extract the timestamp from response
|
|
108
120
|
if (step.since?.updateFrom && data && typeof data === 'object') {
|
|
109
121
|
const extracted = extractNestedValue(data, step.since.updateFrom);
|
|
@@ -134,6 +146,7 @@ export class FetchHandler {
|
|
|
134
146
|
recordsFetched: recordCount ?? 0,
|
|
135
147
|
isIncremental: true,
|
|
136
148
|
});
|
|
149
|
+
return syncedAt;
|
|
137
150
|
}
|
|
138
151
|
resolveFetchTarget(step) {
|
|
139
152
|
if (step.operationRef) {
|
|
@@ -205,6 +218,21 @@ export class FetchHandler {
|
|
|
205
218
|
}
|
|
206
219
|
return { query, headers, checkpointKey };
|
|
207
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Idempotency-Key header for a mutating request, when durable mode is on.
|
|
223
|
+
*
|
|
224
|
+
* GET is safe and gets no key. The key is a hash of
|
|
225
|
+
* (executionId, stepId, method, path, body) so it is stable across retries
|
|
226
|
+
* and replays of the same request, yet distinct per loop iteration (different
|
|
227
|
+
* path/body) — letting a cooperating API dedupe re-issued effects.
|
|
228
|
+
*/
|
|
229
|
+
idempotencyKeyFor(method, path, body) {
|
|
230
|
+
if (!this.deps.idempotency || method === 'GET')
|
|
231
|
+
return undefined;
|
|
232
|
+
const { executionId, stepId } = this.deps.idempotency;
|
|
233
|
+
const signature = `${executionId}${stepId}${method}${path}${JSON.stringify(body ?? null)}`;
|
|
234
|
+
return createHash('sha256').update(signature).digest('hex').slice(0, 32);
|
|
235
|
+
}
|
|
208
236
|
async executePaginated(step, client, basePath, method, sourceName, operationId, sinceQuery = {}, sinceHeaders = {}) {
|
|
209
237
|
const allResults = [];
|
|
210
238
|
const paginate = step.paginate;
|
|
@@ -213,7 +241,24 @@ export class FetchHandler {
|
|
|
213
241
|
page: 0,
|
|
214
242
|
pageSize: paginate.pageSize,
|
|
215
243
|
};
|
|
244
|
+
// Resumable (backfill) pagination: seed the cursor/page from the last
|
|
245
|
+
// persisted position so a restart continues from where it stopped.
|
|
246
|
+
const backfill = this.deps.pagination;
|
|
247
|
+
if (step.backfill && backfill?.resume) {
|
|
248
|
+
if (backfill.resume.done) {
|
|
249
|
+
this.deps.log('Backfill already complete; nothing to fetch.');
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
ctx.page = backfill.resume.page;
|
|
253
|
+
ctx.cursor = backfill.resume.cursor;
|
|
254
|
+
this.deps.log(`Resuming backfill from page ${ctx.page + 1}`);
|
|
255
|
+
}
|
|
256
|
+
// Per-run item cap. For a backfill this bounds memory per run: when hit, the
|
|
257
|
+
// run stops *incomplete* and a later resume continues — so an arbitrarily
|
|
258
|
+
// large backfill completes across runs without buffering it all at once.
|
|
259
|
+
const itemCap = backfill?.maxItemsPerRun ?? MAX_PAGINATION_ITEMS;
|
|
216
260
|
let hasMore = true;
|
|
261
|
+
let stoppedByCap = false;
|
|
217
262
|
while (hasMore) {
|
|
218
263
|
// Build query with pagination params
|
|
219
264
|
const paginationQuery = strategy.buildQuery(ctx);
|
|
@@ -224,22 +269,46 @@ export class FetchHandler {
|
|
|
224
269
|
await this.validateOASResponse(sourceName, operationId, response.data);
|
|
225
270
|
// Temporarily set response for until condition evaluation
|
|
226
271
|
this.deps.ctx.response = response.data;
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
const shouldStop = evaluate(step.until, this.deps.ctx);
|
|
230
|
-
if (shouldStop) {
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
// Extract results using strategy
|
|
272
|
+
// Extract and append the current page BEFORE deciding whether to stop, so
|
|
273
|
+
// the page that satisfies `until` is not silently dropped.
|
|
235
274
|
const pageResult = strategy.extractResults(response.data, ctx);
|
|
236
275
|
allResults.push(...pageResult.items);
|
|
237
276
|
hasMore = pageResult.hasMore;
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
277
|
+
// Advance the cursor. If the API echoes the cursor we just used, stop
|
|
278
|
+
// instead of looping to the page cap and duplicating items each round.
|
|
279
|
+
if (pageResult.nextCursor !== undefined) {
|
|
280
|
+
if (pageResult.nextCursor === ctx.cursor) {
|
|
281
|
+
this.deps.log(`Pagination cursor did not advance ('${pageResult.nextCursor}'); stopping.`);
|
|
282
|
+
hasMore = false;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
ctx.cursor = pageResult.nextCursor;
|
|
286
|
+
}
|
|
241
287
|
}
|
|
242
288
|
ctx.page++;
|
|
289
|
+
// `until` is checked after the current page has been appended.
|
|
290
|
+
if (hasMore && step.until && evaluate(step.until, this.deps.ctx)) {
|
|
291
|
+
hasMore = false;
|
|
292
|
+
}
|
|
293
|
+
// Memory safety: cap total accumulated items, not just page count. For a
|
|
294
|
+
// backfill this is a clean stop-and-resume boundary, not an end of data.
|
|
295
|
+
if (allResults.length >= itemCap) {
|
|
296
|
+
this.deps.log(`Pagination item limit (${itemCap}) reached`);
|
|
297
|
+
if (hasMore)
|
|
298
|
+
stoppedByCap = true;
|
|
299
|
+
hasMore = false;
|
|
300
|
+
}
|
|
301
|
+
// Record this page's position for a resumable backfill. `done` is the
|
|
302
|
+
// natural end of pagination — never set when we stopped on the item cap,
|
|
303
|
+
// so a resume knows to continue rather than treat the backfill as finished.
|
|
304
|
+
if (step.backfill && backfill?.onPage) {
|
|
305
|
+
await backfill.onPage({
|
|
306
|
+
page: ctx.page,
|
|
307
|
+
cursor: ctx.cursor,
|
|
308
|
+
recordCount: pageResult.items.length,
|
|
309
|
+
done: !hasMore && !stoppedByCap,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
243
312
|
// Emit heartbeat after each page
|
|
244
313
|
this.deps.emit?.('fetch.heartbeat', {
|
|
245
314
|
source: sourceName,
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { RetryConfig } from '../ast/nodes.js';
|
|
2
2
|
import type { RateLimiter } from '../auth/types.js';
|
|
3
3
|
import { CircuitBreaker } from '../auth/circuit-breaker.js';
|
|
4
|
+
/**
|
|
5
|
+
* Parse a `Retry-After` header into a delay in ms, clamped to `maxDelayMs`.
|
|
6
|
+
* The header may be delta-seconds (`120`) or an HTTP-date; a date in the past or
|
|
7
|
+
* an unparseable value yields 0 and `undefined` respectively. Clamping stops a
|
|
8
|
+
* hostile/broken server from pinning the client for hours, and the date branch
|
|
9
|
+
* stops `parseInt` from turning a date into `NaN` → `sleep(NaN)` → a tight loop.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseRetryAfterMs(value: string | null | undefined, maxDelayMs: number): number | undefined;
|
|
4
12
|
export interface HttpClientConfig {
|
|
5
13
|
baseUrl: string;
|
|
6
14
|
headers?: Record<string, string>;
|
|
@@ -9,6 +17,8 @@ export interface HttpClientConfig {
|
|
|
9
17
|
circuitBreaker?: CircuitBreaker;
|
|
10
18
|
/** Source name for rate limit and circuit breaker tracking */
|
|
11
19
|
sourceName?: string;
|
|
20
|
+
/** Default per-request timeout in ms (overridden by RetryConfig.timeout) */
|
|
21
|
+
timeout?: number;
|
|
12
22
|
}
|
|
13
23
|
export interface AuthProvider {
|
|
14
24
|
getToken(): Promise<string>;
|
|
@@ -20,6 +30,12 @@ export interface HttpRequest {
|
|
|
20
30
|
body?: unknown;
|
|
21
31
|
headers?: Record<string, string>;
|
|
22
32
|
query?: Record<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Opt a non-idempotent request (POST/PATCH) into automatic retries by
|
|
35
|
+
* supplying an idempotency key. Sent as the `Idempotency-Key` header so the
|
|
36
|
+
* server can dedup a re-sent write.
|
|
37
|
+
*/
|
|
38
|
+
idempotencyKey?: string;
|
|
23
39
|
}
|
|
24
40
|
export interface HttpResponse<T = unknown> {
|
|
25
41
|
status: number;
|
|
@@ -31,10 +47,20 @@ export declare class HttpClient {
|
|
|
31
47
|
constructor(config: HttpClientConfig);
|
|
32
48
|
request<T = unknown>(req: HttpRequest, retry?: RetryConfig): Promise<HttpResponse<T>>;
|
|
33
49
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
50
|
+
* Run a fetch with a per-attempt timeout. Aborts the request (freeing the
|
|
51
|
+
* connection and rate-limiter slot) if it exceeds `timeoutMs`, surfacing a
|
|
52
|
+
* retryable FetchError rather than hanging forever.
|
|
53
|
+
*/
|
|
54
|
+
private fetchWithTimeout;
|
|
55
|
+
/**
|
|
56
|
+
* Parse a successful (2xx) response body. Handles empty/204 responses,
|
|
57
|
+
* returns non-JSON content as raw text, and caps the buffered size.
|
|
36
58
|
*/
|
|
37
59
|
private parseResponseBody;
|
|
60
|
+
/** Read a response body to text, rejecting once it exceeds MAX_RESPONSE_BYTES. */
|
|
61
|
+
private readCappedText;
|
|
62
|
+
/** Read a short snippet of a body for an error message (best-effort). */
|
|
63
|
+
private safeReadSnippet;
|
|
38
64
|
private buildUrl;
|
|
39
65
|
private buildHeaders;
|
|
40
66
|
private calculateDelay;
|
|
@@ -50,6 +76,8 @@ export declare class OAuth2AuthProvider implements AuthProvider {
|
|
|
50
76
|
private tokenEndpoint?;
|
|
51
77
|
private clientId?;
|
|
52
78
|
private clientSecret?;
|
|
79
|
+
/** Single-flight guard: coalesces concurrent refreshes into one in-flight request */
|
|
80
|
+
private refreshPromise;
|
|
53
81
|
constructor(config: {
|
|
54
82
|
accessToken: string;
|
|
55
83
|
refreshToken?: string;
|
|
@@ -59,4 +87,5 @@ export declare class OAuth2AuthProvider implements AuthProvider {
|
|
|
59
87
|
});
|
|
60
88
|
getToken(): Promise<string>;
|
|
61
89
|
refreshToken(): Promise<string>;
|
|
90
|
+
private doRefresh;
|
|
62
91
|
}
|
package/dist/interpreter/http.js
CHANGED
|
@@ -3,6 +3,34 @@ import { CircuitBreakerError } from '../auth/circuit-breaker.js';
|
|
|
3
3
|
import { sleep } from '../utils/async.js';
|
|
4
4
|
import { HTTP_RETRY_DEFAULTS } from '../config/index.js';
|
|
5
5
|
import { FetchError } from '../errors/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* Parse a `Retry-After` header into a delay in ms, clamped to `maxDelayMs`.
|
|
8
|
+
* The header may be delta-seconds (`120`) or an HTTP-date; a date in the past or
|
|
9
|
+
* an unparseable value yields 0 and `undefined` respectively. Clamping stops a
|
|
10
|
+
* hostile/broken server from pinning the client for hours, and the date branch
|
|
11
|
+
* stops `parseInt` from turning a date into `NaN` → `sleep(NaN)` → a tight loop.
|
|
12
|
+
*/
|
|
13
|
+
export function parseRetryAfterMs(value, maxDelayMs) {
|
|
14
|
+
if (!value)
|
|
15
|
+
return undefined;
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
const seconds = Number(trimmed);
|
|
18
|
+
let ms;
|
|
19
|
+
if (trimmed !== '' && Number.isFinite(seconds)) {
|
|
20
|
+
ms = seconds * 1000;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const when = Date.parse(trimmed);
|
|
24
|
+
if (Number.isNaN(when))
|
|
25
|
+
return undefined;
|
|
26
|
+
ms = when - Date.now();
|
|
27
|
+
}
|
|
28
|
+
return Math.min(Math.max(ms, 0), maxDelayMs);
|
|
29
|
+
}
|
|
30
|
+
/** Maximum buffered response body size (10 MiB) before the request is rejected. */
|
|
31
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
32
|
+
/** HTTP methods that are safe to retry automatically (idempotent per RFC 7231). */
|
|
33
|
+
const IDEMPOTENT_METHODS = new Set(['GET', 'PUT', 'DELETE']);
|
|
6
34
|
export class HttpClient {
|
|
7
35
|
config;
|
|
8
36
|
constructor(config) {
|
|
@@ -10,17 +38,29 @@ export class HttpClient {
|
|
|
10
38
|
}
|
|
11
39
|
async request(req, retry) {
|
|
12
40
|
const url = this.buildUrl(req.path, req.query);
|
|
13
|
-
const
|
|
41
|
+
const requestHeaders = { ...req.headers };
|
|
42
|
+
if (req.idempotencyKey) {
|
|
43
|
+
requestHeaders['Idempotency-Key'] = req.idempotencyKey;
|
|
44
|
+
}
|
|
45
|
+
const headers = await this.buildHeaders(requestHeaders);
|
|
14
46
|
const fetchOptions = {
|
|
15
47
|
method: req.method,
|
|
16
48
|
headers,
|
|
17
49
|
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
18
50
|
};
|
|
51
|
+
// Auto-retry only idempotent verbs, or any verb carrying an idempotency key.
|
|
52
|
+
// A blind retry of POST/PATCH can re-send a write the server already
|
|
53
|
+
// committed (timeout / dropped socket after commit), duplicating data.
|
|
54
|
+
const retriable = IDEMPOTENT_METHODS.has(req.method) || Boolean(req.idempotencyKey);
|
|
19
55
|
const maxAttempts = retry?.maxAttempts ?? HTTP_RETRY_DEFAULTS.MAX_ATTEMPTS;
|
|
20
56
|
const backoff = retry?.backoff ?? HTTP_RETRY_DEFAULTS.BACKOFF;
|
|
21
57
|
const initialDelay = retry?.initialDelay ?? HTTP_RETRY_DEFAULTS.INITIAL_DELAY_MS;
|
|
22
58
|
const maxDelay = retry?.maxDelay ?? HTTP_RETRY_DEFAULTS.MAX_DELAY_MS;
|
|
59
|
+
const timeout = retry?.timeout ?? this.config.timeout ?? HTTP_RETRY_DEFAULTS.TIMEOUT_MS;
|
|
23
60
|
let lastError = null;
|
|
61
|
+
// Refresh the token at most once per request to avoid burning a fresh
|
|
62
|
+
// rotating refresh token on every 401 retry attempt.
|
|
63
|
+
let hasRefreshed = false;
|
|
24
64
|
// Check circuit breaker before attempting requests
|
|
25
65
|
if (this.config.circuitBreaker && this.config.sourceName) {
|
|
26
66
|
// This will throw CircuitBreakerError if circuit is open
|
|
@@ -32,14 +72,16 @@ export class HttpClient {
|
|
|
32
72
|
if (attempt > 1 && this.config.circuitBreaker && this.config.sourceName) {
|
|
33
73
|
if (!this.config.circuitBreaker.canProceed(this.config.sourceName, req.path)) {
|
|
34
74
|
// Circuit opened during retries, fail fast
|
|
35
|
-
throw new CircuitBreakerError(this.config.sourceName, req.path, this.config.circuitBreaker
|
|
75
|
+
throw new CircuitBreakerError(this.config.sourceName, req.path, this.config.circuitBreaker
|
|
76
|
+
.getStatus(this.config.sourceName, req.path)
|
|
77
|
+
.nextAttemptTime?.getTime() ?? 0 - Date.now());
|
|
36
78
|
}
|
|
37
79
|
}
|
|
38
80
|
// Wait for rate limit capacity if we have a rate limiter
|
|
39
81
|
if (this.config.rateLimiter && this.config.sourceName) {
|
|
40
82
|
await this.config.rateLimiter.waitForCapacity(this.config.sourceName, req.path);
|
|
41
83
|
}
|
|
42
|
-
const response = await
|
|
84
|
+
const response = await this.fetchWithTimeout(url, fetchOptions, timeout, req.method);
|
|
43
85
|
// Extract and record rate limit info from response headers
|
|
44
86
|
const responseHeaders = {};
|
|
45
87
|
response.headers.forEach((value, key) => {
|
|
@@ -49,17 +91,19 @@ export class HttpClient {
|
|
|
49
91
|
const rateLimitInfo = parseRateLimitHeaders(responseHeaders);
|
|
50
92
|
// Add retry-after from 429 responses
|
|
51
93
|
if (response.status === 429) {
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
rateLimitInfo.retryAfter =
|
|
94
|
+
const ms = parseRetryAfterMs(response.headers.get('Retry-After'), maxDelay);
|
|
95
|
+
if (ms !== undefined) {
|
|
96
|
+
rateLimitInfo.retryAfter = Math.ceil(ms / 1000);
|
|
55
97
|
}
|
|
56
98
|
}
|
|
57
99
|
this.config.rateLimiter.recordResponse(this.config.sourceName, rateLimitInfo, req.path);
|
|
58
100
|
}
|
|
59
|
-
// Handle rate limiting
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
101
|
+
// Handle rate limiting: retry only while attempts remain. A 429 on the
|
|
102
|
+
// final attempt falls through to the >=400 handler below, which throws a
|
|
103
|
+
// FetchError carrying status 429 — not the generic "all retries" error.
|
|
104
|
+
if (response.status === 429 && retriable && attempt < maxAttempts) {
|
|
105
|
+
const delay = parseRetryAfterMs(response.headers.get('Retry-After'), maxDelay) ??
|
|
106
|
+
this.calculateDelay(attempt, backoff, initialDelay, maxDelay);
|
|
63
107
|
await sleep(delay);
|
|
64
108
|
continue;
|
|
65
109
|
}
|
|
@@ -69,20 +113,34 @@ export class HttpClient {
|
|
|
69
113
|
if (this.config.circuitBreaker && this.config.sourceName) {
|
|
70
114
|
this.config.circuitBreaker.recordFailure(this.config.sourceName, req.path, response.status);
|
|
71
115
|
}
|
|
72
|
-
if (attempt < maxAttempts) {
|
|
116
|
+
if (retriable && attempt < maxAttempts) {
|
|
73
117
|
const delay = this.calculateDelay(attempt, backoff, initialDelay, maxDelay);
|
|
74
118
|
await sleep(delay);
|
|
75
119
|
continue;
|
|
76
120
|
}
|
|
121
|
+
// Non-idempotent without an idempotency key: do not re-send a write
|
|
122
|
+
// that the server may have already committed. Return the 5xx instead.
|
|
77
123
|
}
|
|
78
|
-
// Handle 401 - try token refresh
|
|
79
|
-
if (response.status === 401 &&
|
|
124
|
+
// Handle 401 - try token refresh (at most once per request)
|
|
125
|
+
if (response.status === 401 &&
|
|
126
|
+
this.config.auth?.refreshToken &&
|
|
127
|
+
!hasRefreshed &&
|
|
128
|
+
attempt < maxAttempts) {
|
|
129
|
+
hasRefreshed = true;
|
|
80
130
|
await this.config.auth.refreshToken();
|
|
81
|
-
// Rebuild headers with new token
|
|
82
|
-
const newHeaders = await this.buildHeaders(
|
|
131
|
+
// Rebuild headers with new token (preserving the idempotency key)
|
|
132
|
+
const newHeaders = await this.buildHeaders(requestHeaders);
|
|
83
133
|
fetchOptions.headers = newHeaders;
|
|
84
134
|
continue;
|
|
85
135
|
}
|
|
136
|
+
// Any remaining non-2xx/3xx response is an error: a 4xx (other than the
|
|
137
|
+
// 429/401-refresh cases handled above) or a 5xx that exhausted retries.
|
|
138
|
+
// Returning it as `data` would let map/store persist an API error body.
|
|
139
|
+
if (response.status >= 400) {
|
|
140
|
+
const snippet = await this.safeReadSnippet(response);
|
|
141
|
+
throw new FetchError(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}` +
|
|
142
|
+
(snippet ? `: ${snippet}` : ''), { url, method: req.method, statusCode: response.status });
|
|
143
|
+
}
|
|
86
144
|
const data = await this.parseResponseBody(response, url, req.method);
|
|
87
145
|
// Record success in circuit breaker
|
|
88
146
|
if (this.config.circuitBreaker && this.config.sourceName && response.status < 500) {
|
|
@@ -100,10 +158,21 @@ export class HttpClient {
|
|
|
100
158
|
if (error instanceof CircuitBreakerError) {
|
|
101
159
|
throw error;
|
|
102
160
|
}
|
|
161
|
+
// HTTP-status errors (4xx, exhausted 5xx) and body parse/size errors
|
|
162
|
+
// are definitive — don't burn retries re-fetching them.
|
|
163
|
+
if (error instanceof FetchError && error.statusCode !== undefined) {
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
103
166
|
// Record network errors in circuit breaker
|
|
104
167
|
if (this.config.circuitBreaker && this.config.sourceName) {
|
|
105
168
|
this.config.circuitBreaker.recordFailure(this.config.sourceName, req.path, undefined, true);
|
|
106
169
|
}
|
|
170
|
+
// A network error on a non-idempotent write is ambiguous: the request
|
|
171
|
+
// may have reached the server and committed before the socket dropped.
|
|
172
|
+
// Surface the error rather than blindly re-sending a duplicate write.
|
|
173
|
+
if (!retriable) {
|
|
174
|
+
throw lastError;
|
|
175
|
+
}
|
|
107
176
|
if (attempt < maxAttempts) {
|
|
108
177
|
const delay = this.calculateDelay(attempt, backoff, initialDelay, maxDelay);
|
|
109
178
|
await sleep(delay);
|
|
@@ -113,22 +182,97 @@ export class HttpClient {
|
|
|
113
182
|
throw lastError ?? new Error('Request failed after all retries');
|
|
114
183
|
}
|
|
115
184
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
185
|
+
* Run a fetch with a per-attempt timeout. Aborts the request (freeing the
|
|
186
|
+
* connection and rate-limiter slot) if it exceeds `timeoutMs`, surfacing a
|
|
187
|
+
* retryable FetchError rather than hanging forever.
|
|
188
|
+
*/
|
|
189
|
+
async fetchWithTimeout(url, options, timeoutMs, method) {
|
|
190
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
191
|
+
return fetch(url, options);
|
|
192
|
+
}
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
195
|
+
try {
|
|
196
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error.name === 'AbortError') {
|
|
200
|
+
throw new FetchError(`Request timed out after ${timeoutMs}ms`, {
|
|
201
|
+
url,
|
|
202
|
+
method,
|
|
203
|
+
cause: error,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Parse a successful (2xx) response body. Handles empty/204 responses,
|
|
214
|
+
* returns non-JSON content as raw text, and caps the buffered size.
|
|
118
215
|
*/
|
|
119
216
|
async parseResponseBody(response, url, method) {
|
|
217
|
+
// No-content responses have no body to parse.
|
|
218
|
+
if (response.status === 204 || response.status === 205) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const text = await this.readCappedText(response, url, method);
|
|
222
|
+
if (text.trim() === '') {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
120
225
|
const contentType = response.headers.get('content-type') ?? '';
|
|
121
|
-
|
|
226
|
+
const looksJson = contentType === '' || contentType.includes('json');
|
|
122
227
|
try {
|
|
123
|
-
return
|
|
228
|
+
return JSON.parse(text);
|
|
124
229
|
}
|
|
125
230
|
catch (parseError) {
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
throw new FetchError(`Failed to parse JSON response${
|
|
231
|
+
// A non-JSON content-type (text/html, text/plain, …) is returned as-is
|
|
232
|
+
// rather than throwing — only fail when the body claimed to be JSON.
|
|
233
|
+
if (!looksJson) {
|
|
234
|
+
return text;
|
|
235
|
+
}
|
|
236
|
+
throw new FetchError(`Failed to parse JSON response (content-type '${contentType}'): ${parseError.message}`, { url, method, statusCode: response.status, cause: parseError });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Read a response body to text, rejecting once it exceeds MAX_RESPONSE_BYTES. */
|
|
240
|
+
async readCappedText(response, url, method) {
|
|
241
|
+
const body = response.body;
|
|
242
|
+
if (!body) {
|
|
243
|
+
return await response.text();
|
|
244
|
+
}
|
|
245
|
+
const reader = body.getReader();
|
|
246
|
+
const chunks = [];
|
|
247
|
+
let size = 0;
|
|
248
|
+
for (;;) {
|
|
249
|
+
const { done, value } = await reader.read();
|
|
250
|
+
if (done)
|
|
251
|
+
break;
|
|
252
|
+
if (value) {
|
|
253
|
+
size += value.byteLength;
|
|
254
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
255
|
+
await reader.cancel();
|
|
256
|
+
throw new FetchError(`Response body exceeds ${MAX_RESPONSE_BYTES} bytes`, {
|
|
257
|
+
url,
|
|
258
|
+
method,
|
|
259
|
+
statusCode: response.status,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
chunks.push(Buffer.from(value));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
266
|
+
}
|
|
267
|
+
/** Read a short snippet of a body for an error message (best-effort). */
|
|
268
|
+
async safeReadSnippet(response) {
|
|
269
|
+
try {
|
|
270
|
+
const text = await response.text();
|
|
271
|
+
const trimmed = text.trim();
|
|
272
|
+
return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return '';
|
|
132
276
|
}
|
|
133
277
|
}
|
|
134
278
|
buildUrl(path, query) {
|
|
@@ -190,6 +334,8 @@ export class OAuth2AuthProvider {
|
|
|
190
334
|
tokenEndpoint;
|
|
191
335
|
clientId;
|
|
192
336
|
clientSecret;
|
|
337
|
+
/** Single-flight guard: coalesces concurrent refreshes into one in-flight request */
|
|
338
|
+
refreshPromise = null;
|
|
193
339
|
constructor(config) {
|
|
194
340
|
this.accessToken = config.accessToken;
|
|
195
341
|
this.refreshTokenValue = config.refreshToken;
|
|
@@ -201,6 +347,21 @@ export class OAuth2AuthProvider {
|
|
|
201
347
|
return this.accessToken;
|
|
202
348
|
}
|
|
203
349
|
async refreshToken() {
|
|
350
|
+
// Deduplicate concurrent refresh requests. With rotating refresh tokens,
|
|
351
|
+
// letting many in-flight requests each POST to the token endpoint would
|
|
352
|
+
// 400 invalid_grant all but one and kill the session.
|
|
353
|
+
if (this.refreshPromise) {
|
|
354
|
+
return this.refreshPromise;
|
|
355
|
+
}
|
|
356
|
+
this.refreshPromise = this.doRefresh();
|
|
357
|
+
try {
|
|
358
|
+
return await this.refreshPromise;
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
this.refreshPromise = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async doRefresh() {
|
|
204
365
|
if (!this.refreshTokenValue || !this.tokenEndpoint) {
|
|
205
366
|
throw new Error('Cannot refresh token: missing refresh token or endpoint');
|
|
206
367
|
}
|
|
@@ -214,7 +375,7 @@ export class OAuth2AuthProvider {
|
|
|
214
375
|
client_secret: this.clientSecret ?? '',
|
|
215
376
|
}),
|
|
216
377
|
});
|
|
217
|
-
const data = await response.json();
|
|
378
|
+
const data = (await response.json());
|
|
218
379
|
this.accessToken = data.access_token;
|
|
219
380
|
if (data.refresh_token) {
|
|
220
381
|
this.refreshTokenValue = data.refresh_token;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export { MissionExecutor, type ExecutionResult, type ExecutionError, type ExecutorConfig, type ProgressCallbacks, type ExecutionStartEvent, type ExecutionCompleteEvent, type StageStartEvent, type StageCompleteEvent, type AuthConfig, } from './executor.js';
|
|
2
|
-
export { HttpClient, BearerAuthProvider, OAuth2AuthProvider, type HttpClientConfig, type AuthProvider } from './http.js';
|
|
3
|
-
export { SourceManager, type SourceManagerConfig, type SourceManagerDeps } from './source-manager.js';
|
|
2
|
+
export { HttpClient, BearerAuthProvider, OAuth2AuthProvider, type HttpClientConfig, type AuthProvider, } from './http.js';
|
|
3
|
+
export { SourceManager, type SourceManagerConfig, type SourceManagerDeps, } from './source-manager.js';
|
|
4
4
|
export { StoreManager, type StoreManagerConfig } from './store-manager.js';
|
|
5
|
-
export { createContext, childContext, getVariable, setVariable, type ExecutionContext } from './context.js';
|
|
5
|
+
export { createContext, childContext, getVariable, setVariable, type ExecutionContext, } from './context.js';
|
|
6
6
|
export { evaluate, evaluateToString, interpolatePath } from './evaluator.js';
|
|
7
7
|
export { FetchHandler, type FetchHandlerDeps, type FetchResult } from './fetch-handler.js';
|
|
8
8
|
export { type PaginationStrategy, type PaginationContext, type PageResult, createPaginationStrategy, OffsetPaginationStrategy, PageNumberPaginationStrategy, CursorPaginationStrategy, } from './pagination.js';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export { MissionExecutor, } from './executor.js';
|
|
2
|
-
export { HttpClient, BearerAuthProvider, OAuth2AuthProvider } from './http.js';
|
|
3
|
-
export { SourceManager } from './source-manager.js';
|
|
2
|
+
export { HttpClient, BearerAuthProvider, OAuth2AuthProvider, } from './http.js';
|
|
3
|
+
export { SourceManager, } from './source-manager.js';
|
|
4
4
|
export { StoreManager } from './store-manager.js';
|
|
5
|
-
export { createContext, childContext, getVariable, setVariable } from './context.js';
|
|
5
|
+
export { createContext, childContext, getVariable, setVariable, } from './context.js';
|
|
6
6
|
export { evaluate, evaluateToString, interpolatePath } from './evaluator.js';
|
|
7
7
|
export { FetchHandler } from './fetch-handler.js';
|
|
8
8
|
export { createPaginationStrategy, OffsetPaginationStrategy, PageNumberPaginationStrategy, CursorPaginationStrategy, } from './pagination.js';
|
|
@@ -63,7 +63,7 @@ export declare class CursorPaginationStrategy implements PaginationStrategy {
|
|
|
63
63
|
private cachedArrayField;
|
|
64
64
|
constructor(config: PaginationConfig);
|
|
65
65
|
buildQuery(ctx: PaginationContext): Record<string, string>;
|
|
66
|
-
extractResults(response: unknown,
|
|
66
|
+
extractResults(response: unknown, _ctx: PaginationContext): PageResult;
|
|
67
67
|
clearCache(): void;
|
|
68
68
|
}
|
|
69
69
|
/**
|