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.
Files changed (122) hide show
  1. package/README.md +23 -3
  2. package/dist/ast/nodes.d.ts +8 -0
  3. package/dist/auth/circuit-breaker.d.ts +11 -0
  4. package/dist/auth/circuit-breaker.js +83 -12
  5. package/dist/auth/credentials.d.ts +6 -1
  6. package/dist/auth/credentials.js +12 -4
  7. package/dist/auth/oauth2-provider.js +13 -3
  8. package/dist/auth/rate-limiter.d.ts +8 -1
  9. package/dist/auth/rate-limiter.js +30 -10
  10. package/dist/auth/token-store.js +8 -1
  11. package/dist/cli.d.ts +11 -1
  12. package/dist/cli.js +65 -6
  13. package/dist/config/constants.d.ts +15 -4
  14. package/dist/config/constants.js +15 -4
  15. package/dist/control/server.d.ts +17 -0
  16. package/dist/control/server.js +82 -5
  17. package/dist/control/types.d.ts +6 -0
  18. package/dist/debug/cli-debugger.js +8 -3
  19. package/dist/execution/store.js +2 -2
  20. package/dist/execution-log/events.d.ts +125 -0
  21. package/dist/execution-log/events.js +17 -0
  22. package/dist/execution-log/fold.d.ts +38 -0
  23. package/dist/execution-log/fold.js +54 -0
  24. package/dist/execution-log/index.d.ts +18 -0
  25. package/dist/execution-log/index.js +6 -0
  26. package/dist/execution-log/postgres-store.d.ts +36 -0
  27. package/dist/execution-log/postgres-store.js +108 -0
  28. package/dist/execution-log/resume.d.ts +11 -0
  29. package/dist/execution-log/resume.js +5 -0
  30. package/dist/execution-log/sqlite-store.d.ts +16 -0
  31. package/dist/execution-log/sqlite-store.js +101 -0
  32. package/dist/execution-log/store.d.ts +72 -0
  33. package/dist/execution-log/store.js +182 -0
  34. package/dist/index.d.ts +4 -3
  35. package/dist/index.js +4 -3
  36. package/dist/interpreter/context.d.ts +15 -0
  37. package/dist/interpreter/context.js +3 -0
  38. package/dist/interpreter/evaluator.js +38 -8
  39. package/dist/interpreter/executor.d.ts +63 -1
  40. package/dist/interpreter/executor.js +406 -30
  41. package/dist/interpreter/fetch-handler.d.ts +39 -1
  42. package/dist/interpreter/fetch-handler.js +84 -15
  43. package/dist/interpreter/http.d.ts +31 -2
  44. package/dist/interpreter/http.js +187 -26
  45. package/dist/interpreter/index.d.ts +3 -3
  46. package/dist/interpreter/index.js +3 -3
  47. package/dist/interpreter/pagination.d.ts +1 -1
  48. package/dist/interpreter/pagination.js +7 -1
  49. package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
  50. package/dist/interpreter/step-handlers/for-handler.js +18 -3
  51. package/dist/interpreter/step-handlers/match-handler.js +5 -2
  52. package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
  53. package/dist/interpreter/step-handlers/store-handler.js +25 -16
  54. package/dist/interpreter/step-handlers/validate-handler.js +4 -1
  55. package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
  56. package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
  57. package/dist/interpreter/store-manager.d.ts +1 -1
  58. package/dist/interpreter/store-manager.js +5 -1
  59. package/dist/loader/index.js +5 -8
  60. package/dist/mcp/sandbox.d.ts +41 -0
  61. package/dist/mcp/sandbox.js +76 -0
  62. package/dist/mcp/server.js +62 -9
  63. package/dist/oas/loader.d.ts +13 -1
  64. package/dist/oas/loader.js +25 -3
  65. package/dist/oas/mock-generator.js +13 -4
  66. package/dist/oas/validator.js +45 -5
  67. package/dist/observability/events.d.ts +6 -2
  68. package/dist/observability/events.js +0 -5
  69. package/dist/observability/logger.js +17 -10
  70. package/dist/observability/otel.d.ts +8 -0
  71. package/dist/observability/otel.js +45 -10
  72. package/dist/parser/action-parser.js +2 -2
  73. package/dist/parser/base.d.ts +7 -0
  74. package/dist/parser/base.js +11 -0
  75. package/dist/parser/expressions.d.ts +1 -0
  76. package/dist/parser/expressions.js +17 -4
  77. package/dist/parser/fetch-parser.js +13 -2
  78. package/dist/pause/index.d.ts +1 -0
  79. package/dist/pause/index.js +1 -0
  80. package/dist/pause/log-store.d.ts +33 -0
  81. package/dist/pause/log-store.js +98 -0
  82. package/dist/pause/manager.d.ts +12 -0
  83. package/dist/pause/manager.js +77 -28
  84. package/dist/pause/store.js +5 -3
  85. package/dist/scheduler/cron-parser.d.ts +10 -3
  86. package/dist/scheduler/cron-parser.js +227 -48
  87. package/dist/scheduler/scheduler.js +56 -22
  88. package/dist/stores/factory.d.ts +6 -0
  89. package/dist/stores/factory.js +11 -1
  90. package/dist/stores/file.js +9 -17
  91. package/dist/stores/memory.js +3 -12
  92. package/dist/stores/postgrest.d.ts +28 -0
  93. package/dist/stores/postgrest.js +84 -37
  94. package/dist/sync/index.d.ts +3 -2
  95. package/dist/sync/index.js +2 -1
  96. package/dist/sync/log-store.d.ts +30 -0
  97. package/dist/sync/log-store.js +45 -0
  98. package/dist/sync/store.js +1 -1
  99. package/dist/trace/index.d.ts +2 -0
  100. package/dist/trace/index.js +1 -0
  101. package/dist/trace/log-view.d.ts +57 -0
  102. package/dist/trace/log-view.js +76 -0
  103. package/dist/trace/recorder.d.ts +5 -1
  104. package/dist/trace/recorder.js +19 -6
  105. package/dist/trace/store.d.ts +6 -0
  106. package/dist/trace/store.js +47 -22
  107. package/dist/utils/deep-merge.d.ts +10 -0
  108. package/dist/utils/deep-merge.js +23 -0
  109. package/dist/utils/file.d.ts +13 -4
  110. package/dist/utils/file.js +70 -12
  111. package/dist/utils/index.d.ts +1 -1
  112. package/dist/utils/index.js +1 -1
  113. package/dist/utils/long-timeout.d.ts +19 -0
  114. package/dist/utils/long-timeout.js +33 -0
  115. package/dist/utils/path.d.ts +22 -1
  116. package/dist/utils/path.js +46 -1
  117. package/dist/utils/redact.d.ts +22 -0
  118. package/dist/utils/redact.js +42 -0
  119. package/dist/webhook/server.d.ts +9 -0
  120. package/dist/webhook/server.js +115 -30
  121. package/dist/webhook/types.d.ts +9 -1
  122. 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<void>;
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: step.body ? evaluate(step.body, this.deps.ctx) : undefined,
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
- let syncedAt = new Date();
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
- // Check until condition
228
- if (step.until) {
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
- // Update cursor for next iteration
239
- if (pageResult.nextCursor) {
240
- ctx.cursor = pageResult.nextCursor;
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
- * Safely parse response body, handling non-JSON responses gracefully.
35
- * Attempts JSON parsing first, providing helpful errors on failure.
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
  }
@@ -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 headers = await this.buildHeaders(req.headers);
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.getStatus(this.config.sourceName, req.path).nextAttemptTime?.getTime() ?? 0 - Date.now());
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 fetch(url, fetchOptions);
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 retryAfter = response.headers.get('Retry-After');
53
- if (retryAfter) {
54
- rateLimitInfo.retryAfter = parseInt(retryAfter, 10);
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
- if (response.status === 429) {
61
- const retryAfter = response.headers.get('Retry-After');
62
- const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : this.calculateDelay(attempt, backoff, initialDelay, maxDelay);
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 && this.config.auth?.refreshToken && attempt < maxAttempts) {
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(req.headers);
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
- * Safely parse response body, handling non-JSON responses gracefully.
117
- * Attempts JSON parsing first, providing helpful errors on failure.
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
- // Always attempt JSON parsing - many APIs don't set content-type correctly
226
+ const looksJson = contentType === '' || contentType.includes('json');
122
227
  try {
123
- return await response.json();
228
+ return JSON.parse(text);
124
229
  }
125
230
  catch (parseError) {
126
- // Provide context about content-type mismatch if applicable
127
- const isJsonContentType = contentType.includes('application/json') || contentType === '';
128
- const contentTypeHint = !isJsonContentType
129
- ? ` (content-type was '${contentType}')`
130
- : '';
131
- throw new FetchError(`Failed to parse JSON response${contentTypeHint}: ${parseError.message}`, { url, method, statusCode: response.status, cause: parseError });
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, ctx: PaginationContext): PageResult;
66
+ extractResults(response: unknown, _ctx: PaginationContext): PageResult;
67
67
  clearCache(): void;
68
68
  }
69
69
  /**