@apifuse/provider-sdk 2.2.0-beta.2 → 2.2.0-beta.21
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/AUTHORING.md +487 -0
- package/CHANGELOG.md +90 -0
- package/README.md +49 -3
- package/SUBMISSION.md +1 -1
- package/bin/apifuse-check.ts +44 -59
- package/bin/apifuse-create.ts +1 -1
- package/bin/apifuse-dev.ts +27 -52
- package/bin/apifuse-pack-check.ts +14 -0
- package/bin/apifuse-pack-smoke.ts +36 -81
- package/bin/apifuse-pack-types.ts +305 -0
- package/bin/apifuse-perf.ts +45 -127
- package/bin/apifuse-record.ts +659 -111
- package/bin/apifuse-submit-check.ts +546 -48
- package/bin/apifuse-sync-assets.ts +117 -0
- package/bin/apifuse.ts +1 -1
- package/bin/submit-check-delimited-text.ts +50 -0
- package/bin/submit-check-xml-semantics.ts +204 -0
- package/bin/submit-check-xml.ts +134 -0
- package/dist/auth-turn/index.d.ts +3 -3
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +9 -18
- package/dist/ceremonies/index.d.ts +9 -1
- package/dist/ceremonies/index.js +65 -18
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +8 -0
- package/dist/cli/create.d.ts +3 -0
- package/dist/cli/create.js +34 -35
- package/dist/cli/prompt-assets.d.ts +80 -0
- package/dist/cli/prompt-assets.js +743 -0
- package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
- package/dist/config/loader.d.ts +176 -8
- package/dist/config/loader.js +424 -95
- package/dist/contract-serialization.d.ts +2 -2
- package/dist/contract-serialization.js +3 -6
- package/dist/contract-types.d.ts +2 -2
- package/dist/contract.d.ts +3 -3
- package/dist/contract.js +4 -6
- package/dist/define.d.ts +9 -1
- package/dist/define.js +304 -119
- package/dist/dev.d.ts +1 -1
- package/dist/dev.js +1 -1
- package/dist/error-resolution.d.ts +2 -0
- package/dist/error-resolution.js +90 -0
- package/dist/errors.d.ts +22 -1
- package/dist/errors.js +88 -0
- package/dist/fixture-sanitization.d.ts +26 -0
- package/dist/fixture-sanitization.js +216 -0
- package/dist/i18n/catalog.d.ts +2 -2
- package/dist/i18n/catalog.js +4 -10
- package/dist/i18n/index.d.ts +2 -2
- package/dist/i18n/index.js +2 -2
- package/dist/i18n/keys.d.ts +2 -2
- package/dist/index.d.ts +46 -42
- package/dist/index.js +41 -37
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +8 -15
- package/dist/native-address.d.ts +43 -0
- package/dist/native-address.js +281 -0
- package/dist/native-egress-policy.d.ts +31 -0
- package/dist/native-egress-policy.js +288 -0
- package/dist/observability.d.ts +5 -2
- package/dist/observability.js +48 -1
- package/dist/provider.d.ts +12 -11
- package/dist/provider.js +10 -9
- package/dist/public-schema-field-lint.d.ts +1 -1
- package/dist/recipes/gov-api.js +1 -1
- package/dist/runtime/auth-flow.d.ts +2 -1
- package/dist/runtime/auth-flow.js +4 -3
- package/dist/runtime/browser.d.ts +1 -1
- package/dist/runtime/browser.js +15 -29
- package/dist/runtime/cache.d.ts +1 -1
- package/dist/runtime/cache.js +4 -8
- package/dist/runtime/choice.d.ts +1 -1
- package/dist/runtime/choice.js +31 -35
- package/dist/runtime/credential.d.ts +1 -1
- package/dist/runtime/credential.js +1 -1
- package/dist/runtime/env.d.ts +1 -1
- package/dist/runtime/executor.d.ts +1 -1
- package/dist/runtime/executor.js +31 -4
- package/dist/runtime/http.d.ts +2 -2
- package/dist/runtime/http.js +387 -47
- package/dist/runtime/insights.d.ts +1 -1
- package/dist/runtime/insights.js +6 -13
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +345 -22
- package/dist/runtime/keyring.js +1 -1
- package/dist/runtime/namespace.js +1 -1
- package/dist/runtime/native-network.d.ts +127 -0
- package/dist/runtime/native-network.js +1298 -0
- package/dist/runtime/otlp.d.ts +1 -1
- package/dist/runtime/perf.d.ts +1 -1
- package/dist/runtime/provider.d.ts +1 -1
- package/dist/runtime/provider.js +1 -2
- package/dist/runtime/proxy-errors.d.ts +1 -1
- package/dist/runtime/proxy-errors.js +9 -7
- package/dist/runtime/proxy-nodemaven.d.ts +56 -0
- package/dist/runtime/proxy-nodemaven.js +146 -0
- package/dist/runtime/proxy-retry-policy.d.ts +2 -2
- package/dist/runtime/proxy-retry-policy.js +2 -2
- package/dist/runtime/proxy-telemetry.d.ts +2 -1
- package/dist/runtime/proxy-telemetry.js +55 -52
- package/dist/runtime/redirects.d.ts +29 -0
- package/dist/runtime/redirects.js +36 -0
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +2 -4
- package/dist/runtime/request-options.d.ts +68 -1
- package/dist/runtime/request-options.js +548 -0
- package/dist/runtime/secrets.d.ts +27 -0
- package/dist/runtime/secrets.js +51 -0
- package/dist/runtime/state.d.ts +2 -2
- package/dist/runtime/state.js +238 -26
- package/dist/runtime/stealth.d.ts +5 -3
- package/dist/runtime/stealth.js +423 -143
- package/dist/runtime/stt.d.ts +1 -1
- package/dist/runtime/stt.js +11 -15
- package/dist/runtime/trace.d.ts +2 -2
- package/dist/runtime/trace.js +2 -4
- package/dist/runtime/waterfall.d.ts +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +7 -15
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +6 -6
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/server/self-test-redaction.d.ts +1 -1
- package/dist/server/self-test-redaction.js +1 -1
- package/dist/server/self-test.d.ts +104 -3
- package/dist/server/self-test.js +673 -115
- package/dist/server/serve.d.ts +116 -4
- package/dist/server/serve.js +799 -128
- package/dist/server/types.d.ts +34 -9
- package/dist/server/types.js +8 -1
- package/dist/stateful/errors.d.ts +14 -0
- package/dist/stateful/errors.js +14 -0
- package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
- package/dist/stateful/http-provider-event-emitter.js +237 -0
- package/dist/stateful/http-session-owner-registry.d.ts +44 -0
- package/dist/stateful/http-session-owner-registry.js +210 -0
- package/dist/stateful/index.d.ts +18 -0
- package/dist/stateful/index.js +18 -0
- package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
- package/dist/stateful/provider-event-delivery-failures.js +43 -0
- package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
- package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
- package/dist/stateful/provider-event-pipeline.d.ts +50 -0
- package/dist/stateful/provider-event-pipeline.js +1 -0
- package/dist/stateful/provider-events.d.ts +101 -0
- package/dist/stateful/provider-events.js +289 -0
- package/dist/stateful/session-key.d.ts +15 -0
- package/dist/stateful/session-key.js +86 -0
- package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
- package/dist/stateful/stateful-provider-adapter-context.js +42 -0
- package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
- package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
- package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
- package/dist/stateful/stateful-provider-adapter.js +287 -0
- package/dist/stateful/stateful-provider-observability.d.ts +62 -0
- package/dist/stateful/stateful-provider-observability.js +161 -0
- package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
- package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
- package/dist/stateful/stateful-provider-runtime-context.js +60 -0
- package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
- package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
- package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
- package/dist/stateful/stateful-provider-session-routing.js +353 -0
- package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
- package/dist/stateful/stateful-provider-session-runtime.js +245 -0
- package/dist/stateful-signing.d.ts +18 -0
- package/dist/stateful-signing.js +27 -0
- package/dist/stealth/profiles.d.ts +1 -1
- package/dist/stealth/profiles.js +5 -14
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/stream.d.ts +1 -1
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +2 -2
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +478 -28
- package/dist/types.d.ts +342 -14
- package/dist/types.js +1 -0
- package/dist/user-input.d.ts +30 -0
- package/dist/user-input.js +66 -0
- package/package.json +16 -5
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +28 -86
- package/src/ceremonies/index.ts +103 -78
- package/src/cli/commands.ts +10 -0
- package/src/cli/create.ts +42 -35
- package/src/cli/prompt-assets.ts +865 -0
- package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
- package/src/config/loader.ts +652 -204
- package/src/contract-serialization.ts +5 -11
- package/src/contract-types.ts +2 -2
- package/src/contract.ts +12 -28
- package/src/define.ts +499 -498
- package/src/dev.ts +4 -9
- package/src/error-resolution.ts +91 -0
- package/src/errors.ts +110 -5
- package/src/fixture-sanitization.ts +247 -0
- package/src/i18n/catalog.ts +10 -32
- package/src/i18n/index.ts +2 -2
- package/src/i18n/keys.ts +5 -11
- package/src/index.ts +112 -42
- package/src/lint.ts +88 -152
- package/src/native-address.ts +340 -0
- package/src/native-egress-policy.ts +358 -0
- package/src/observability.ts +51 -1
- package/src/provider.ts +66 -11
- package/src/public-schema-field-lint.ts +7 -33
- package/src/recipes/gov-api.ts +2 -5
- package/src/runtime/auth-flow.ts +7 -7
- package/src/runtime/browser.ts +64 -187
- package/src/runtime/cache.ts +20 -67
- package/src/runtime/choice.ts +79 -132
- package/src/runtime/credential.ts +2 -2
- package/src/runtime/env.ts +1 -1
- package/src/runtime/executor.ts +43 -20
- package/src/runtime/http.ts +494 -57
- package/src/runtime/insights.ts +15 -53
- package/src/runtime/instrumentation.ts +495 -66
- package/src/runtime/keyring.ts +7 -19
- package/src/runtime/namespace.ts +2 -7
- package/src/runtime/native-network.ts +1686 -0
- package/src/runtime/otlp.ts +12 -23
- package/src/runtime/perf.ts +1 -1
- package/src/runtime/provider.ts +4 -9
- package/src/runtime/proxy-errors.ts +29 -42
- package/src/runtime/proxy-nodemaven.ts +221 -0
- package/src/runtime/proxy-retry-policy.ts +3 -3
- package/src/runtime/proxy-telemetry.ts +79 -77
- package/src/runtime/redirects.ts +66 -0
- package/src/runtime/redis.ts +4 -12
- package/src/runtime/request-options.ts +679 -9
- package/src/runtime/secrets.ts +64 -0
- package/src/runtime/state.ts +353 -133
- package/src/runtime/stealth.ts +505 -154
- package/src/runtime/stt.ts +38 -94
- package/src/runtime/trace.ts +14 -44
- package/src/runtime/waterfall.ts +5 -18
- package/src/schema.ts +23 -84
- package/src/serve.ts +1 -1
- package/src/server/index.ts +29 -7
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/server/self-test-redaction.ts +2 -2
- package/src/server/self-test.ts +857 -132
- package/src/server/serve.ts +1151 -328
- package/src/server/types.ts +12 -13
- package/src/stateful/README.md +146 -0
- package/src/stateful/errors.ts +23 -0
- package/src/stateful/http-provider-event-emitter.ts +314 -0
- package/src/stateful/http-session-owner-registry.ts +306 -0
- package/src/stateful/index.ts +18 -0
- package/src/stateful/provider-event-delivery-failures.ts +80 -0
- package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
- package/src/stateful/provider-event-pipeline.ts +61 -0
- package/src/stateful/provider-events.ts +462 -0
- package/src/stateful/session-key.ts +111 -0
- package/src/stateful/stateful-provider-adapter-context.ts +59 -0
- package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
- package/src/stateful/stateful-provider-adapter.ts +562 -0
- package/src/stateful/stateful-provider-observability.ts +261 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
- package/src/stateful/stateful-provider-runtime-context.ts +92 -0
- package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
- package/src/stateful/stateful-provider-session-routing.ts +555 -0
- package/src/stateful/stateful-provider-session-runtime.ts +403 -0
- package/src/stateful-signing.ts +46 -0
- package/src/stealth/profiles.ts +10 -26
- package/src/stream-evidence.ts +988 -0
- package/src/stream.ts +8 -19
- package/src/testing/index.ts +10 -2
- package/src/testing/run.ts +653 -74
- package/src/types.ts +408 -28
- package/src/user-input.ts +118 -0
- package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
- package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
- /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
package/src/runtime/http.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import type { ProxyResolutionOptions } from "../config/loader";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import type { ProxyResolutionOptions } from "../config/loader.js";
|
|
2
|
+
import {
|
|
3
|
+
policyRotatesTransportVendorChain,
|
|
4
|
+
resolvePolicyTransportAttemptCap,
|
|
5
|
+
resolveProxyConfigAsync,
|
|
6
|
+
} from "../config/loader.js";
|
|
7
|
+
import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
|
|
8
|
+
import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
|
|
5
9
|
import type {
|
|
6
10
|
HttpClient,
|
|
7
11
|
HttpMethod,
|
|
12
|
+
HttpRedirectPolicy,
|
|
8
13
|
HttpResponse,
|
|
9
14
|
HttpRetrySummary,
|
|
10
15
|
HttpStreamResponse,
|
|
16
|
+
ProviderProxyPolicy,
|
|
11
17
|
RequestOptions,
|
|
12
18
|
RequestWithMethodOptions,
|
|
13
19
|
SseMessage,
|
|
14
|
-
} from "../types";
|
|
20
|
+
} from "../types.js";
|
|
15
21
|
import {
|
|
16
22
|
computeProxyAttemptIndex,
|
|
17
23
|
computeProxyTransportRetryDelayMs,
|
|
@@ -22,8 +28,15 @@ import {
|
|
|
22
28
|
proxyTransportRetryErrorStatus,
|
|
23
29
|
shouldRetryProxyTransportAttempt,
|
|
24
30
|
validateUnsafeProxyTransportRetryMethods,
|
|
25
|
-
} from "./proxy-retry-policy";
|
|
26
|
-
import {
|
|
31
|
+
} from "./proxy-retry-policy.js";
|
|
32
|
+
import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
|
|
33
|
+
import {
|
|
34
|
+
normalizeHttpRequestBody,
|
|
35
|
+
redactSensitiveError,
|
|
36
|
+
redactSensitiveRequestError,
|
|
37
|
+
type SerializedRequestUrl,
|
|
38
|
+
serializeRequestUrl,
|
|
39
|
+
} from "./request-options.js";
|
|
27
40
|
|
|
28
41
|
const DEFAULT_HTTP_BASE_URL = "http://localhost";
|
|
29
42
|
|
|
@@ -41,16 +54,28 @@ type HttpStatusOutcome = {
|
|
|
41
54
|
proxyUsed: boolean;
|
|
42
55
|
};
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Sentinel returned when a policy-allocator attempt resolved an endpoint that a
|
|
59
|
+
* prior attempt already tried (an under-filled pool repeats endpoints via the
|
|
60
|
+
* modulo mapping before the flat offset crosses into the next vendor). The retry
|
|
61
|
+
* loop advances to the next offset rather than re-issuing the request — but does
|
|
62
|
+
* NOT treat it as chain exhaustion, so the offset still walks into the fallback
|
|
63
|
+
* vendor's span.
|
|
64
|
+
*/
|
|
65
|
+
type NativeHttpSkipOutcome = { kind: "dedupe-skip" };
|
|
66
|
+
|
|
67
|
+
type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome | NativeHttpSkipOutcome;
|
|
45
68
|
|
|
46
69
|
type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
|
|
47
70
|
|
|
48
|
-
function isHttpStatusOutcome(
|
|
49
|
-
outcome: HttpResponse | HttpStatusOutcome,
|
|
50
|
-
): outcome is HttpStatusOutcome {
|
|
71
|
+
function isHttpStatusOutcome(outcome: NativeHttpAttemptOutcome): outcome is HttpStatusOutcome {
|
|
51
72
|
return "kind" in outcome && outcome.kind === "http-status";
|
|
52
73
|
}
|
|
53
74
|
|
|
75
|
+
function isDedupeSkipOutcome(outcome: NativeHttpAttemptOutcome): outcome is NativeHttpSkipOutcome {
|
|
76
|
+
return "kind" in outcome && outcome.kind === "dedupe-skip";
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
async function sleep(ms: number): Promise<void> {
|
|
55
80
|
if (ms <= 0) return;
|
|
56
81
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -191,9 +216,58 @@ function requireNativeResponseBody(response: Response): ReadableStream<Uint8Arra
|
|
|
191
216
|
return response.body;
|
|
192
217
|
}
|
|
193
218
|
|
|
194
|
-
function
|
|
219
|
+
function sanitizeStreamErrors(
|
|
220
|
+
body: ReadableStream<Uint8Array>,
|
|
221
|
+
serializedUrl: SerializedRequestUrl,
|
|
222
|
+
): ReadableStream<Uint8Array> {
|
|
223
|
+
if (serializedUrl.sensitiveValues.length === 0) return body;
|
|
224
|
+
|
|
225
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
226
|
+
return new ReadableStream<Uint8Array>(
|
|
227
|
+
{
|
|
228
|
+
async pull(controller) {
|
|
229
|
+
try {
|
|
230
|
+
reader ??= body.getReader();
|
|
231
|
+
const chunk = await reader.read();
|
|
232
|
+
if (chunk.done) {
|
|
233
|
+
controller.close();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
controller.enqueue(chunk.value);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
controller.error(
|
|
239
|
+
redactSensitiveError(
|
|
240
|
+
error,
|
|
241
|
+
serializedUrl.sensitiveValues,
|
|
242
|
+
serializedUrl.requestUrl,
|
|
243
|
+
serializedUrl.redactedUrl,
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
async cancel(reason) {
|
|
249
|
+
try {
|
|
250
|
+
await (reader ? reader.cancel(reason) : body.cancel(reason));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw redactSensitiveError(
|
|
253
|
+
error,
|
|
254
|
+
serializedUrl.sensitiveValues,
|
|
255
|
+
serializedUrl.requestUrl,
|
|
256
|
+
serializedUrl.redactedUrl,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{ highWaterMark: 0 },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function toNativeHttpStreamResponse(
|
|
266
|
+
response: Response,
|
|
267
|
+
serializedUrl: SerializedRequestUrl,
|
|
268
|
+
): HttpStreamResponse {
|
|
195
269
|
const headers = Object.fromEntries(response.headers.entries());
|
|
196
|
-
const body = requireNativeResponseBody(response);
|
|
270
|
+
const body = sanitizeStreamErrors(requireNativeResponseBody(response), serializedUrl);
|
|
197
271
|
return {
|
|
198
272
|
body,
|
|
199
273
|
headers,
|
|
@@ -240,6 +314,200 @@ function resolveHttpUrl(baseUrl: string | undefined, url: string): string {
|
|
|
240
314
|
|
|
241
315
|
type NativeFetchInit = RequestInit & { proxy?: string };
|
|
242
316
|
|
|
317
|
+
const MAX_HTTP_REDIRECT_HOPS = 20;
|
|
318
|
+
const HTTP_REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
|
|
319
|
+
const REDIRECT_BODY_HEADERS = new Set([
|
|
320
|
+
"content-encoding",
|
|
321
|
+
"content-language",
|
|
322
|
+
"content-location",
|
|
323
|
+
"content-type",
|
|
324
|
+
]);
|
|
325
|
+
const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
|
|
326
|
+
|
|
327
|
+
function invalidHttpRedirectPolicy(message: string, cause?: Error): TransportError {
|
|
328
|
+
return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
|
|
329
|
+
code: "http_redirect_policy_invalid",
|
|
330
|
+
...(cause ? { cause } : {}),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
|
|
335
|
+
function normalizeHttpRedirectPolicy(value: unknown): HttpRedirectPolicy | undefined {
|
|
336
|
+
if (value === undefined) return undefined;
|
|
337
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
338
|
+
throw invalidHttpRedirectPolicy("expected an object");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const keys = Reflect.ownKeys(value);
|
|
343
|
+
for (const key of keys) {
|
|
344
|
+
if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
|
|
345
|
+
throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
|
|
349
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, field);
|
|
350
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
351
|
+
throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const record = value as Record<string, unknown>;
|
|
356
|
+
if (record.mode !== "same-origin") {
|
|
357
|
+
throw invalidHttpRedirectPolicy('mode must be "same-origin"');
|
|
358
|
+
}
|
|
359
|
+
if (
|
|
360
|
+
typeof record.maxHops !== "number" ||
|
|
361
|
+
!Number.isInteger(record.maxHops) ||
|
|
362
|
+
record.maxHops < 0 ||
|
|
363
|
+
record.maxHops > MAX_HTTP_REDIRECT_HOPS
|
|
364
|
+
) {
|
|
365
|
+
throw invalidHttpRedirectPolicy(
|
|
366
|
+
`maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { mode: "same-origin", maxHops: record.maxHops };
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error instanceof TransportError) throw error;
|
|
373
|
+
throw invalidHttpRedirectPolicy(
|
|
374
|
+
"could not be inspected safely",
|
|
375
|
+
error instanceof Error ? error : undefined,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function snapshotHttpRedirectPolicy(options: RequestOptions): HttpRedirectPolicy | undefined {
|
|
381
|
+
try {
|
|
382
|
+
return normalizeHttpRedirectPolicy(options.redirectPolicy);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error instanceof TransportError) throw error;
|
|
385
|
+
throw invalidHttpRedirectPolicy(
|
|
386
|
+
"could not be read safely",
|
|
387
|
+
error instanceof Error ? error : undefined,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function withoutRedirectBodyHeaders(headers: HeadersInit | undefined): Headers {
|
|
393
|
+
const nextHeaders = new Headers(headers);
|
|
394
|
+
for (const name of REDIRECT_BODY_HEADERS) nextHeaders.delete(name);
|
|
395
|
+
return nextHeaders;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function redirectDiagnosticTarget(value: string): string {
|
|
399
|
+
try {
|
|
400
|
+
const parsed = new URL(value);
|
|
401
|
+
// Origin omits URL userinfo. Keeping only origin + path makes diagnostics
|
|
402
|
+
// useful while structurally excluding every query value and fragment,
|
|
403
|
+
// including attacker-chosen keys the provider did not declare sensitive.
|
|
404
|
+
const redactedQuery = parsed.search ? "?[REDACTED]" : "";
|
|
405
|
+
return parsed.origin === "null"
|
|
406
|
+
? `${parsed.protocol}<opaque-target>`
|
|
407
|
+
: `${parsed.origin}${parsed.pathname}${redactedQuery}`;
|
|
408
|
+
} catch {
|
|
409
|
+
return MALFORMED_REDIRECT_TARGET;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function discardRedirectResponseBody(response: Response): void {
|
|
414
|
+
try {
|
|
415
|
+
const cancellation = response.body?.cancel();
|
|
416
|
+
if (cancellation) void cancellation.catch(() => undefined);
|
|
417
|
+
} catch {
|
|
418
|
+
// The redirect decision is security-significant and must not be replaced
|
|
419
|
+
// or delayed by an upstream body's cancellation failure. Cancellation was
|
|
420
|
+
// attempted; redirect evaluation continues without awaiting its completion.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function fetchWithHttpRedirectPolicy(
|
|
425
|
+
requestUrl: string,
|
|
426
|
+
requestInit: NativeFetchInit,
|
|
427
|
+
policy: HttpRedirectPolicy | undefined,
|
|
428
|
+
): Promise<Response> {
|
|
429
|
+
if (!policy) return fetch(requestUrl, requestInit);
|
|
430
|
+
|
|
431
|
+
const initialUrl = new URL(requestUrl);
|
|
432
|
+
if (initialUrl.protocol !== "http:" && initialUrl.protocol !== "https:") {
|
|
433
|
+
throw new TransportError("ctx.http redirectPolicy requires an HTTP(S) origin", {
|
|
434
|
+
code: "transport_invalid_url",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const initialOrigin = initialUrl.origin;
|
|
439
|
+
let currentUrl = requestUrl;
|
|
440
|
+
let method = normalizeHttpMethod(requestInit.method ?? "GET");
|
|
441
|
+
let body = requestInit.body;
|
|
442
|
+
let headers = requestInit.headers;
|
|
443
|
+
let followedHops = 0;
|
|
444
|
+
const visitedRequests = new Set([`${method} ${currentUrl}`]);
|
|
445
|
+
|
|
446
|
+
while (true) {
|
|
447
|
+
const response = await fetch(currentUrl, {
|
|
448
|
+
...requestInit,
|
|
449
|
+
body,
|
|
450
|
+
headers,
|
|
451
|
+
method,
|
|
452
|
+
redirect: "manual",
|
|
453
|
+
});
|
|
454
|
+
if (!isRedirectStatus(response.status)) return response;
|
|
455
|
+
|
|
456
|
+
const location = response.headers.get("location");
|
|
457
|
+
discardRedirectResponseBody(response);
|
|
458
|
+
let nextUrlString: string | undefined;
|
|
459
|
+
try {
|
|
460
|
+
nextUrlString = resolveRedirectUrl(location || undefined, currentUrl);
|
|
461
|
+
} catch {
|
|
462
|
+
const target = MALFORMED_REDIRECT_TARGET;
|
|
463
|
+
throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
|
|
464
|
+
reason: "missing_location",
|
|
465
|
+
target,
|
|
466
|
+
status: response.status,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const decision = evaluateRedirectHop({
|
|
471
|
+
status: response.status,
|
|
472
|
+
method,
|
|
473
|
+
nextUrl: nextUrlString,
|
|
474
|
+
shouldStop: nextUrlString ? new URL(nextUrlString).origin !== initialOrigin : false,
|
|
475
|
+
redirectCount: followedHops + 1,
|
|
476
|
+
maxHops: policy.maxHops,
|
|
477
|
+
visitedRequests,
|
|
478
|
+
});
|
|
479
|
+
if (decision.kind === "stop") {
|
|
480
|
+
const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
|
|
481
|
+
const message = (() => {
|
|
482
|
+
switch (decision.reason) {
|
|
483
|
+
case "stopped":
|
|
484
|
+
return `Redirect policy refused cross-origin target ${target}`;
|
|
485
|
+
case "max_hops":
|
|
486
|
+
return `Redirect policy reached maxHops before target ${target}`;
|
|
487
|
+
case "loop":
|
|
488
|
+
return `Redirect loop refused target ${target}`;
|
|
489
|
+
case "missing_location":
|
|
490
|
+
return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
|
|
491
|
+
}
|
|
492
|
+
})();
|
|
493
|
+
throw new HttpRedirectError(message, {
|
|
494
|
+
reason: decision.reason,
|
|
495
|
+
...(target ? { target } : {}),
|
|
496
|
+
status: response.status,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
if (decision.nextMethod !== method) {
|
|
500
|
+
body = undefined;
|
|
501
|
+
headers = withoutRedirectBodyHeaders(headers);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
method = decision.nextMethod;
|
|
505
|
+
currentUrl = decision.nextUrl;
|
|
506
|
+
followedHops += 1;
|
|
507
|
+
visitedRequests.add(`${method} ${currentUrl}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
243
511
|
async function resolveNativeProxy(
|
|
244
512
|
options: RequestOptions,
|
|
245
513
|
clientOptions: HttpClientOptions,
|
|
@@ -256,6 +524,9 @@ async function resolveNativeProxy(
|
|
|
256
524
|
baseProxyAttempt: clientOptions.proxyAttempt,
|
|
257
525
|
retryAttemptOffset: proxyAttemptOffset,
|
|
258
526
|
}),
|
|
527
|
+
// Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
|
|
528
|
+
// supported here, so a socks5 policy fails loudly rather than downgrading.
|
|
529
|
+
transportProtocols: ["http"],
|
|
259
530
|
telemetry: clientOptions.telemetry,
|
|
260
531
|
});
|
|
261
532
|
if (resolvedProxy.shouldWarn) {
|
|
@@ -285,6 +556,22 @@ function normalizeNativeFetchBody(body: unknown): string | ArrayBuffer | undefin
|
|
|
285
556
|
return copied.buffer;
|
|
286
557
|
}
|
|
287
558
|
|
|
559
|
+
function serializeHttpRequestUrl(
|
|
560
|
+
baseUrl: string | undefined,
|
|
561
|
+
url: string,
|
|
562
|
+
options: RequestOptions,
|
|
563
|
+
): SerializedRequestUrl {
|
|
564
|
+
try {
|
|
565
|
+
return serializeRequestUrl(
|
|
566
|
+
resolveHttpUrl(baseUrl, url),
|
|
567
|
+
options.params,
|
|
568
|
+
options.sensitiveParams,
|
|
569
|
+
);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
288
575
|
async function fetchNativeHttp(
|
|
289
576
|
baseUrl: string | undefined,
|
|
290
577
|
url: string,
|
|
@@ -294,8 +581,10 @@ async function fetchNativeHttp(
|
|
|
294
581
|
warn: (message: string) => void,
|
|
295
582
|
statusRetryCodes?: readonly number[],
|
|
296
583
|
proxyAttemptOffset = 0,
|
|
584
|
+
dedupe?: { attempted: Set<string> },
|
|
297
585
|
): Promise<NativeHttpAttemptOutcome> {
|
|
298
|
-
const
|
|
586
|
+
const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
|
|
587
|
+
const { requestUrl } = serializedUrl;
|
|
299
588
|
const controller = options.timeout ? new AbortController() : undefined;
|
|
300
589
|
const timeoutHandle = options.timeout
|
|
301
590
|
? setTimeout(() => controller?.abort(), options.timeout)
|
|
@@ -303,7 +592,20 @@ async function fetchNativeHttp(
|
|
|
303
592
|
|
|
304
593
|
let proxy: string | undefined;
|
|
305
594
|
try {
|
|
595
|
+
// Resolve inside the try (and after the timeout is armed) so allocator
|
|
596
|
+
// failures are branded as TransportErrors and count against the request
|
|
597
|
+
// deadline, exactly as an inline resolve would.
|
|
306
598
|
proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
|
|
599
|
+
// For a registry allocator chain, skip an endpoint a prior attempt already
|
|
600
|
+
// tried rather than re-issuing the same request. Returning the sentinel
|
|
601
|
+
// (instead of breaking) lets the loop keep advancing the flat offset until
|
|
602
|
+
// it crosses into the fallback vendor's pool span.
|
|
603
|
+
if (dedupe && proxy) {
|
|
604
|
+
if (dedupe.attempted.has(proxy)) {
|
|
605
|
+
return { kind: "dedupe-skip" };
|
|
606
|
+
}
|
|
607
|
+
dedupe.attempted.add(proxy);
|
|
608
|
+
}
|
|
307
609
|
const requestInit: NativeFetchInit = {
|
|
308
610
|
headers: options.headers,
|
|
309
611
|
method,
|
|
@@ -313,9 +615,11 @@ async function fetchNativeHttp(
|
|
|
313
615
|
if (options.body !== undefined) {
|
|
314
616
|
requestInit.body = normalizeNativeFetchBody(options.body);
|
|
315
617
|
}
|
|
316
|
-
const response = await
|
|
317
|
-
|
|
318
|
-
|
|
618
|
+
const response = await fetchWithHttpRedirectPolicy(
|
|
619
|
+
requestUrl,
|
|
620
|
+
requestInit,
|
|
621
|
+
options.redirectPolicy,
|
|
622
|
+
);
|
|
319
623
|
const headers = Object.fromEntries(response.headers.entries());
|
|
320
624
|
|
|
321
625
|
if (statusRetryCodes && response.status >= 400) {
|
|
@@ -340,9 +644,19 @@ async function fetchNativeHttp(
|
|
|
340
644
|
return toNativeHttpResponse(response);
|
|
341
645
|
} catch (error) {
|
|
342
646
|
if (error instanceof SyntaxError) {
|
|
343
|
-
throw
|
|
647
|
+
throw redactSensitiveError(
|
|
648
|
+
error,
|
|
649
|
+
serializedUrl.sensitiveValues,
|
|
650
|
+
serializedUrl.requestUrl,
|
|
651
|
+
serializedUrl.redactedUrl,
|
|
652
|
+
);
|
|
344
653
|
}
|
|
345
|
-
const transportError =
|
|
654
|
+
const transportError: NativeHttpAttemptError = redactSensitiveError(
|
|
655
|
+
toHttpTransportError(error),
|
|
656
|
+
serializedUrl.sensitiveValues,
|
|
657
|
+
serializedUrl.requestUrl,
|
|
658
|
+
serializedUrl.redactedUrl,
|
|
659
|
+
);
|
|
346
660
|
transportError.proxyUsed = Boolean(proxy);
|
|
347
661
|
throw transportError;
|
|
348
662
|
} finally {
|
|
@@ -358,7 +672,8 @@ async function fetchNativeHttpStream(
|
|
|
358
672
|
clientOptions: HttpClientOptions,
|
|
359
673
|
warn: (message: string) => void,
|
|
360
674
|
): Promise<HttpStreamResponse> {
|
|
361
|
-
const
|
|
675
|
+
const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
|
|
676
|
+
const { requestUrl } = serializedUrl;
|
|
362
677
|
const controller = options.timeout ? new AbortController() : undefined;
|
|
363
678
|
const timeoutHandle = options.timeout
|
|
364
679
|
? setTimeout(() => controller?.abort(), options.timeout)
|
|
@@ -375,9 +690,11 @@ async function fetchNativeHttpStream(
|
|
|
375
690
|
if (options.body !== undefined) {
|
|
376
691
|
requestInit.body = normalizeNativeFetchBody(options.body);
|
|
377
692
|
}
|
|
378
|
-
const response = await
|
|
379
|
-
|
|
380
|
-
|
|
693
|
+
const response = await fetchWithHttpRedirectPolicy(
|
|
694
|
+
requestUrl,
|
|
695
|
+
requestInit,
|
|
696
|
+
options.redirectPolicy,
|
|
697
|
+
);
|
|
381
698
|
|
|
382
699
|
if (response.status >= 400 && options.throwOnHttpError !== false) {
|
|
383
700
|
await drainNativeResponseBody(response);
|
|
@@ -387,12 +704,22 @@ async function fetchNativeHttpStream(
|
|
|
387
704
|
});
|
|
388
705
|
}
|
|
389
706
|
|
|
390
|
-
return toNativeHttpStreamResponse(response);
|
|
707
|
+
return toNativeHttpStreamResponse(response, serializedUrl);
|
|
391
708
|
} catch (error) {
|
|
392
709
|
if (error instanceof SyntaxError) {
|
|
393
|
-
throw
|
|
710
|
+
throw redactSensitiveError(
|
|
711
|
+
error,
|
|
712
|
+
serializedUrl.sensitiveValues,
|
|
713
|
+
serializedUrl.requestUrl,
|
|
714
|
+
serializedUrl.redactedUrl,
|
|
715
|
+
);
|
|
394
716
|
}
|
|
395
|
-
throw
|
|
717
|
+
throw redactSensitiveError(
|
|
718
|
+
toHttpTransportError(error),
|
|
719
|
+
serializedUrl.sensitiveValues,
|
|
720
|
+
serializedUrl.requestUrl,
|
|
721
|
+
serializedUrl.redactedUrl,
|
|
722
|
+
);
|
|
396
723
|
} finally {
|
|
397
724
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
398
725
|
}
|
|
@@ -417,22 +744,33 @@ export function createHttpClient(
|
|
|
417
744
|
method: string,
|
|
418
745
|
options: RequestOptions & { body?: unknown } = {},
|
|
419
746
|
): Promise<HttpResponse> {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
747
|
+
const { explicitRetry, headersOptions, methodName, retryOptions } = (() => {
|
|
748
|
+
try {
|
|
749
|
+
if (!baseUrl && !isAbsoluteUrl(url)) {
|
|
750
|
+
throw new TransportError(
|
|
751
|
+
"ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
|
|
752
|
+
{ code: "transport_invalid_url" },
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
assertNoHttpTransportOverrides(options);
|
|
756
|
+
const redirectPolicy = snapshotHttpRedirectPolicy(options);
|
|
757
|
+
const headersOptions = {
|
|
758
|
+
...withClientHeaders(options, clientOptions, options.body),
|
|
759
|
+
redirectPolicy,
|
|
760
|
+
};
|
|
761
|
+
const methodName = normalizeHttpMethod(method);
|
|
762
|
+
const explicitRetry = headersOptions.retry !== undefined;
|
|
763
|
+
const retryOptions =
|
|
764
|
+
normalizeProxyTransportRetryOptions(headersOptions.retry, {
|
|
765
|
+
label: "HTTP",
|
|
766
|
+
}) ??
|
|
767
|
+
(explicitRetry ? undefined : createDefaultProxyTransportRetryOptions({ label: "HTTP" }));
|
|
768
|
+
if (retryOptions) validateUnsafeProxyTransportRetryMethods(retryOptions, "HTTP");
|
|
769
|
+
return { explicitRetry, headersOptions, methodName, retryOptions };
|
|
770
|
+
} catch (error) {
|
|
771
|
+
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
772
|
+
}
|
|
773
|
+
})();
|
|
436
774
|
const retryEnabled = Boolean(
|
|
437
775
|
retryOptions &&
|
|
438
776
|
retryOptions.attempts > 1 &&
|
|
@@ -449,6 +787,56 @@ export function createHttpClient(
|
|
|
449
787
|
? { ...headersOptions, throwOnHttpError: false }
|
|
450
788
|
: headersOptions;
|
|
451
789
|
|
|
790
|
+
// Span the whole vendor chain on transport failures. Like ctx.stealth, a
|
|
791
|
+
// policy-managed proxy resolves a *different* endpoint/vendor per attempt
|
|
792
|
+
// (the flat proxyAttemptOffset rotates across the concatenated vendor pool
|
|
793
|
+
// spans), so a transport failure should advance to the next endpoint —
|
|
794
|
+
// potentially crossing into the fallback vendor — rather than stopping at
|
|
795
|
+
// the per-endpoint retry budget and stranding the request on the primary
|
|
796
|
+
// vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
|
|
797
|
+
// only for implicit, safe-method allocator requests; explicit retry
|
|
798
|
+
// policies (their documented `attempts` ceiling), unsafe methods, and
|
|
799
|
+
// static/non-registry vendors keep the retry budget. Status-code retries
|
|
800
|
+
// stay bounded by the retry budget regardless; only transport rotation gets
|
|
801
|
+
// the full span.
|
|
802
|
+
const policyProxy: ProviderProxyPolicy | undefined = (() => {
|
|
803
|
+
const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
|
|
804
|
+
return policy && typeof policy === "object" ? policy : undefined;
|
|
805
|
+
})();
|
|
806
|
+
const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
|
|
807
|
+
const transportAttemptCap = retryOptions
|
|
808
|
+
? resolvePolicyTransportAttemptCap({
|
|
809
|
+
policy: policyProxy,
|
|
810
|
+
usesPolicyAllocator,
|
|
811
|
+
retryAttempts: retryOptions.attempts,
|
|
812
|
+
explicitRetry,
|
|
813
|
+
method: methodName,
|
|
814
|
+
})
|
|
815
|
+
: 1;
|
|
816
|
+
|
|
817
|
+
// Track resolved endpoints across a policy-allocator chain. Successive
|
|
818
|
+
// attempts rotate the flat offset across the concatenated vendor pool
|
|
819
|
+
// spans, but an under-filled allocation (fewer live endpoints than the
|
|
820
|
+
// configured pool size) makes the modulo mapping repeat endpoints before
|
|
821
|
+
// the offset reaches the next vendor. Rather than re-hammering an
|
|
822
|
+
// already-tried endpoint under backoff, fetchNativeHttp returns a skip
|
|
823
|
+
// sentinel for a duplicate; the loop then advances the flat offset without
|
|
824
|
+
// issuing the request, so it keeps walking toward — and into — the fallback
|
|
825
|
+
// vendor's pool span instead of stalling on the primary vendor.
|
|
826
|
+
// De-duplication is gated on the SAME predicate that widens the attempt cap
|
|
827
|
+
// (implicit, safe-method, registry-chain rotation). It must NOT engage for
|
|
828
|
+
// an explicit retry policy: there the caller's `attempts` count is the
|
|
829
|
+
// contract and each attempt must issue against whatever endpoint it resolves
|
|
830
|
+
// — even a repeat — instead of being silently skipped (which would collapse
|
|
831
|
+
// a `poolSize: 1` + `attempts: 3` request to a single fetch).
|
|
832
|
+
const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
|
|
833
|
+
policy: policyProxy,
|
|
834
|
+
usesPolicyAllocator,
|
|
835
|
+
explicitRetry,
|
|
836
|
+
method: methodName,
|
|
837
|
+
});
|
|
838
|
+
const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set<string>() } : undefined;
|
|
839
|
+
|
|
452
840
|
const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
|
|
453
841
|
fetchNativeHttp(
|
|
454
842
|
baseUrl,
|
|
@@ -459,24 +847,52 @@ export function createHttpClient(
|
|
|
459
847
|
warnOnce,
|
|
460
848
|
statusRetryEnabled ? retryOptions?.statusCodes : undefined,
|
|
461
849
|
proxyAttemptOffset,
|
|
850
|
+
dedupeContext,
|
|
462
851
|
);
|
|
463
852
|
|
|
464
853
|
if (!retryEnabled || !retryOptions) {
|
|
465
854
|
const outcome = await executeOnce();
|
|
855
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
856
|
+
// Single-shot path never de-duplicates (dedupeContext is undefined),
|
|
857
|
+
// but keep the union total.
|
|
858
|
+
throw new TransportError("HTTP request produced no terminal result", {
|
|
859
|
+
code: "retry_exhausted",
|
|
860
|
+
});
|
|
861
|
+
}
|
|
466
862
|
if (isHttpStatusOutcome(outcome)) {
|
|
467
863
|
throw toUpstreamHttpError(outcome.status);
|
|
468
864
|
}
|
|
469
865
|
return outcome;
|
|
470
866
|
}
|
|
471
867
|
|
|
868
|
+
let lastError: unknown;
|
|
472
869
|
let lastErrorCode: string | undefined;
|
|
473
870
|
let lastStatus: number | undefined;
|
|
474
|
-
|
|
871
|
+
// `attempt` walks the flat proxy offset across the full chain span; `issued`
|
|
872
|
+
// counts requests that were actually sent (skipped duplicate offsets do not
|
|
873
|
+
// increment it). Retry summaries and the status-retry budget must reflect
|
|
874
|
+
// issued requests, not the raw offset, so they stay accurate when partial
|
|
875
|
+
// allocations skip offsets.
|
|
876
|
+
let issued = 0;
|
|
877
|
+
for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
|
|
878
|
+
// Whether this offset actually issued a request (vs. a skipped duplicate),
|
|
879
|
+
// so the catch counts a thrown *transport* failure once without
|
|
880
|
+
// double-counting a status outcome that already incremented before it
|
|
881
|
+
// re-threw as an upstream HTTP error.
|
|
882
|
+
let issuedThisAttempt = false;
|
|
475
883
|
try {
|
|
476
884
|
const outcome = await executeOnce(attempt - 1);
|
|
885
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
886
|
+
// Duplicate endpoint from a partial allocation: advance the flat
|
|
887
|
+
// offset without issuing the request (no backoff, not a failure) so
|
|
888
|
+
// the loop keeps rotating toward the fallback vendor.
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
issued += 1;
|
|
892
|
+
issuedThisAttempt = true;
|
|
477
893
|
if (isHttpStatusOutcome(outcome)) {
|
|
478
894
|
lastStatus = outcome.status;
|
|
479
|
-
if (outcome.retryable &&
|
|
895
|
+
if (outcome.retryable && issued < retryOptions.attempts) {
|
|
480
896
|
await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
|
|
481
897
|
continue;
|
|
482
898
|
}
|
|
@@ -488,10 +904,10 @@ export function createHttpClient(
|
|
|
488
904
|
throw toUpstreamHttpError(response.status);
|
|
489
905
|
}
|
|
490
906
|
|
|
491
|
-
if (
|
|
907
|
+
if (issued > 1) {
|
|
492
908
|
const summary: HttpRetrySummary = {
|
|
493
|
-
attempts:
|
|
494
|
-
retries:
|
|
909
|
+
attempts: issued,
|
|
910
|
+
retries: issued - 1,
|
|
495
911
|
...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
|
|
496
912
|
transport: "native",
|
|
497
913
|
...(lastErrorCode ? { lastErrorCode } : {}),
|
|
@@ -501,11 +917,13 @@ export function createHttpClient(
|
|
|
501
917
|
}
|
|
502
918
|
return response;
|
|
503
919
|
} catch (error) {
|
|
920
|
+
if (!issuedThisAttempt) issued += 1;
|
|
921
|
+
lastError = error;
|
|
504
922
|
lastErrorCode = proxyTransportRetryErrorCode(error);
|
|
505
923
|
lastStatus = proxyTransportRetryErrorStatus(error);
|
|
506
924
|
const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
|
|
507
925
|
if (
|
|
508
|
-
attempt <
|
|
926
|
+
attempt < transportAttemptCap &&
|
|
509
927
|
shouldRetryProxyTransportAttempt({
|
|
510
928
|
error,
|
|
511
929
|
explicitRetry,
|
|
@@ -521,6 +939,13 @@ export function createHttpClient(
|
|
|
521
939
|
}
|
|
522
940
|
}
|
|
523
941
|
|
|
942
|
+
// Reached when the attempt cap is consumed without a terminal outcome —
|
|
943
|
+
// e.g. the final offsets of a partial allocation all resolved to
|
|
944
|
+
// already-tried endpoints and were skipped. Surface the last real transport
|
|
945
|
+
// failure rather than a synthetic exhaustion error.
|
|
946
|
+
if (lastError !== undefined) {
|
|
947
|
+
throw lastError;
|
|
948
|
+
}
|
|
524
949
|
throw new TransportError("HTTP retry exhausted without a terminal result", {
|
|
525
950
|
code: "retry_exhausted",
|
|
526
951
|
});
|
|
@@ -531,15 +956,27 @@ export function createHttpClient(
|
|
|
531
956
|
method: string,
|
|
532
957
|
options: RequestOptions & { body?: unknown } = {},
|
|
533
958
|
): Promise<HttpStreamResponse> {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
959
|
+
const { headersOptions, methodName } = (() => {
|
|
960
|
+
try {
|
|
961
|
+
if (!baseUrl && !isAbsoluteUrl(url)) {
|
|
962
|
+
throw new TransportError(
|
|
963
|
+
"ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
|
|
964
|
+
{ code: "transport_invalid_url" },
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
assertNoHttpTransportOverrides(options);
|
|
968
|
+
const redirectPolicy = snapshotHttpRedirectPolicy(options);
|
|
969
|
+
return {
|
|
970
|
+
headersOptions: {
|
|
971
|
+
...withClientHeaders(options, clientOptions, options.body),
|
|
972
|
+
redirectPolicy,
|
|
973
|
+
},
|
|
974
|
+
methodName: normalizeHttpMethod(method),
|
|
975
|
+
};
|
|
976
|
+
} catch (error) {
|
|
977
|
+
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
978
|
+
}
|
|
979
|
+
})();
|
|
543
980
|
return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
|
|
544
981
|
}
|
|
545
982
|
|