deepline 0.3.231 → 0.3.232

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.
@@ -0,0 +1,419 @@
1
+ import type { SafeFetchImplementation } from '../security/safe-fetch';
2
+ import type { RuntimeResourceGovernor } from './resource-governor';
3
+ import { CTX_FETCH_EGRESS_TOOL_ID } from './builtin-pacing';
4
+ import {
5
+ positiveTimeoutMs,
6
+ RUNTIME_RELIABILITY_POLICY,
7
+ } from './runtime-reliability-policy';
8
+ import {
9
+ TOOL_EXECUTE_BARE_RATE_LIMIT_MAX_ATTEMPTS,
10
+ TOOL_EXECUTE_RATE_LIMIT_MAX_ATTEMPTS,
11
+ TOOL_EXECUTE_RETRY_DELAY_FALLBACK_MS,
12
+ TOOL_EXECUTE_RATE_LIMIT_RETRY_DELAY_MAX_MS,
13
+ parseToolExecuteRetryAfterMs,
14
+ } from './tool-execute-retry-policy';
15
+
16
+ const FETCH_TRANSPORT_MAX_ATTEMPTS =
17
+ RUNTIME_RELIABILITY_POLICY.egress.fetchMaxAttempts;
18
+ const FETCH_TRANSPORT_RETRY_DELAY_MS = 100;
19
+ const CTX_FETCH_HEADERS_TIMEOUT_MS =
20
+ RUNTIME_RELIABILITY_POLICY.egress.fetchHeadersTimeoutMs;
21
+ const CTX_FETCH_BODY_TIMEOUT_MS =
22
+ RUNTIME_RELIABILITY_POLICY.egress.fetchBodyTimeoutMs;
23
+ const CTX_FETCH_TOTAL_TIMEOUT_MS =
24
+ RUNTIME_RELIABILITY_POLICY.egress.fetchTotalTimeoutMs;
25
+
26
+ type SafeFetchModule = typeof import('../security/safe-fetch');
27
+ let safeFetchModule: Promise<SafeFetchModule> | null = null;
28
+ function loadSafeFetch(): Promise<SafeFetchModule> {
29
+ safeFetchModule ??= import('../security/safe-fetch');
30
+ return safeFetchModule;
31
+ }
32
+
33
+ function isUnsafeOutboundUrlError(error: unknown): boolean {
34
+ return error instanceof Error && error.name === 'UnsafeOutboundUrlError';
35
+ }
36
+
37
+ export class CtxFetchTimeoutError extends Error {
38
+ readonly code = 'CTX_FETCH_TIMEOUT';
39
+
40
+ constructor(
41
+ readonly phase: 'headers' | 'body' | 'total',
42
+ readonly timeoutMs: number,
43
+ ) {
44
+ super(
45
+ `[CTX_FETCH_TIMEOUT phase=${phase} timeout_ms=${timeoutMs}] ` +
46
+ `ctx.fetch exceeded its ${phase} deadline after ${timeoutMs}ms.`,
47
+ );
48
+ this.name = 'CtxFetchTimeoutError';
49
+ }
50
+ }
51
+
52
+ type CtxFetchDeadline = {
53
+ bodyMs: number;
54
+ headersMs: number;
55
+ startedAt: number;
56
+ totalMs: number;
57
+ };
58
+
59
+ function remainingCtxFetchMs(deadline: CtxFetchDeadline): number {
60
+ return Math.max(0, deadline.totalMs - (Date.now() - deadline.startedAt));
61
+ }
62
+
63
+ async function runCtxFetchPhase<T>(input: {
64
+ callerSignal?: AbortSignal | null;
65
+ controller: AbortController;
66
+ deadline: CtxFetchDeadline;
67
+ phase: 'headers' | 'body' | 'total';
68
+ run: () => Promise<T>;
69
+ }): Promise<T> {
70
+ const remainingMs = remainingCtxFetchMs(input.deadline);
71
+ if (remainingMs <= 0) {
72
+ throw new CtxFetchTimeoutError('total', input.deadline.totalMs);
73
+ }
74
+ if (input.callerSignal?.aborted) {
75
+ throw (
76
+ input.callerSignal.reason ?? new DOMException('Aborted', 'AbortError')
77
+ );
78
+ }
79
+ const phaseLimitMs =
80
+ input.phase === 'headers'
81
+ ? input.deadline.headersMs
82
+ : input.phase === 'body'
83
+ ? input.deadline.bodyMs
84
+ : remainingMs;
85
+ const timeoutMs = Math.min(phaseLimitMs, remainingMs);
86
+ const timeoutPhase = timeoutMs >= remainingMs ? 'total' : input.phase;
87
+ let timeout: ReturnType<typeof setTimeout> | null = null;
88
+ let onCallerAbort: (() => void) | null = null;
89
+ const phaseTimeout = new Promise<never>((_resolve, reject) => {
90
+ timeout = setTimeout(() => {
91
+ const error = new CtxFetchTimeoutError(
92
+ timeoutPhase,
93
+ timeoutPhase === 'total' ? input.deadline.totalMs : phaseLimitMs,
94
+ );
95
+ reject(error);
96
+ input.controller.abort(error);
97
+ }, timeoutMs);
98
+ });
99
+ const callerAbort = input.callerSignal
100
+ ? new Promise<never>((_resolve, reject) => {
101
+ onCallerAbort = () => {
102
+ const reason =
103
+ input.callerSignal?.reason ??
104
+ new DOMException('Aborted', 'AbortError');
105
+ reject(reason);
106
+ input.controller.abort(reason);
107
+ };
108
+ input.callerSignal!.addEventListener('abort', onCallerAbort, {
109
+ once: true,
110
+ });
111
+ })
112
+ : null;
113
+ try {
114
+ return await Promise.race([
115
+ input.run(),
116
+ phaseTimeout,
117
+ ...(callerAbort ? [callerAbort] : []),
118
+ ]);
119
+ } finally {
120
+ if (timeout) clearTimeout(timeout);
121
+ if (onCallerAbort) {
122
+ input.callerSignal?.removeEventListener('abort', onCallerAbort);
123
+ }
124
+ }
125
+ }
126
+
127
+ async function sleepWithinCtxFetchDeadline(input: {
128
+ callerSignal?: AbortSignal | null;
129
+ deadline: CtxFetchDeadline;
130
+ delayMs: number;
131
+ }): Promise<void> {
132
+ const controller = new AbortController();
133
+ let timer: ReturnType<typeof setTimeout> | undefined;
134
+ try {
135
+ await runCtxFetchPhase({
136
+ callerSignal: input.callerSignal,
137
+ controller,
138
+ deadline: input.deadline,
139
+ phase: 'total',
140
+ run: () =>
141
+ new Promise<void>((resolve) => {
142
+ timer = setTimeout(resolve, input.delayMs);
143
+ }),
144
+ });
145
+ } finally {
146
+ if (timer !== undefined) clearTimeout(timer);
147
+ }
148
+ }
149
+
150
+ function isCtxFetchBackpressure(response: Response, bodyText: string): boolean {
151
+ if (response.status === 429) return true;
152
+ if (response.status !== 503) return false;
153
+ // This Salesforce endpoint reports a rejected burst as 503. Match the final
154
+ // destination and exact response contract; arbitrary 503s remain failures.
155
+ try {
156
+ const destination = new URL(response.url);
157
+ const body: unknown = JSON.parse(bodyText);
158
+ return (
159
+ destination.protocol === 'https:' &&
160
+ destination.hostname.endsWith('.salesforce-sites.com') &&
161
+ body !== null &&
162
+ typeof body === 'object' &&
163
+ !Array.isArray(body) &&
164
+ (body as Record<string, unknown>).status === 'burst_circuit_open'
165
+ );
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ async function readCtxFetchBody(input: {
172
+ callerSignal?: AbortSignal | null;
173
+ controller: AbortController;
174
+ deadline: CtxFetchDeadline;
175
+ response: Response;
176
+ }): Promise<string> {
177
+ if (!input.response.body) return '';
178
+ const reader = input.response.body.getReader();
179
+ const decoder = new TextDecoder();
180
+ try {
181
+ return await runCtxFetchPhase({
182
+ callerSignal: input.callerSignal,
183
+ controller: input.controller,
184
+ deadline: input.deadline,
185
+ phase: 'body',
186
+ run: async () => {
187
+ let body = '';
188
+ for (;;) {
189
+ const chunk = await reader.read();
190
+ if (chunk.done) return body + decoder.decode();
191
+ body += decoder.decode(chunk.value, { stream: true });
192
+ }
193
+ },
194
+ });
195
+ } catch (error) {
196
+ // Cancellation is socket hygiene, not part of the timeout contract. A
197
+ // hostile/custom stream may never resolve its cancel hook; awaiting it
198
+ // here would recreate the exact forever-pending ctx.fetch this deadline
199
+ // is meant to eliminate.
200
+ void reader.cancel(error).catch(() => {});
201
+ throw error;
202
+ } finally {
203
+ reader.releaseLock();
204
+ }
205
+ }
206
+
207
+ /** Sends one logical Play HTTP request within its permit and I/O budgets.
208
+ * Durable receipt policy and secret redaction belong to the caller.
209
+ */
210
+ export async function sendPlayHttpRequest(input: {
211
+ key: string;
212
+ url: string;
213
+ init: RequestInit;
214
+ governor: Pick<RuntimeResourceGovernor, 'acquireProviderPermit'>;
215
+ sensitiveHeaders: string[];
216
+ timeouts?: { headersMs?: number; bodyMs?: number; totalMs?: number };
217
+ fetchImpl?: SafeFetchImplementation;
218
+ log: (message: string) => void;
219
+ }): Promise<{ response: Response; bodyText: string }> {
220
+ const { url, init } = input;
221
+ const method = String(init.method ?? 'GET').toUpperCase();
222
+ const fetchDeadline: CtxFetchDeadline = {
223
+ startedAt: Date.now(),
224
+ headersMs: positiveTimeoutMs(
225
+ input.timeouts?.headersMs,
226
+ CTX_FETCH_HEADERS_TIMEOUT_MS,
227
+ ),
228
+ bodyMs: positiveTimeoutMs(
229
+ input.timeouts?.bodyMs,
230
+ CTX_FETCH_BODY_TIMEOUT_MS,
231
+ ),
232
+ totalMs: positiveTimeoutMs(
233
+ input.timeouts?.totalMs,
234
+ CTX_FETCH_TOTAL_TIMEOUT_MS,
235
+ ),
236
+ };
237
+ let response: Response | null = null;
238
+ let bodyText: string | null = null;
239
+ // Only replay a body whose bytes are stable. In particular, a
240
+ // consumed ReadableStream cannot be retried even with a write key.
241
+ const replayableBody = init.body == null || typeof init.body === 'string';
242
+ const canRetryTransport =
243
+ replayableBody && ['GET', 'HEAD', 'OPTIONS'].includes(method);
244
+ const { safePublicFetch } = await loadSafeFetch();
245
+ let transportAttempt = 1;
246
+ let rateLimitAttempt = 0;
247
+ while (true) {
248
+ const attemptController = new AbortController();
249
+ let permit: Awaited<
250
+ ReturnType<RuntimeResourceGovernor['acquireProviderPermit']>
251
+ > | null = null;
252
+ let dispatched = false;
253
+ const releasePhysicalPermit = async () => {
254
+ const current = permit;
255
+ permit = null;
256
+ if (!current) return;
257
+ if (dispatched) current.release();
258
+ else await current.cancel();
259
+ dispatched = false;
260
+ };
261
+ let transportRetry = false;
262
+ try {
263
+ const attemptInit = {
264
+ ...init,
265
+ signal: init.signal
266
+ ? AbortSignal.any([init.signal, attemptController.signal])
267
+ : attemptController.signal,
268
+ };
269
+ response = await runCtxFetchPhase({
270
+ callerSignal: init.signal,
271
+ controller: attemptController,
272
+ deadline: fetchDeadline,
273
+ phase: 'total',
274
+ run: () =>
275
+ safePublicFetch(url, attemptInit, {
276
+ // safePublicFetch validates each URL before invoking this
277
+ // wrapper. Redirect hops are physical dispatches too.
278
+ fetchImpl: async (physicalUrl, physicalInit) => {
279
+ await releasePhysicalPermit();
280
+ const signal = attemptInit.signal;
281
+ permit = await runCtxFetchPhase({
282
+ callerSignal: init.signal,
283
+ controller: attemptController,
284
+ deadline: fetchDeadline,
285
+ phase: 'total',
286
+ run: async () => {
287
+ for (;;) {
288
+ const acquired = await input.governor.acquireProviderPermit(
289
+ { toolId: CTX_FETCH_EGRESS_TOOL_ID, signal },
290
+ );
291
+ try {
292
+ signal.throwIfAborted();
293
+ const valid =
294
+ !acquired.validate || (await acquired.validate());
295
+ signal.throwIfAborted();
296
+ if (valid) return acquired;
297
+ } catch (error) {
298
+ await acquired.cancel();
299
+ throw error;
300
+ }
301
+ await acquired.cancel();
302
+ }
303
+ },
304
+ });
305
+ return await runCtxFetchPhase({
306
+ callerSignal: init.signal,
307
+ controller: attemptController,
308
+ deadline: fetchDeadline,
309
+ phase: 'headers',
310
+ run: () => {
311
+ dispatched = true;
312
+ return (input.fetchImpl ?? globalThis.fetch)(
313
+ physicalUrl,
314
+ physicalInit,
315
+ );
316
+ },
317
+ });
318
+ },
319
+ sensitiveHeaders: input.sensitiveHeaders,
320
+ stripHeadersOnCrossOriginRedirect: true,
321
+ headersOnCrossOriginRedirect: {
322
+ 'user-agent': 'Deepline Play Runtime',
323
+ },
324
+ }),
325
+ });
326
+ bodyText = await readCtxFetchBody({
327
+ callerSignal: init.signal,
328
+ controller: attemptController,
329
+ deadline: fetchDeadline,
330
+ response,
331
+ });
332
+ } catch (error) {
333
+ void response?.body?.cancel(error).catch(() => undefined);
334
+ response = null;
335
+ bodyText = null;
336
+ if (error instanceof CtxFetchTimeoutError) throw error;
337
+ if (init.signal?.aborted) {
338
+ throw init.signal.reason ?? error;
339
+ }
340
+ if (isUnsafeOutboundUrlError(error)) {
341
+ throw error;
342
+ }
343
+ const message = error instanceof Error ? error.message : String(error);
344
+ if (
345
+ canRetryTransport &&
346
+ transportAttempt < FETCH_TRANSPORT_MAX_ATTEMPTS
347
+ ) {
348
+ input.log(
349
+ `ctx.fetch(${method} ${url}) transport failed on attempt ${transportAttempt}/${FETCH_TRANSPORT_MAX_ATTEMPTS}; retrying: ${message}`,
350
+ );
351
+ transportRetry = true;
352
+ } else {
353
+ throw new Error(
354
+ `ctx.fetch(${method} ${url}) failed on attempt ${transportAttempt}/${FETCH_TRANSPORT_MAX_ATTEMPTS}: ${message}`,
355
+ );
356
+ }
357
+ } finally {
358
+ await releasePhysicalPermit();
359
+ }
360
+ if (transportRetry) {
361
+ await sleepWithinCtxFetchDeadline({
362
+ callerSignal: init.signal,
363
+ deadline: fetchDeadline,
364
+ delayMs: FETCH_TRANSPORT_RETRY_DELAY_MS * transportAttempt,
365
+ });
366
+ transportAttempt += 1;
367
+ continue;
368
+ }
369
+ if (
370
+ !response ||
371
+ bodyText === null ||
372
+ !replayableBody ||
373
+ !isCtxFetchBackpressure(response, bodyText)
374
+ )
375
+ break;
376
+ rateLimitAttempt += 1;
377
+ const retryAfter = response.headers.get('retry-after');
378
+ const attemptCap = retryAfter?.trim()
379
+ ? TOOL_EXECUTE_RATE_LIMIT_MAX_ATTEMPTS
380
+ : TOOL_EXECUTE_BARE_RATE_LIMIT_MAX_ATTEMPTS;
381
+ if (rateLimitAttempt >= attemptCap) break;
382
+ const delayMs = Math.max(
383
+ parseToolExecuteRetryAfterMs(retryAfter),
384
+ Math.min(
385
+ TOOL_EXECUTE_RATE_LIMIT_RETRY_DELAY_MAX_MS,
386
+ TOOL_EXECUTE_RETRY_DELAY_FALLBACK_MS * rateLimitAttempt,
387
+ ),
388
+ );
389
+ input.log(
390
+ `[runtime.ctx_fetch_backpressure] ${JSON.stringify({
391
+ key: input.key,
392
+ method,
393
+ origin: new URL(response.url || url).origin,
394
+ http_status: response.status,
395
+ attempt: rateLimitAttempt,
396
+ max_attempts: attemptCap,
397
+ retry_delay_ms: delayMs,
398
+ })}`,
399
+ );
400
+ // Destination feedback must not cool the shared generic_http
401
+ // bucket used by unrelated websites. Pace this logical request.
402
+ await sleepWithinCtxFetchDeadline({
403
+ callerSignal: init.signal,
404
+ deadline: fetchDeadline,
405
+ delayMs,
406
+ });
407
+ }
408
+ if (!response) {
409
+ throw new Error(
410
+ `ctx.fetch(${method} ${url}) failed before receiving a response.`,
411
+ );
412
+ }
413
+ if (bodyText === null) {
414
+ throw new Error(
415
+ `ctx.fetch(${method} ${url}) failed while reading the response body.`,
416
+ );
417
+ }
418
+ return { response, bodyText };
419
+ }
@@ -389,3 +389,13 @@ export function resolveRuntimeReliabilityPolicy(
389
389
  }
390
390
 
391
391
  export const RUNTIME_RELIABILITY_POLICY = resolveRuntimeReliabilityPolicy();
392
+
393
+ /** Resolve optional runtime intervals and I/O deadlines with the same fallback policy. */
394
+ export function positiveTimeoutMs(
395
+ value: number | undefined,
396
+ fallback: number,
397
+ ): number {
398
+ return value === undefined || !Number.isFinite(value) || value <= 0
399
+ ? fallback
400
+ : Math.max(1, Math.floor(value));
401
+ }