@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.40
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 +501 -13
- package/CHANGELOG.md +165 -1
- package/README.md +72 -14
- package/SUBMISSION.md +1 -1
- package/bin/apifuse-check.ts +88 -4
- package/bin/apifuse-dev.ts +38 -5
- package/bin/apifuse-pack-check.ts +22 -2
- package/bin/apifuse-pack-smoke.ts +57 -2
- package/bin/apifuse-pack-types.ts +356 -38
- package/bin/apifuse-perf.ts +14 -13
- package/bin/apifuse-record.ts +691 -68
- package/bin/apifuse-submit-check.ts +518 -37
- package/bin/apifuse-sync-assets.ts +117 -0
- package/bin/submit-check-delimited-text.ts +50 -0
- package/dist/auth-turn/index.d.ts +3 -3
- package/dist/auth-turn/index.js +1 -1
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +67 -0
- package/dist/ceremonies/index.d.ts +16 -0
- package/dist/ceremonies/index.js +141 -36
- 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/cli/templates/provider/Dockerfile.tpl +1 -1
- package/dist/cli/templates/provider/README.md.tpl +4 -4
- package/dist/cli/templates/provider/index.ts.tpl +6 -3
- package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -1
- package/dist/config/loader.d.ts +177 -16
- package/dist/config/loader.js +424 -127
- package/dist/contract-serialization.js +4 -8
- package/dist/contract-types.d.ts +1 -0
- package/dist/contract.js +2 -0
- package/dist/declaration-validation.d.ts +32 -0
- package/dist/declaration-validation.js +207 -0
- package/dist/define.d.ts +51 -25
- package/dist/define.js +752 -38
- package/dist/error-resolution.d.ts +4 -0
- package/dist/error-resolution.js +122 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +40 -0
- package/dist/fixture-sanitization.d.ts +28 -0
- package/dist/fixture-sanitization.js +217 -0
- package/dist/health-scenario.d.ts +1842 -0
- package/dist/health-scenario.js +624 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.js +10 -6
- package/dist/lint.d.ts +6 -1
- package/dist/lint.js +362 -3
- 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 +8 -2
- package/dist/provider.js +3 -1
- package/dist/runtime/auth-flow.d.ts +5 -1
- package/dist/runtime/auth-flow.js +6 -0
- package/dist/runtime/browser.d.ts +1 -0
- package/dist/runtime/browser.js +492 -49
- package/dist/runtime/cache.d.ts +1 -0
- package/dist/runtime/cache.js +169 -15
- package/dist/runtime/choice-wordlist.d.ts +9 -0
- package/dist/runtime/choice-wordlist.js +138 -0
- package/dist/runtime/choice.d.ts +13 -1
- package/dist/runtime/choice.js +490 -102
- package/dist/runtime/executor.d.ts +2 -2
- package/dist/runtime/executor.js +26 -2
- package/dist/runtime/http.d.ts +1 -0
- package/dist/runtime/http.js +515 -53
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +366 -8
- package/dist/runtime/native-network-errors.d.ts +33 -0
- package/dist/runtime/native-network-errors.js +69 -0
- package/dist/runtime/native-network.d.ts +96 -0
- package/dist/runtime/native-network.js +1232 -0
- package/dist/runtime/ocr.d.ts +29 -0
- package/dist/runtime/ocr.js +440 -0
- package/dist/runtime/proxy-errors.js +6 -2
- package/dist/runtime/proxy-nodemaven.d.ts +56 -0
- package/dist/runtime/proxy-nodemaven.js +146 -0
- package/dist/runtime/proxy-telemetry.d.ts +80 -1
- package/dist/runtime/proxy-telemetry.js +154 -47
- 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 +4 -2
- package/dist/runtime/request-options.d.ts +68 -1
- package/dist/runtime/request-options.js +548 -0
- package/dist/runtime/resolver-config.d.ts +6 -0
- package/dist/runtime/resolver-config.js +6 -0
- package/dist/runtime/resolver-public.d.ts +1 -0
- package/dist/runtime/resolver-public.js +1 -0
- package/dist/runtime/resolver-shared.d.ts +3 -0
- package/dist/runtime/resolver-shared.js +12 -0
- package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
- package/dist/runtime/resolver-vendors/bindings.js +40 -0
- package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
- package/dist/runtime/resolver-vendors/browser.js +377 -0
- package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
- package/dist/runtime/resolver-vendors/capsolver.js +526 -0
- package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
- package/dist/runtime/resolver-vendors/hosts.js +33 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
- package/dist/runtime/resolver-vendors/twocaptcha.js +407 -0
- package/dist/runtime/resolver-vendors/types.d.ts +94 -0
- package/dist/runtime/resolver-vendors/types.js +96 -0
- package/dist/runtime/resolver.d.ts +60 -0
- package/dist/runtime/resolver.js +737 -0
- package/dist/runtime/secrets.d.ts +27 -0
- package/dist/runtime/secrets.js +51 -0
- package/dist/runtime/state.d.ts +3 -0
- package/dist/runtime/state.js +277 -71
- package/dist/runtime/stealth-cookies.d.ts +20 -0
- package/dist/runtime/stealth-cookies.js +111 -0
- package/dist/runtime/stealth.d.ts +28 -3
- package/dist/runtime/stealth.js +519 -255
- package/dist/runtime/stt.js +1 -12
- package/dist/runtime/timeout.d.ts +5 -0
- package/dist/runtime/timeout.js +12 -0
- package/dist/runtime/trace-config.d.ts +12 -0
- package/dist/runtime/trace-config.js +61 -0
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/index.d.ts +5 -3
- package/dist/server/index.js +3 -3
- 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.d.ts +114 -0
- package/dist/server/self-test.js +784 -148
- package/dist/server/serve-implementation.d.ts +213 -0
- package/dist/server/serve-implementation.js +2173 -0
- package/dist/server/serve.d.ts +1 -70
- package/dist/server/serve.js +1 -1130
- package/dist/server/trace-output.d.ts +4 -0
- package/dist/server/trace-output.js +20 -0
- package/dist/server/types.d.ts +30 -5
- package/dist/server/types.js +13 -1
- package/dist/stateful/errors.d.ts +19 -0
- package/dist/stateful/errors.js +24 -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 +67 -0
- package/dist/stateful/stateful-provider-session-routing.js +345 -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.js +16 -7
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/stream.js +7 -1
- package/dist/testing/index.d.ts +2 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +489 -21
- package/dist/trace-sanitization.d.ts +5 -0
- package/dist/trace-sanitization.js +45 -0
- package/dist/types.d.ts +545 -23
- package/dist/types.js +1 -0
- package/package.json +44 -5
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +118 -0
- package/src/ceremonies/index.ts +189 -46
- 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/cli/templates/provider/Dockerfile.tpl +1 -1
- package/src/cli/templates/provider/README.md.tpl +4 -4
- package/src/cli/templates/provider/index.ts.tpl +6 -3
- package/src/cli/templates/provider/operations/ping.ts.tpl +2 -1
- package/src/config/loader.ts +665 -163
- package/src/contract-serialization.ts +5 -7
- package/src/contract-types.ts +1 -0
- package/src/contract.ts +2 -0
- package/src/declaration-validation.ts +266 -0
- package/src/define.ts +970 -87
- package/src/error-resolution.ts +127 -0
- package/src/errors.ts +52 -0
- package/src/fixture-sanitization.ts +248 -0
- package/src/health-scenario.ts +875 -0
- package/src/index.ts +204 -8
- package/src/lint.ts +408 -4
- 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 +133 -0
- package/src/runtime/auth-flow.ts +12 -0
- package/src/runtime/browser.ts +661 -63
- package/src/runtime/cache.ts +189 -14
- package/src/runtime/choice-wordlist.ts +145 -0
- package/src/runtime/choice.ts +631 -120
- package/src/runtime/executor.ts +40 -7
- package/src/runtime/http.ts +641 -61
- package/src/runtime/instrumentation.ts +520 -15
- package/src/runtime/native-network-errors.ts +99 -0
- package/src/runtime/native-network.ts +1605 -0
- package/src/runtime/ocr.ts +523 -0
- package/src/runtime/proxy-errors.ts +12 -4
- package/src/runtime/proxy-nodemaven.ts +221 -0
- package/src/runtime/proxy-telemetry.ts +244 -75
- package/src/runtime/redirects.ts +66 -0
- package/src/runtime/redis.ts +7 -2
- package/src/runtime/request-options.ts +680 -1
- package/src/runtime/resolver-config.ts +6 -0
- package/src/runtime/resolver-public.ts +20 -0
- package/src/runtime/resolver-shared.ts +17 -0
- package/src/runtime/resolver-vendors/bindings.ts +56 -0
- package/src/runtime/resolver-vendors/browser.ts +533 -0
- package/src/runtime/resolver-vendors/capsolver.ts +700 -0
- package/src/runtime/resolver-vendors/hosts.ts +38 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +539 -0
- package/src/runtime/resolver-vendors/types.ts +212 -0
- package/src/runtime/resolver.ts +1103 -0
- package/src/runtime/secrets.ts +64 -0
- package/src/runtime/state.ts +394 -77
- package/src/runtime/stealth-cookies.ts +132 -0
- package/src/runtime/stealth.ts +675 -289
- package/src/runtime/stt.ts +1 -19
- package/src/runtime/timeout.ts +18 -0
- package/src/runtime/trace-config.ts +77 -0
- package/src/serve.ts +6 -1
- package/src/server/index.ts +37 -2
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/server/self-test.ts +1025 -175
- package/src/server/serve-implementation.ts +3250 -0
- package/src/server/serve.ts +1 -1626
- package/src/server/trace-output.ts +32 -0
- package/src/server/types.ts +13 -1
- package/src/stateful/README.md +146 -0
- package/src/stateful/errors.ts +35 -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 +546 -0
- package/src/stateful/stateful-provider-session-runtime.ts +403 -0
- package/src/stateful-signing.ts +46 -0
- package/src/stealth/profiles.ts +17 -7
- package/src/stream-evidence.ts +988 -0
- package/src/stream.ts +8 -1
- package/src/testing/index.ts +10 -1
- package/src/testing/run.ts +658 -15
- package/src/trace-sanitization.ts +63 -0
- package/src/types.ts +660 -38
- 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,13 +1,19 @@
|
|
|
1
1
|
import type { ProxyResolutionOptions } from "../config/loader.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
policyRotatesTransportVendorChain,
|
|
4
|
+
resolvePolicyTransportAttemptCap,
|
|
5
|
+
resolveProxyConfigAsync,
|
|
6
|
+
} from "../config/loader.js";
|
|
7
|
+
import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
|
|
4
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,
|
|
@@ -23,7 +29,14 @@ import {
|
|
|
23
29
|
shouldRetryProxyTransportAttempt,
|
|
24
30
|
validateUnsafeProxyTransportRetryMethods,
|
|
25
31
|
} from "./proxy-retry-policy.js";
|
|
26
|
-
import {
|
|
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
|
|
|
@@ -31,6 +44,7 @@ export type HttpClientOptions = ProxyResolutionOptions & {
|
|
|
31
44
|
warn?: (message: string) => void;
|
|
32
45
|
userAgent?: string;
|
|
33
46
|
onRetrySummary?: (summary: HttpRetrySummary) => void;
|
|
47
|
+
signal?: AbortSignal;
|
|
34
48
|
};
|
|
35
49
|
|
|
36
50
|
type HttpStatusOutcome = {
|
|
@@ -41,19 +55,67 @@ type HttpStatusOutcome = {
|
|
|
41
55
|
proxyUsed: boolean;
|
|
42
56
|
};
|
|
43
57
|
|
|
44
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Sentinel returned when a policy-allocator attempt resolved an endpoint that a
|
|
60
|
+
* prior attempt already tried (an under-filled pool repeats endpoints via the
|
|
61
|
+
* modulo mapping before the flat offset crosses into the next vendor). The retry
|
|
62
|
+
* loop advances to the next offset rather than re-issuing the request — but does
|
|
63
|
+
* NOT treat it as chain exhaustion, so the offset still walks into the fallback
|
|
64
|
+
* vendor's span.
|
|
65
|
+
*/
|
|
66
|
+
type NativeHttpSkipOutcome = { kind: "dedupe-skip" };
|
|
67
|
+
|
|
68
|
+
type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome | NativeHttpSkipOutcome;
|
|
45
69
|
|
|
46
70
|
type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
|
|
47
71
|
|
|
48
|
-
function isHttpStatusOutcome(
|
|
49
|
-
outcome: HttpResponse | HttpStatusOutcome,
|
|
50
|
-
): outcome is HttpStatusOutcome {
|
|
72
|
+
function isHttpStatusOutcome(outcome: NativeHttpAttemptOutcome): outcome is HttpStatusOutcome {
|
|
51
73
|
return "kind" in outcome && outcome.kind === "http-status";
|
|
52
74
|
}
|
|
53
75
|
|
|
54
|
-
|
|
76
|
+
function isDedupeSkipOutcome(outcome: NativeHttpAttemptOutcome): outcome is NativeHttpSkipOutcome {
|
|
77
|
+
return "kind" in outcome && outcome.kind === "dedupe-skip";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toAmbientCancellationError(
|
|
81
|
+
signal: AbortSignal,
|
|
82
|
+
error: unknown = signal.reason,
|
|
83
|
+
): TransportError {
|
|
84
|
+
if (error instanceof TransportError && error.code === "transport_cancelled") {
|
|
85
|
+
return error;
|
|
86
|
+
}
|
|
87
|
+
return new TransportError("Request cancelled", {
|
|
88
|
+
code: "transport_cancelled",
|
|
89
|
+
status: 0,
|
|
90
|
+
retryable: false,
|
|
91
|
+
...(error !== undefined
|
|
92
|
+
? { cause: error instanceof Error ? error : new Error(String(error)) }
|
|
93
|
+
: {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function throwIfAmbientAborted(signal: AbortSignal | undefined): void {
|
|
98
|
+
if (signal?.aborted) throw toAmbientCancellationError(signal);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
102
|
+
throwIfAmbientAborted(signal);
|
|
55
103
|
if (ms <= 0) return;
|
|
56
|
-
|
|
104
|
+
if (!signal) {
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await new Promise<void>((resolve, reject) => {
|
|
109
|
+
const onAbort = () => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
reject(toAmbientCancellationError(signal));
|
|
112
|
+
};
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
signal.removeEventListener("abort", onAbort);
|
|
115
|
+
resolve();
|
|
116
|
+
}, ms);
|
|
117
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
118
|
+
});
|
|
57
119
|
}
|
|
58
120
|
|
|
59
121
|
function toUpstreamHttpError(status: number): TransportError {
|
|
@@ -107,7 +169,21 @@ function isTimeoutMessage(message: string): boolean {
|
|
|
107
169
|
return /\b(timed out|timeout|deadline exceeded)\b/i.test(message);
|
|
108
170
|
}
|
|
109
171
|
|
|
110
|
-
function toHttpTransportError(
|
|
172
|
+
function toHttpTransportError(
|
|
173
|
+
error: unknown,
|
|
174
|
+
ambientSignal?: AbortSignal,
|
|
175
|
+
timeoutSignal?: AbortSignal,
|
|
176
|
+
): TransportError {
|
|
177
|
+
if (ambientSignal?.aborted) {
|
|
178
|
+
return toAmbientCancellationError(ambientSignal, error);
|
|
179
|
+
}
|
|
180
|
+
if (timeoutSignal?.aborted) {
|
|
181
|
+
return new TransportError("Request timed out", {
|
|
182
|
+
code: "transport_timeout",
|
|
183
|
+
status: 0,
|
|
184
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
111
187
|
if (error instanceof TransportError) {
|
|
112
188
|
if (error.code) {
|
|
113
189
|
return error;
|
|
@@ -191,9 +267,134 @@ function requireNativeResponseBody(response: Response): ReadableStream<Uint8Arra
|
|
|
191
267
|
return response.body;
|
|
192
268
|
}
|
|
193
269
|
|
|
194
|
-
function
|
|
270
|
+
function mergeAbortSignals(
|
|
271
|
+
...signals: Array<AbortSignal | null | undefined>
|
|
272
|
+
): AbortSignal | undefined {
|
|
273
|
+
const activeSignals = signals.filter((signal): signal is AbortSignal => signal != null);
|
|
274
|
+
if (activeSignals.length === 0) return undefined;
|
|
275
|
+
if (activeSignals.length === 1) return activeSignals[0];
|
|
276
|
+
return AbortSignal.any(activeSignals);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function cancelStreamOnAbort(
|
|
280
|
+
body: ReadableStream<Uint8Array>,
|
|
281
|
+
signal: AbortSignal | undefined,
|
|
282
|
+
): ReadableStream<Uint8Array> {
|
|
283
|
+
if (!signal) return body;
|
|
284
|
+
|
|
285
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
286
|
+
let finished = false;
|
|
287
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
288
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
289
|
+
const onAbort = () => {
|
|
290
|
+
if (finished) return;
|
|
291
|
+
finished = true;
|
|
292
|
+
cleanup();
|
|
293
|
+
const reason = toAmbientCancellationError(signal);
|
|
294
|
+
streamController?.error(reason);
|
|
295
|
+
const cancellation = reader ? reader.cancel(reason) : body.cancel(reason);
|
|
296
|
+
void cancellation.catch(() => undefined).finally(() => reader?.releaseLock());
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return new ReadableStream<Uint8Array>(
|
|
300
|
+
{
|
|
301
|
+
start(controller) {
|
|
302
|
+
streamController = controller;
|
|
303
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
304
|
+
if (signal.aborted) onAbort();
|
|
305
|
+
},
|
|
306
|
+
async pull(controller) {
|
|
307
|
+
try {
|
|
308
|
+
reader ??= body.getReader();
|
|
309
|
+
const chunk = await reader.read();
|
|
310
|
+
if (finished) return;
|
|
311
|
+
if (chunk.done) {
|
|
312
|
+
finished = true;
|
|
313
|
+
cleanup();
|
|
314
|
+
controller.close();
|
|
315
|
+
reader.releaseLock();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
controller.enqueue(chunk.value);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (finished) return;
|
|
321
|
+
finished = true;
|
|
322
|
+
cleanup();
|
|
323
|
+
controller.error(error);
|
|
324
|
+
reader?.releaseLock();
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
async cancel(reason) {
|
|
328
|
+
if (finished) return;
|
|
329
|
+
finished = true;
|
|
330
|
+
cleanup();
|
|
331
|
+
try {
|
|
332
|
+
await (reader ? reader.cancel(reason) : body.cancel(reason));
|
|
333
|
+
} finally {
|
|
334
|
+
reader?.releaseLock();
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
{ highWaterMark: 0 },
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function sanitizeStreamErrors(
|
|
343
|
+
body: ReadableStream<Uint8Array>,
|
|
344
|
+
serializedUrl: SerializedRequestUrl,
|
|
345
|
+
): ReadableStream<Uint8Array> {
|
|
346
|
+
if (serializedUrl.sensitiveValues.length === 0) return body;
|
|
347
|
+
|
|
348
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
349
|
+
return new ReadableStream<Uint8Array>(
|
|
350
|
+
{
|
|
351
|
+
async pull(controller) {
|
|
352
|
+
try {
|
|
353
|
+
reader ??= body.getReader();
|
|
354
|
+
const chunk = await reader.read();
|
|
355
|
+
if (chunk.done) {
|
|
356
|
+
controller.close();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
controller.enqueue(chunk.value);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
controller.error(
|
|
362
|
+
redactSensitiveError(
|
|
363
|
+
error,
|
|
364
|
+
serializedUrl.sensitiveValues,
|
|
365
|
+
serializedUrl.requestUrl,
|
|
366
|
+
serializedUrl.redactedUrl,
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
async cancel(reason) {
|
|
372
|
+
try {
|
|
373
|
+
await (reader ? reader.cancel(reason) : body.cancel(reason));
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throw redactSensitiveError(
|
|
376
|
+
error,
|
|
377
|
+
serializedUrl.sensitiveValues,
|
|
378
|
+
serializedUrl.requestUrl,
|
|
379
|
+
serializedUrl.redactedUrl,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
{ highWaterMark: 0 },
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function toNativeHttpStreamResponse(
|
|
389
|
+
response: Response,
|
|
390
|
+
serializedUrl: SerializedRequestUrl,
|
|
391
|
+
signal?: AbortSignal,
|
|
392
|
+
): HttpStreamResponse {
|
|
195
393
|
const headers = Object.fromEntries(response.headers.entries());
|
|
196
|
-
const body =
|
|
394
|
+
const body = sanitizeStreamErrors(
|
|
395
|
+
cancelStreamOnAbort(requireNativeResponseBody(response), signal),
|
|
396
|
+
serializedUrl,
|
|
397
|
+
);
|
|
197
398
|
return {
|
|
198
399
|
body,
|
|
199
400
|
headers,
|
|
@@ -240,6 +441,200 @@ function resolveHttpUrl(baseUrl: string | undefined, url: string): string {
|
|
|
240
441
|
|
|
241
442
|
type NativeFetchInit = RequestInit & { proxy?: string };
|
|
242
443
|
|
|
444
|
+
const MAX_HTTP_REDIRECT_HOPS = 20;
|
|
445
|
+
const HTTP_REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
|
|
446
|
+
const REDIRECT_BODY_HEADERS = new Set([
|
|
447
|
+
"content-encoding",
|
|
448
|
+
"content-language",
|
|
449
|
+
"content-location",
|
|
450
|
+
"content-type",
|
|
451
|
+
]);
|
|
452
|
+
const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
|
|
453
|
+
|
|
454
|
+
function invalidHttpRedirectPolicy(message: string, cause?: Error): TransportError {
|
|
455
|
+
return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
|
|
456
|
+
code: "http_redirect_policy_invalid",
|
|
457
|
+
...(cause ? { cause } : {}),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
|
|
462
|
+
function normalizeHttpRedirectPolicy(value: unknown): HttpRedirectPolicy | undefined {
|
|
463
|
+
if (value === undefined) return undefined;
|
|
464
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
465
|
+
throw invalidHttpRedirectPolicy("expected an object");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const keys = Reflect.ownKeys(value);
|
|
470
|
+
for (const key of keys) {
|
|
471
|
+
if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
|
|
472
|
+
throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
|
|
476
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, field);
|
|
477
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
478
|
+
throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const record = value as Record<string, unknown>;
|
|
483
|
+
if (record.mode !== "same-origin") {
|
|
484
|
+
throw invalidHttpRedirectPolicy('mode must be "same-origin"');
|
|
485
|
+
}
|
|
486
|
+
if (
|
|
487
|
+
typeof record.maxHops !== "number" ||
|
|
488
|
+
!Number.isInteger(record.maxHops) ||
|
|
489
|
+
record.maxHops < 0 ||
|
|
490
|
+
record.maxHops > MAX_HTTP_REDIRECT_HOPS
|
|
491
|
+
) {
|
|
492
|
+
throw invalidHttpRedirectPolicy(
|
|
493
|
+
`maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return { mode: "same-origin", maxHops: record.maxHops };
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (error instanceof TransportError) throw error;
|
|
500
|
+
throw invalidHttpRedirectPolicy(
|
|
501
|
+
"could not be inspected safely",
|
|
502
|
+
error instanceof Error ? error : undefined,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function snapshotHttpRedirectPolicy(options: RequestOptions): HttpRedirectPolicy | undefined {
|
|
508
|
+
try {
|
|
509
|
+
return normalizeHttpRedirectPolicy(options.redirectPolicy);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (error instanceof TransportError) throw error;
|
|
512
|
+
throw invalidHttpRedirectPolicy(
|
|
513
|
+
"could not be read safely",
|
|
514
|
+
error instanceof Error ? error : undefined,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function withoutRedirectBodyHeaders(headers: HeadersInit | undefined): Headers {
|
|
520
|
+
const nextHeaders = new Headers(headers);
|
|
521
|
+
for (const name of REDIRECT_BODY_HEADERS) nextHeaders.delete(name);
|
|
522
|
+
return nextHeaders;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function redirectDiagnosticTarget(value: string): string {
|
|
526
|
+
try {
|
|
527
|
+
const parsed = new URL(value);
|
|
528
|
+
// Origin omits URL userinfo. Keeping only origin + path makes diagnostics
|
|
529
|
+
// useful while structurally excluding every query value and fragment,
|
|
530
|
+
// including attacker-chosen keys the provider did not declare sensitive.
|
|
531
|
+
const redactedQuery = parsed.search ? "?[REDACTED]" : "";
|
|
532
|
+
return parsed.origin === "null"
|
|
533
|
+
? `${parsed.protocol}<opaque-target>`
|
|
534
|
+
: `${parsed.origin}${parsed.pathname}${redactedQuery}`;
|
|
535
|
+
} catch {
|
|
536
|
+
return MALFORMED_REDIRECT_TARGET;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function discardRedirectResponseBody(response: Response): void {
|
|
541
|
+
try {
|
|
542
|
+
const cancellation = response.body?.cancel();
|
|
543
|
+
if (cancellation) void cancellation.catch(() => undefined);
|
|
544
|
+
} catch {
|
|
545
|
+
// The redirect decision is security-significant and must not be replaced
|
|
546
|
+
// or delayed by an upstream body's cancellation failure. Cancellation was
|
|
547
|
+
// attempted; redirect evaluation continues without awaiting its completion.
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function fetchWithHttpRedirectPolicy(
|
|
552
|
+
requestUrl: string,
|
|
553
|
+
requestInit: NativeFetchInit,
|
|
554
|
+
policy: HttpRedirectPolicy | undefined,
|
|
555
|
+
): Promise<Response> {
|
|
556
|
+
if (!policy) return fetch(requestUrl, requestInit);
|
|
557
|
+
|
|
558
|
+
const initialUrl = new URL(requestUrl);
|
|
559
|
+
if (initialUrl.protocol !== "http:" && initialUrl.protocol !== "https:") {
|
|
560
|
+
throw new TransportError("ctx.http redirectPolicy requires an HTTP(S) origin", {
|
|
561
|
+
code: "transport_invalid_url",
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const initialOrigin = initialUrl.origin;
|
|
566
|
+
let currentUrl = requestUrl;
|
|
567
|
+
let method = normalizeHttpMethod(requestInit.method ?? "GET");
|
|
568
|
+
let body = requestInit.body;
|
|
569
|
+
let headers = requestInit.headers;
|
|
570
|
+
let followedHops = 0;
|
|
571
|
+
const visitedRequests = new Set([`${method} ${currentUrl}`]);
|
|
572
|
+
|
|
573
|
+
while (true) {
|
|
574
|
+
const response = await fetch(currentUrl, {
|
|
575
|
+
...requestInit,
|
|
576
|
+
body,
|
|
577
|
+
headers,
|
|
578
|
+
method,
|
|
579
|
+
redirect: "manual",
|
|
580
|
+
});
|
|
581
|
+
if (!isRedirectStatus(response.status)) return response;
|
|
582
|
+
|
|
583
|
+
const location = response.headers.get("location");
|
|
584
|
+
discardRedirectResponseBody(response);
|
|
585
|
+
let nextUrlString: string | undefined;
|
|
586
|
+
try {
|
|
587
|
+
nextUrlString = resolveRedirectUrl(location || undefined, currentUrl);
|
|
588
|
+
} catch {
|
|
589
|
+
const target = MALFORMED_REDIRECT_TARGET;
|
|
590
|
+
throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
|
|
591
|
+
reason: "missing_location",
|
|
592
|
+
target,
|
|
593
|
+
status: response.status,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const decision = evaluateRedirectHop({
|
|
598
|
+
status: response.status,
|
|
599
|
+
method,
|
|
600
|
+
nextUrl: nextUrlString,
|
|
601
|
+
shouldStop: nextUrlString ? new URL(nextUrlString).origin !== initialOrigin : false,
|
|
602
|
+
redirectCount: followedHops + 1,
|
|
603
|
+
maxHops: policy.maxHops,
|
|
604
|
+
visitedRequests,
|
|
605
|
+
});
|
|
606
|
+
if (decision.kind === "stop") {
|
|
607
|
+
const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
|
|
608
|
+
const message = (() => {
|
|
609
|
+
switch (decision.reason) {
|
|
610
|
+
case "stopped":
|
|
611
|
+
return `Redirect policy refused cross-origin target ${target}`;
|
|
612
|
+
case "max_hops":
|
|
613
|
+
return `Redirect policy reached maxHops before target ${target}`;
|
|
614
|
+
case "loop":
|
|
615
|
+
return `Redirect loop refused target ${target}`;
|
|
616
|
+
case "missing_location":
|
|
617
|
+
return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
|
|
618
|
+
}
|
|
619
|
+
})();
|
|
620
|
+
throw new HttpRedirectError(message, {
|
|
621
|
+
reason: decision.reason,
|
|
622
|
+
...(target ? { target } : {}),
|
|
623
|
+
status: response.status,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
if (decision.nextMethod !== method) {
|
|
627
|
+
body = undefined;
|
|
628
|
+
headers = withoutRedirectBodyHeaders(headers);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
method = decision.nextMethod;
|
|
632
|
+
currentUrl = decision.nextUrl;
|
|
633
|
+
followedHops += 1;
|
|
634
|
+
visitedRequests.add(`${method} ${currentUrl}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
243
638
|
async function resolveNativeProxy(
|
|
244
639
|
options: RequestOptions,
|
|
245
640
|
clientOptions: HttpClientOptions,
|
|
@@ -249,13 +644,15 @@ async function resolveNativeProxy(
|
|
|
249
644
|
const resolvedProxy = await resolveProxyConfigAsync({
|
|
250
645
|
proxy: options.proxy ?? clientOptions.proxy,
|
|
251
646
|
upstream: clientOptions.upstream,
|
|
252
|
-
apifuseConfig: clientOptions.apifuseConfig,
|
|
253
647
|
proxyPolicy: clientOptions.proxyPolicy,
|
|
254
648
|
affinityKey: clientOptions.affinityKey,
|
|
255
649
|
proxyAttempt: computeProxyAttemptIndex({
|
|
256
650
|
baseProxyAttempt: clientOptions.proxyAttempt,
|
|
257
651
|
retryAttemptOffset: proxyAttemptOffset,
|
|
258
652
|
}),
|
|
653
|
+
// Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
|
|
654
|
+
// supported here, so a socks5 policy fails loudly rather than downgrading.
|
|
655
|
+
transportProtocols: ["http"],
|
|
259
656
|
telemetry: clientOptions.telemetry,
|
|
260
657
|
});
|
|
261
658
|
if (resolvedProxy.shouldWarn) {
|
|
@@ -285,6 +682,22 @@ function normalizeNativeFetchBody(body: unknown): string | ArrayBuffer | undefin
|
|
|
285
682
|
return copied.buffer;
|
|
286
683
|
}
|
|
287
684
|
|
|
685
|
+
function serializeHttpRequestUrl(
|
|
686
|
+
baseUrl: string | undefined,
|
|
687
|
+
url: string,
|
|
688
|
+
options: RequestOptions,
|
|
689
|
+
): SerializedRequestUrl {
|
|
690
|
+
try {
|
|
691
|
+
return serializeRequestUrl(
|
|
692
|
+
resolveHttpUrl(baseUrl, url),
|
|
693
|
+
options.params,
|
|
694
|
+
options.sensitiveParams,
|
|
695
|
+
);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
288
701
|
async function fetchNativeHttp(
|
|
289
702
|
baseUrl: string | undefined,
|
|
290
703
|
url: string,
|
|
@@ -294,28 +707,48 @@ async function fetchNativeHttp(
|
|
|
294
707
|
warn: (message: string) => void,
|
|
295
708
|
statusRetryCodes?: readonly number[],
|
|
296
709
|
proxyAttemptOffset = 0,
|
|
710
|
+
dedupe?: { attempted: Set<string> },
|
|
297
711
|
): Promise<NativeHttpAttemptOutcome> {
|
|
298
|
-
const
|
|
712
|
+
const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
|
|
713
|
+
const { requestUrl } = serializedUrl;
|
|
299
714
|
const controller = options.timeout ? new AbortController() : undefined;
|
|
715
|
+
const signal = mergeAbortSignals(clientOptions.signal, controller?.signal);
|
|
300
716
|
const timeoutHandle = options.timeout
|
|
301
717
|
? setTimeout(() => controller?.abort(), options.timeout)
|
|
302
718
|
: undefined;
|
|
303
719
|
|
|
304
720
|
let proxy: string | undefined;
|
|
305
721
|
try {
|
|
722
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
723
|
+
// Resolve inside the try (and after the timeout is armed) so allocator
|
|
724
|
+
// failures are branded as TransportErrors and count against the request
|
|
725
|
+
// deadline, exactly as an inline resolve would.
|
|
306
726
|
proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
|
|
727
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
728
|
+
// For a registry allocator chain, skip an endpoint a prior attempt already
|
|
729
|
+
// tried rather than re-issuing the same request. Returning the sentinel
|
|
730
|
+
// (instead of breaking) lets the loop keep advancing the flat offset until
|
|
731
|
+
// it crosses into the fallback vendor's pool span.
|
|
732
|
+
if (dedupe && proxy) {
|
|
733
|
+
if (dedupe.attempted.has(proxy)) {
|
|
734
|
+
return { kind: "dedupe-skip" };
|
|
735
|
+
}
|
|
736
|
+
dedupe.attempted.add(proxy);
|
|
737
|
+
}
|
|
307
738
|
const requestInit: NativeFetchInit = {
|
|
308
739
|
headers: options.headers,
|
|
309
740
|
method,
|
|
310
741
|
...(proxy ? { proxy } : {}),
|
|
311
|
-
signal:
|
|
742
|
+
...(signal ? { signal } : {}),
|
|
312
743
|
};
|
|
313
744
|
if (options.body !== undefined) {
|
|
314
745
|
requestInit.body = normalizeNativeFetchBody(options.body);
|
|
315
746
|
}
|
|
316
|
-
const response = await
|
|
317
|
-
|
|
318
|
-
|
|
747
|
+
const response = await fetchWithHttpRedirectPolicy(
|
|
748
|
+
requestUrl,
|
|
749
|
+
requestInit,
|
|
750
|
+
options.redirectPolicy,
|
|
751
|
+
);
|
|
319
752
|
const headers = Object.fromEntries(response.headers.entries());
|
|
320
753
|
|
|
321
754
|
if (statusRetryCodes && response.status >= 400) {
|
|
@@ -340,9 +773,19 @@ async function fetchNativeHttp(
|
|
|
340
773
|
return toNativeHttpResponse(response);
|
|
341
774
|
} catch (error) {
|
|
342
775
|
if (error instanceof SyntaxError) {
|
|
343
|
-
throw
|
|
776
|
+
throw redactSensitiveError(
|
|
777
|
+
error,
|
|
778
|
+
serializedUrl.sensitiveValues,
|
|
779
|
+
serializedUrl.requestUrl,
|
|
780
|
+
serializedUrl.redactedUrl,
|
|
781
|
+
);
|
|
344
782
|
}
|
|
345
|
-
const transportError =
|
|
783
|
+
const transportError: NativeHttpAttemptError = redactSensitiveError(
|
|
784
|
+
toHttpTransportError(error, clientOptions.signal, controller?.signal),
|
|
785
|
+
serializedUrl.sensitiveValues,
|
|
786
|
+
serializedUrl.requestUrl,
|
|
787
|
+
serializedUrl.redactedUrl,
|
|
788
|
+
);
|
|
346
789
|
transportError.proxyUsed = Boolean(proxy);
|
|
347
790
|
throw transportError;
|
|
348
791
|
} finally {
|
|
@@ -358,26 +801,32 @@ async function fetchNativeHttpStream(
|
|
|
358
801
|
clientOptions: HttpClientOptions,
|
|
359
802
|
warn: (message: string) => void,
|
|
360
803
|
): Promise<HttpStreamResponse> {
|
|
361
|
-
const
|
|
804
|
+
const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
|
|
805
|
+
const { requestUrl } = serializedUrl;
|
|
362
806
|
const controller = options.timeout ? new AbortController() : undefined;
|
|
807
|
+
const signal = mergeAbortSignals(clientOptions.signal, controller?.signal);
|
|
363
808
|
const timeoutHandle = options.timeout
|
|
364
809
|
? setTimeout(() => controller?.abort(), options.timeout)
|
|
365
810
|
: undefined;
|
|
366
811
|
|
|
367
812
|
try {
|
|
813
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
368
814
|
const proxy = await resolveNativeProxy(options, clientOptions, warn);
|
|
815
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
369
816
|
const requestInit: NativeFetchInit = {
|
|
370
817
|
headers: options.headers,
|
|
371
818
|
method,
|
|
372
819
|
...(proxy ? { proxy } : {}),
|
|
373
|
-
signal:
|
|
820
|
+
...(signal ? { signal } : {}),
|
|
374
821
|
};
|
|
375
822
|
if (options.body !== undefined) {
|
|
376
823
|
requestInit.body = normalizeNativeFetchBody(options.body);
|
|
377
824
|
}
|
|
378
|
-
const response = await
|
|
379
|
-
|
|
380
|
-
|
|
825
|
+
const response = await fetchWithHttpRedirectPolicy(
|
|
826
|
+
requestUrl,
|
|
827
|
+
requestInit,
|
|
828
|
+
options.redirectPolicy,
|
|
829
|
+
);
|
|
381
830
|
|
|
382
831
|
if (response.status >= 400 && options.throwOnHttpError !== false) {
|
|
383
832
|
await drainNativeResponseBody(response);
|
|
@@ -387,12 +836,24 @@ async function fetchNativeHttpStream(
|
|
|
387
836
|
});
|
|
388
837
|
}
|
|
389
838
|
|
|
390
|
-
|
|
839
|
+
// Per-call timeout remains header-scoped, while the ambient request signal
|
|
840
|
+
// stays attached to the response body for its full consumption lifetime.
|
|
841
|
+
return toNativeHttpStreamResponse(response, serializedUrl, clientOptions.signal);
|
|
391
842
|
} catch (error) {
|
|
392
843
|
if (error instanceof SyntaxError) {
|
|
393
|
-
throw
|
|
844
|
+
throw redactSensitiveError(
|
|
845
|
+
error,
|
|
846
|
+
serializedUrl.sensitiveValues,
|
|
847
|
+
serializedUrl.requestUrl,
|
|
848
|
+
serializedUrl.redactedUrl,
|
|
849
|
+
);
|
|
394
850
|
}
|
|
395
|
-
throw
|
|
851
|
+
throw redactSensitiveError(
|
|
852
|
+
toHttpTransportError(error, clientOptions.signal, controller?.signal),
|
|
853
|
+
serializedUrl.sensitiveValues,
|
|
854
|
+
serializedUrl.requestUrl,
|
|
855
|
+
serializedUrl.redactedUrl,
|
|
856
|
+
);
|
|
396
857
|
} finally {
|
|
397
858
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
398
859
|
}
|
|
@@ -417,22 +878,33 @@ export function createHttpClient(
|
|
|
417
878
|
method: string,
|
|
418
879
|
options: RequestOptions & { body?: unknown } = {},
|
|
419
880
|
): Promise<HttpResponse> {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
881
|
+
const { explicitRetry, headersOptions, methodName, retryOptions } = (() => {
|
|
882
|
+
try {
|
|
883
|
+
if (!baseUrl && !isAbsoluteUrl(url)) {
|
|
884
|
+
throw new TransportError(
|
|
885
|
+
"ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
|
|
886
|
+
{ code: "transport_invalid_url" },
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
assertNoHttpTransportOverrides(options);
|
|
890
|
+
const redirectPolicy = snapshotHttpRedirectPolicy(options);
|
|
891
|
+
const headersOptions = {
|
|
892
|
+
...withClientHeaders(options, clientOptions, options.body),
|
|
893
|
+
redirectPolicy,
|
|
894
|
+
};
|
|
895
|
+
const methodName = normalizeHttpMethod(method);
|
|
896
|
+
const explicitRetry = headersOptions.retry !== undefined;
|
|
897
|
+
const retryOptions =
|
|
898
|
+
normalizeProxyTransportRetryOptions(headersOptions.retry, {
|
|
899
|
+
label: "HTTP",
|
|
900
|
+
}) ??
|
|
901
|
+
(explicitRetry ? undefined : createDefaultProxyTransportRetryOptions({ label: "HTTP" }));
|
|
902
|
+
if (retryOptions) validateUnsafeProxyTransportRetryMethods(retryOptions, "HTTP");
|
|
903
|
+
return { explicitRetry, headersOptions, methodName, retryOptions };
|
|
904
|
+
} catch (error) {
|
|
905
|
+
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
906
|
+
}
|
|
907
|
+
})();
|
|
436
908
|
const retryEnabled = Boolean(
|
|
437
909
|
retryOptions &&
|
|
438
910
|
retryOptions.attempts > 1 &&
|
|
@@ -449,6 +921,56 @@ export function createHttpClient(
|
|
|
449
921
|
? { ...headersOptions, throwOnHttpError: false }
|
|
450
922
|
: headersOptions;
|
|
451
923
|
|
|
924
|
+
// Span the whole vendor chain on transport failures. Like ctx.stealth, a
|
|
925
|
+
// policy-managed proxy resolves a *different* endpoint/vendor per attempt
|
|
926
|
+
// (the flat proxyAttemptOffset rotates across the concatenated vendor pool
|
|
927
|
+
// spans), so a transport failure should advance to the next endpoint —
|
|
928
|
+
// potentially crossing into the fallback vendor — rather than stopping at
|
|
929
|
+
// the per-endpoint retry budget and stranding the request on the primary
|
|
930
|
+
// vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
|
|
931
|
+
// only for implicit, safe-method allocator requests; explicit retry
|
|
932
|
+
// policies (their documented `attempts` ceiling), unsafe methods, and
|
|
933
|
+
// static/non-registry vendors keep the retry budget. Status-code retries
|
|
934
|
+
// stay bounded by the retry budget regardless; only transport rotation gets
|
|
935
|
+
// the full span.
|
|
936
|
+
const policyProxy: ProviderProxyPolicy | undefined = (() => {
|
|
937
|
+
const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
|
|
938
|
+
return policy && typeof policy === "object" ? policy : undefined;
|
|
939
|
+
})();
|
|
940
|
+
const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
|
|
941
|
+
const transportAttemptCap = retryOptions
|
|
942
|
+
? resolvePolicyTransportAttemptCap({
|
|
943
|
+
policy: policyProxy,
|
|
944
|
+
usesPolicyAllocator,
|
|
945
|
+
retryAttempts: retryOptions.attempts,
|
|
946
|
+
explicitRetry,
|
|
947
|
+
method: methodName,
|
|
948
|
+
})
|
|
949
|
+
: 1;
|
|
950
|
+
|
|
951
|
+
// Track resolved endpoints across a policy-allocator chain. Successive
|
|
952
|
+
// attempts rotate the flat offset across the concatenated vendor pool
|
|
953
|
+
// spans, but an under-filled allocation (fewer live endpoints than the
|
|
954
|
+
// configured pool size) makes the modulo mapping repeat endpoints before
|
|
955
|
+
// the offset reaches the next vendor. Rather than re-hammering an
|
|
956
|
+
// already-tried endpoint under backoff, fetchNativeHttp returns a skip
|
|
957
|
+
// sentinel for a duplicate; the loop then advances the flat offset without
|
|
958
|
+
// issuing the request, so it keeps walking toward — and into — the fallback
|
|
959
|
+
// vendor's pool span instead of stalling on the primary vendor.
|
|
960
|
+
// De-duplication is gated on the SAME predicate that widens the attempt cap
|
|
961
|
+
// (implicit, safe-method, registry-chain rotation). It must NOT engage for
|
|
962
|
+
// an explicit retry policy: there the caller's `attempts` count is the
|
|
963
|
+
// contract and each attempt must issue against whatever endpoint it resolves
|
|
964
|
+
// — even a repeat — instead of being silently skipped (which would collapse
|
|
965
|
+
// a `poolSize: 1` + `attempts: 3` request to a single fetch).
|
|
966
|
+
const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
|
|
967
|
+
policy: policyProxy,
|
|
968
|
+
usesPolicyAllocator,
|
|
969
|
+
explicitRetry,
|
|
970
|
+
method: methodName,
|
|
971
|
+
});
|
|
972
|
+
const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set<string>() } : undefined;
|
|
973
|
+
|
|
452
974
|
const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
|
|
453
975
|
fetchNativeHttp(
|
|
454
976
|
baseUrl,
|
|
@@ -459,25 +981,58 @@ export function createHttpClient(
|
|
|
459
981
|
warnOnce,
|
|
460
982
|
statusRetryEnabled ? retryOptions?.statusCodes : undefined,
|
|
461
983
|
proxyAttemptOffset,
|
|
984
|
+
dedupeContext,
|
|
462
985
|
);
|
|
463
986
|
|
|
464
987
|
if (!retryEnabled || !retryOptions) {
|
|
988
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
465
989
|
const outcome = await executeOnce();
|
|
990
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
991
|
+
// Single-shot path never de-duplicates (dedupeContext is undefined),
|
|
992
|
+
// but keep the union total.
|
|
993
|
+
throw new TransportError("HTTP request produced no terminal result", {
|
|
994
|
+
code: "retry_exhausted",
|
|
995
|
+
});
|
|
996
|
+
}
|
|
466
997
|
if (isHttpStatusOutcome(outcome)) {
|
|
467
998
|
throw toUpstreamHttpError(outcome.status);
|
|
468
999
|
}
|
|
469
1000
|
return outcome;
|
|
470
1001
|
}
|
|
471
1002
|
|
|
1003
|
+
let lastError: unknown;
|
|
472
1004
|
let lastErrorCode: string | undefined;
|
|
473
1005
|
let lastStatus: number | undefined;
|
|
474
|
-
|
|
1006
|
+
// `attempt` walks the flat proxy offset across the full chain span; `issued`
|
|
1007
|
+
// counts requests that were actually sent (skipped duplicate offsets do not
|
|
1008
|
+
// increment it). Retry summaries and the status-retry budget must reflect
|
|
1009
|
+
// issued requests, not the raw offset, so they stay accurate when partial
|
|
1010
|
+
// allocations skip offsets.
|
|
1011
|
+
let issued = 0;
|
|
1012
|
+
for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
|
|
1013
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
1014
|
+
// Whether this offset actually issued a request (vs. a skipped duplicate),
|
|
1015
|
+
// so the catch counts a thrown *transport* failure once without
|
|
1016
|
+
// double-counting a status outcome that already incremented before it
|
|
1017
|
+
// re-threw as an upstream HTTP error.
|
|
1018
|
+
let issuedThisAttempt = false;
|
|
475
1019
|
try {
|
|
476
1020
|
const outcome = await executeOnce(attempt - 1);
|
|
1021
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
1022
|
+
// Duplicate endpoint from a partial allocation: advance the flat
|
|
1023
|
+
// offset without issuing the request (no backoff, not a failure) so
|
|
1024
|
+
// the loop keeps rotating toward the fallback vendor.
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
issued += 1;
|
|
1028
|
+
issuedThisAttempt = true;
|
|
477
1029
|
if (isHttpStatusOutcome(outcome)) {
|
|
478
1030
|
lastStatus = outcome.status;
|
|
479
|
-
if (outcome.retryable &&
|
|
480
|
-
await sleep(
|
|
1031
|
+
if (outcome.retryable && issued < retryOptions.attempts) {
|
|
1032
|
+
await sleep(
|
|
1033
|
+
computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers),
|
|
1034
|
+
clientOptions.signal,
|
|
1035
|
+
);
|
|
481
1036
|
continue;
|
|
482
1037
|
}
|
|
483
1038
|
throw toUpstreamHttpError(outcome.status);
|
|
@@ -488,10 +1043,10 @@ export function createHttpClient(
|
|
|
488
1043
|
throw toUpstreamHttpError(response.status);
|
|
489
1044
|
}
|
|
490
1045
|
|
|
491
|
-
if (
|
|
1046
|
+
if (issued > 1) {
|
|
492
1047
|
const summary: HttpRetrySummary = {
|
|
493
|
-
attempts:
|
|
494
|
-
retries:
|
|
1048
|
+
attempts: issued,
|
|
1049
|
+
retries: issued - 1,
|
|
495
1050
|
...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
|
|
496
1051
|
transport: "native",
|
|
497
1052
|
...(lastErrorCode ? { lastErrorCode } : {}),
|
|
@@ -501,11 +1056,14 @@ export function createHttpClient(
|
|
|
501
1056
|
}
|
|
502
1057
|
return response;
|
|
503
1058
|
} catch (error) {
|
|
1059
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
1060
|
+
if (!issuedThisAttempt) issued += 1;
|
|
1061
|
+
lastError = error;
|
|
504
1062
|
lastErrorCode = proxyTransportRetryErrorCode(error);
|
|
505
1063
|
lastStatus = proxyTransportRetryErrorStatus(error);
|
|
506
1064
|
const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
|
|
507
1065
|
if (
|
|
508
|
-
attempt <
|
|
1066
|
+
attempt < transportAttemptCap &&
|
|
509
1067
|
shouldRetryProxyTransportAttempt({
|
|
510
1068
|
error,
|
|
511
1069
|
explicitRetry,
|
|
@@ -514,13 +1072,23 @@ export function createHttpClient(
|
|
|
514
1072
|
proxyUsed,
|
|
515
1073
|
})
|
|
516
1074
|
) {
|
|
517
|
-
await sleep(
|
|
1075
|
+
await sleep(
|
|
1076
|
+
computeProxyTransportRetryDelayMs(retryOptions, attempt),
|
|
1077
|
+
clientOptions.signal,
|
|
1078
|
+
);
|
|
518
1079
|
continue;
|
|
519
1080
|
}
|
|
520
1081
|
throw error;
|
|
521
1082
|
}
|
|
522
1083
|
}
|
|
523
1084
|
|
|
1085
|
+
// Reached when the attempt cap is consumed without a terminal outcome —
|
|
1086
|
+
// e.g. the final offsets of a partial allocation all resolved to
|
|
1087
|
+
// already-tried endpoints and were skipped. Surface the last real transport
|
|
1088
|
+
// failure rather than a synthetic exhaustion error.
|
|
1089
|
+
if (lastError !== undefined) {
|
|
1090
|
+
throw lastError;
|
|
1091
|
+
}
|
|
524
1092
|
throw new TransportError("HTTP retry exhausted without a terminal result", {
|
|
525
1093
|
code: "retry_exhausted",
|
|
526
1094
|
});
|
|
@@ -531,15 +1099,27 @@ export function createHttpClient(
|
|
|
531
1099
|
method: string,
|
|
532
1100
|
options: RequestOptions & { body?: unknown } = {},
|
|
533
1101
|
): Promise<HttpStreamResponse> {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1102
|
+
const { headersOptions, methodName } = (() => {
|
|
1103
|
+
try {
|
|
1104
|
+
if (!baseUrl && !isAbsoluteUrl(url)) {
|
|
1105
|
+
throw new TransportError(
|
|
1106
|
+
"ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
|
|
1107
|
+
{ code: "transport_invalid_url" },
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
assertNoHttpTransportOverrides(options);
|
|
1111
|
+
const redirectPolicy = snapshotHttpRedirectPolicy(options);
|
|
1112
|
+
return {
|
|
1113
|
+
headersOptions: {
|
|
1114
|
+
...withClientHeaders(options, clientOptions, options.body),
|
|
1115
|
+
redirectPolicy,
|
|
1116
|
+
},
|
|
1117
|
+
methodName: normalizeHttpMethod(method),
|
|
1118
|
+
};
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
1121
|
+
}
|
|
1122
|
+
})();
|
|
543
1123
|
return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
|
|
544
1124
|
}
|
|
545
1125
|
|